saas-voc-gateway.js 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855
  1. /*
  2. * Source uploaded to Parse's Function table by scripts/deploy-cloud-function.mjs.
  3. * The Fmode runtime injects Parse, request and response. Keep this file free of
  4. * imports so it can run in the managed standalone-function evaluator.
  5. */
  6. const ACTIONS = new Set([
  7. 'context.get', 'workspace.list', 'workspace.members.list', 'workspace.member.update', 'data-source.list', 'import.list', 'audit.list',
  8. 'domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list',
  9. 'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
  10. 'analysis.list', 'analysis.create', 'analysis.update', 'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
  11. 'action.list', 'action.create', 'action.update', 'alert.list', 'alert.create', 'alert.update',
  12. 'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
  13. 'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
  14. 'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
  15. 'listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score', 'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run', 'listing.score-job.create',
  16. 'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel',
  17. 'listing.versions.list', 'listing.version.create', 'listing.version.adopt',
  18. 'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
  19. 'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic'
  20. ]);
  21. const CLASS_BY_READ_ACTION = {
  22. 'workspace.list': 'VocWorkspace',
  23. 'workspace.members.list': 'VocWorkspaceMember',
  24. 'data-source.list': 'VocSourceConnection',
  25. 'import.list': 'VocImportBatch',
  26. 'audit.list': 'VocAuditLog',
  27. 'domestic.products.list': 'VocProduct',
  28. 'domestic.reviews.list': 'VocReview',
  29. 'domestic.relations.list': 'VocProductRelation',
  30. 'sync.jobs.list': 'VocSyncJob',
  31. 'sync.job.events': 'VocSyncJobEvent',
  32. 'analysis.list': 'VocAnalysisRun',
  33. 'insight-decision.list': 'VocInsightDecision',
  34. 'action.list': 'VocActionItem',
  35. 'alert.list': 'VocAlert',
  36. 'knowledge.products.list': 'VocProductKnowledge',
  37. 'ai.prompts.list': 'VocPromptConfig',
  38. 'listing.products.list': 'VocListingSourceSnapshot',
  39. 'listing.score-job.list': 'VocListingScoreJob',
  40. 'listing.versions.list': 'VocListingVersion',
  41. 'competitor.tasks.list': 'VocCompetitorOptimizationTask',
  42. };
  43. const WRITE_ROLES = new Set(['owner', 'admin', 'editor']);
  44. const ADMIN_ROLES = new Set(['owner', 'admin']);
  45. const PRODUCT_SCOPE_FIELDS = {
  46. VocProduct: 'productId', VocReview: 'productId', VocDailyMetric: 'productId',
  47. VocProductRelation: 'ownProductId', VocProductKnowledge: 'productId',
  48. VocListingSourceSnapshot: 'productId', VocListingCurrentScore: 'productId', VocListingVersion: 'productId'
  49. ,VocCompetitorListingSnapshot: 'productId', VocCompetitorListingChange: 'productId', VocCompetitorOptimizationTask: 'ownProductId'
  50. };
  51. const READ_FILTER_FIELDS = {
  52. VocProduct: ['platform', 'role'],
  53. VocReview: ['platform', 'productId'],
  54. VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
  55. VocSyncJob: ['status', 'platform'],
  56. VocSyncJobEvent: ['jobId', 'eventType'],
  57. VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
  58. VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
  59. VocActionItem: ['status', 'actionType', 'productKey'],
  60. VocAlert: ['status', 'alertType', 'productKey'],
  61. VocProductKnowledge: ['productId', 'productKey', 'status'],
  62. VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
  63. VocListingScoreJob: ['status', 'platform'],
  64. VocListingVersion: ['productId', 'status'],
  65. VocCompetitorListingSnapshot: ['platform', 'productId'],
  66. VocCompetitorListingChange: ['platform', 'productId'],
  67. VocCompetitorOptimizationTask: ['status', 'ownProductId'],
  68. };
  69. const UPSTREAM_PATHS = {
  70. amazon: [/^\/test$/, /^\/orders(?:\/[A-Za-z0-9._~-]+)?(?:\/items)?$/, /^\/sales\/orderMetrics$/, /^\/catalog\/items(?:\/[A-Za-z0-9._~-]+)?$/, /^\/listings\/items\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)?$/, /^\/customerFeedback\/items\/[A-Za-z0-9._~-]+\/reviews\/(?:topics|trends|browseNode)$/, /^\/sellers\/marketplaceParticipations$/, /^\/returns$/],
  71. sorftime: [/^\/api\/(?:CategoryTree|CategoryRequest|CategoryProducts|ProductQuery|ProductRequest|AsinSalesVolume|SimilarProductRealtimeRequest|SimilarProductRealtimeRequestStatusQuery|SimilarProductRealtimeRequestCollection|ProductReviewsQuery|MonitorQuery|KeywordQuery|ASINRequestKeyword|KeywordProductRanking|KeywordSearchResultTrend|ProductVariationHistory)$/],
  72. tikhub: [/^\/v1\/(?:tiktok|instagram)\/[A-Za-z0-9._~/-]+$/],
  73. domestic: [/^\/jd\/(?:get-item-detail|get-item-comments|search-item-list)\/v1$/],
  74. };
  75. const UPSTREAM_OPERATIONS = {
  76. amazon: new Set(['get', 'post']),
  77. sorftime: new Set(['get', 'post', 'forward']),
  78. tikhub: new Set(['get', 'post', 'forward']),
  79. domestic: new Set(['gateway.get']),
  80. };
  81. let activeRequest;
  82. let activeResponse;
  83. let functionQueue = Promise.resolve();
  84. async function handler(request, response) {
  85. let release;
  86. const previous = functionQueue;
  87. functionQueue = new Promise((resolve) => { release = resolve; });
  88. await previous;
  89. try {
  90. return await runHandler(request, response);
  91. } finally {
  92. activeRequest = null;
  93. activeResponse = null;
  94. release();
  95. }
  96. }
  97. function fail(status, code, message) {
  98. activeResponse.status(status).json({ success: false, code, message, requestId: requestId() });
  99. }
  100. function requestId() {
  101. return activeRequest.headers && (activeRequest.headers['x-request-id'] || activeRequest.headers['X-Request-Id']) || 'cloud-' + Date.now().toString(36);
  102. }
  103. function paramsOf() {
  104. const value = activeRequest.body && activeRequest.body.params;
  105. if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
  106. const payload = value.payload && typeof value.payload === 'object' && !Array.isArray(value.payload) ? value.payload : {};
  107. return { ...payload, action: value.action, workspaceId: value.workspaceId, platform: value.platform, idempotencyKey: value.idempotencyKey };
  108. }
  109. async function activeMember(workspaceId) {
  110. const user = activeRequest.user;
  111. if (!user) return null;
  112. const query = new Parse.Query('VocWorkspaceMember');
  113. query.equalTo('workspaceId', workspaceId);
  114. query.equalTo('userId', user.id);
  115. query.equalTo('status', 'active');
  116. return query.first({ useMasterKey: true });
  117. }
  118. async function accessibleWorkspaces() {
  119. const memberQuery = new Parse.Query('VocWorkspaceMember');
  120. memberQuery.equalTo('userId', activeRequest.user.id);
  121. memberQuery.equalTo('status', 'active');
  122. memberQuery.limit(100);
  123. const members = await memberQuery.find({ useMasterKey: true });
  124. const ids = [...new Set(members.map((member) => String(member.get('workspaceId') || '')).filter(Boolean))];
  125. if (!ids.length) return [];
  126. const workspaceQuery = new Parse.Query('VocWorkspace');
  127. workspaceQuery.containedIn('publicId', ids);
  128. workspaceQuery.equalTo('status', 'active');
  129. workspaceQuery.limit(100);
  130. const workspaces = await workspaceQuery.find({ useMasterKey: true });
  131. const roleByWorkspace = new Map(members.map((member) => [String(member.get('workspaceId') || ''), String(member.get('role') || 'viewer')]));
  132. return workspaces.map((workspace) => ({ ...safeValue(workspace.toJSON(), 0), role: roleByWorkspace.get(String(workspace.get('publicId') || '')) || 'viewer' }));
  133. }
  134. async function authorize(action, workspaceId) {
  135. if (!activeRequest.user) throw { status: 401, code: 'unauthenticated', message: '需要登录' };
  136. const member = await activeMember(workspaceId);
  137. if (!member) throw { status: 403, code: 'workspace_access_denied', message: '无权访问该 workspace' };
  138. const role = String(member.get('role') || 'viewer');
  139. if (action === 'workspace.members.list' && !ADMIN_ROLES.has(role)) throw { status: 403, code: 'forbidden', message: '权限不足' };
  140. if (isWriteAction(action) && !WRITE_ROLES.has(role)) throw { status: 403, code: 'viewer_write_forbidden', message: 'Viewer 不能执行写入操作' };
  141. const configuredProductIds = member.get('productIds');
  142. const productIds = Array.isArray(configuredProductIds)
  143. ? [...new Set(configuredProductIds.filter((value) => typeof value === 'string' && value.length <= 200))]
  144. : (ADMIN_ROLES.has(role) ? null : []);
  145. return { member, role, productIds };
  146. }
  147. function isWriteAction(action) {
  148. return /\.create$|\.update$|\.upsert$|\.delete$|\.enqueue$|\.retry$|\.cancel$|\.adopt$|\.run$|^competitor\.refresh$/.test(action)
  149. || action === 'ai.chat' || action === 'ai.test';
  150. }
  151. function boundedLimit(value, fallback) {
  152. const limit = Number(value || fallback);
  153. return Number.isInteger(limit) ? Math.max(1, Math.min(limit, 100)) : fallback;
  154. }
  155. function safeValue(value, depth) {
  156. if (depth > 5 || value === null || value === undefined) return value;
  157. if (value instanceof Date) return value;
  158. if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
  159. if (typeof value !== 'object') return value;
  160. const output = {};
  161. Object.keys(value).slice(0, 100).forEach((key) => {
  162. if (/token|secret|password|credential|authorization|master/i.test(key)) return;
  163. output[key] = safeValue(value[key], depth + 1);
  164. });
  165. return output;
  166. }
  167. async function readMany(className, workspaceId, params, productIds, maximum = 100) {
  168. const query = new Parse.Query(className);
  169. query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
  170. applyReadFilters(query, className, params);
  171. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
  172. query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
  173. }
  174. const limit = boundedLimit(params.limit, 50, maximum);
  175. if (typeof params.cursor === 'string' && params.cursor) {
  176. const cursorDate = new Date(params.cursor);
  177. if (Number.isNaN(cursorDate.getTime())) throw { status: 400, code: 'invalid_cursor', message: '分页游标无效' };
  178. query.lessThan('createdAt', cursorDate);
  179. }
  180. if (Number.isInteger(params.skip) && params.skip >= 0) query.skip(Math.min(params.skip, 100000));
  181. query.limit(limit);
  182. query.descending('createdAt');
  183. const rows = await query.find({ useMasterKey: true });
  184. const last = rows[rows.length - 1];
  185. const nextCursor = Number.isInteger(params.skip)
  186. ? (rows.length === limit ? String(params.skip + rows.length) : null)
  187. : (rows.length === limit && last && last.createdAt ? last.createdAt.toISOString() : null);
  188. return { items: rows.map((row) => safeValue(row.toJSON(), 0)), nextCursor };
  189. }
  190. function applyReadFilters(query, className, params) {
  191. const fields = READ_FILTER_FIELDS[className] || [];
  192. for (const field of fields) {
  193. const value = params && params[field];
  194. if (value !== undefined && value !== null && value !== '') query.equalTo(field, value);
  195. }
  196. if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) {
  197. query.contains('title', params.search.trim().slice(0, 200));
  198. }
  199. }
  200. async function readAll(className, workspaceId, params, productIds, maximum = 10000) {
  201. const items = [];
  202. while (items.length < maximum) {
  203. const page = await readMany(className, workspaceId, { ...params, limit: Math.min(100, maximum - items.length), skip: items.length }, productIds, 100);
  204. items.push(...page.items);
  205. if (!page.nextCursor || !page.items.length) break;
  206. }
  207. return items;
  208. }
  209. async function readAllWhere(className, workspaceId, filters, productIds, maximum = 10000) {
  210. const items = [];
  211. while (items.length < maximum) {
  212. const query = new Parse.Query(className);
  213. query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
  214. Object.entries(filters || {}).forEach(([key, value]) => {
  215. if (value !== undefined && value !== null && value !== '') query.equalTo(key, value);
  216. });
  217. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
  218. query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
  219. }
  220. const pageSize = Math.min(100, maximum - items.length);
  221. query.skip(items.length); query.limit(pageSize); query.descending('createdAt');
  222. const rows = await query.find({ useMasterKey: true });
  223. items.push(...rows.map((row) => safeValue(row.toJSON(), 0)));
  224. if (rows.length < pageSize) break;
  225. }
  226. return items;
  227. }
  228. async function readOne(className, workspaceId, objectId, productIds) {
  229. if (!objectId || typeof objectId !== 'string' || objectId.length > 200) throw { status: 400, code: 'invalid_id', message: '标识无效' };
  230. const query = new Parse.Query(className);
  231. query.equalTo('workspaceId', workspaceId);
  232. query.equalTo(className === 'VocProduct' ? 'productId' : 'publicId', objectId);
  233. if (productIds !== null && className === 'VocProduct') query.containedIn('productId', productIds);
  234. const row = await query.first({ useMasterKey: true });
  235. return row ? safeValue(row.toJSON(), 0) : null;
  236. }
  237. function bodyData(params, workspaceId) {
  238. const output = {};
  239. Object.keys(params).forEach((key) => {
  240. if (!['action', 'workspaceId', 'platform', 'payload', 'idempotencyKey', 'userId', 'jobId', 'productId', 'decisionId', 'analysisId', 'actionId', 'alertId', 'taskId', 'runId', 'versionId'].includes(key)) output[key] = safeValue(params[key], 0);
  241. });
  242. output.workspaceId = workspaceId;
  243. return output;
  244. }
  245. function storageData(params, workspaceId) {
  246. const output = {};
  247. Object.keys(params).forEach((key) => {
  248. if (!['action', 'workspaceId', 'userId'].includes(key)) output[key] = safeValue(params[key], 0);
  249. });
  250. output.workspaceId = workspaceId;
  251. return output;
  252. }
  253. function publicId() {
  254. return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
  255. }
  256. async function findObject(className, workspaceId, field, value) {
  257. const query = new Parse.Query(className);
  258. query.equalTo('workspaceId', workspaceId);
  259. query.equalTo(field, value);
  260. return query.first({ useMasterKey: true });
  261. }
  262. async function findObjects(className, workspaceId, filters, limit, productIds) {
  263. const query = new Parse.Query(className); query.equalTo('workspaceId', workspaceId);
  264. Object.entries(filters || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') query.equalTo(key, value); });
  265. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
  266. query.limit(boundedLimit(limit, 50)); query.descending('createdAt');
  267. return (await query.find({ useMasterKey: true })).map((item) => safeValue(item.toJSON(), 0));
  268. }
  269. function presentReadItem(action, item) {
  270. if (action === 'listing.score-job.list') return presentListingJob(item);
  271. const output = { ...item };
  272. if (!output.id) output.id = output.publicId || output.objectId || output.productId;
  273. if (action === 'data-source.list') {
  274. output.kind = output.kind || output.connectionKind || '';
  275. output.credentialStorage = output.credentialStorage || output.metadata?.credentialStorage || 'external_secret';
  276. }
  277. if (action === 'import.list') output.id = output.id || output.publicId;
  278. if (action === 'domestic.reviews.list') output.reviewId = output.reviewId || output.reviewKey || output.sourceReviewId || output.id;
  279. if (action === 'sync.jobs.list') output.id = output.publicId || output.id;
  280. if (action === 'sync.job.events') output.type = output.type || output.eventType || '';
  281. if (action === 'listing.versions.list') output.id = output.publicId || output.id;
  282. return output;
  283. }
  284. function presentListingJob(item) {
  285. const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
  286. return {
  287. ...payload,
  288. id: payload.id || item.publicId || item.objectId,
  289. workspaceId: payload.workspaceId || item.workspaceId,
  290. platform: payload.platform || item.platform || 'jd',
  291. idempotencyKey: payload.idempotencyKey || item.idempotencyKey,
  292. requestHash: payload.requestHash || item.requestHash,
  293. status: item.status || payload.status,
  294. };
  295. }
  296. function presentListingJobItem(item) {
  297. const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
  298. return {
  299. ...payload,
  300. id: payload.id || item.publicId || item.objectId,
  301. jobId: payload.jobId || item.jobId,
  302. workspaceId: payload.workspaceId || item.workspaceId,
  303. productId: payload.productId || item.productId,
  304. sourceHash: payload.sourceHash || item.sourceHash,
  305. status: item.status || payload.status,
  306. };
  307. }
  308. function listingSourcePayload(item) {
  309. return item && item.payload && typeof item.payload === 'object' ? item.payload : item;
  310. }
  311. function listingSourceMatches(item, filter) {
  312. const source = listingSourcePayload(item);
  313. const normalizedSearch = String(filter.search || '').trim().toLowerCase();
  314. if (normalizedSearch && !`${source.productId || ''} ${source.title || ''}`.toLowerCase().includes(normalizedSearch)) return false;
  315. if (filter.categoryId && !(Array.isArray(source.categoryIds) ? source.categoryIds : []).includes(filter.categoryId)) return false;
  316. if (filter.itemStatus && source.itemStatus !== filter.itemStatus) return false;
  317. if (filter.coverageStatus && item.coverageStatus !== filter.coverageStatus) return false;
  318. if (filter.scoreStatus && item.scoreStatus !== filter.scoreStatus) return false;
  319. if (filter.aiScoreStatus && item.aiScoreStatus !== filter.aiScoreStatus) return false;
  320. const score = Number(item.latestOverallScore);
  321. if (filter.minScore !== undefined && (!Number.isFinite(score) || score < Number(filter.minScore))) return false;
  322. if (filter.maxScore !== undefined && (!Number.isFinite(score) || score > Number(filter.maxScore))) return false;
  323. return true;
  324. }
  325. async function resolveListingScoreSources(workspaceId, scope, productIds) {
  326. if (!scope || !['selected', 'filter'].includes(scope.mode)) throw { status: 400, code: 'listing_scope_invalid', message: '评分范围无效' };
  327. const allRows = await readAll('VocListingSourceSnapshot', workspaceId, {}, productIds, 10000);
  328. const latestByProduct = new Map();
  329. allRows.filter((row) => row.platform === 'jd' && row.isCurrent !== false).forEach((row) => {
  330. const source = listingSourcePayload(row);
  331. const productId = String(source.productId || row.productId || '').trim();
  332. if (productId && !latestByProduct.has(productId)) latestByProduct.set(productId, row);
  333. });
  334. if (scope.mode === 'selected') {
  335. const selectedIds = [...new Set(Array.isArray(scope.productIds) ? scope.productIds.map((value) => String(value).trim()).filter(Boolean) : [])];
  336. if (!selectedIds.length || selectedIds.length > 100) throw { status: 400, code: 'listing_scope_invalid', message: '评分商品范围无效' };
  337. if (productIds !== null && selectedIds.some((id) => !productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '评分范围无效' };
  338. const selected = selectedIds.map((id) => latestByProduct.get(id)).filter(Boolean);
  339. if (selected.length !== selectedIds.length) throw { status: 422, code: 'listing_source_incomplete', message: '评分商品数据不完整' };
  340. return selected;
  341. }
  342. const filter = scope.filter && typeof scope.filter === 'object' ? scope.filter : {};
  343. return [...latestByProduct.values()].filter((row) => listingSourceMatches(row, filter));
  344. }
  345. async function createListingScoreItems(workspaceId, jobId, sources, requestedAt) {
  346. const objects = sources.map((row) => {
  347. const source = listingSourcePayload(row);
  348. const productId = String(source.productId || row.productId || '');
  349. const sourceHash = String(source.sourceHash || row.sourceHash || '');
  350. const id = publicId();
  351. const payload = { id, jobId, workspaceId, productId, sourceHash, status: 'queued', attempts: 0, errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: requestedAt };
  352. const item = new Parse.Object('VocListingScoreItem');
  353. item.set('publicId', id); item.set('naturalKey', `${jobId}|${productId}|${sourceHash}`); item.set('workspaceId', workspaceId);
  354. item.set('jobId', jobId); item.set('productId', productId); item.set('sourceHash', sourceHash); item.set('status', 'queued'); item.set('payload', payload);
  355. return item;
  356. });
  357. for (let index = 0; index < objects.length; index += 50) await Parse.Object.saveAll(objects.slice(index, index + 50), { useMasterKey: true });
  358. }
  359. async function activateListingScoreJob(workspaceId, jobId, job) {
  360. const object = await findObject('VocListingScoreJob', workspaceId, 'publicId', jobId);
  361. if (!object) throw { status: 500, code: 'listing_score_job_missing', message: '评分任务保存失败' };
  362. const activated = { ...job, status: 'queued', updatedAt: new Date().toISOString() };
  363. object.set('status', 'queued'); object.set('payload', activated);
  364. await object.save(null, { useMasterKey: true });
  365. return activated;
  366. }
  367. function presentReadPage(action, page) {
  368. return { ...page, items: page.items.map((item) => presentReadItem(action, item)) };
  369. }
  370. async function visibleProductIds(className, workspaceId, productIds) {
  371. if (!productIds || !['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'].includes(className)) return productIds;
  372. const relationQuery = new Parse.Query('VocProductRelation');
  373. relationQuery.equalTo('workspaceId', workspaceId);
  374. relationQuery.containedIn('ownProductId', productIds);
  375. relationQuery.limit(10000);
  376. const relations = await relationQuery.find({ useMasterKey: true });
  377. const competitorIds = relations.map((relation) => String(relation.get('competitorProductId') || '')).filter(Boolean);
  378. return [...new Set(productIds.concat(competitorIds))];
  379. }
  380. async function updateByPublicId(className, workspaceId, id, params) {
  381. const object = await findObject(className, workspaceId, 'publicId', id);
  382. if (!object) throw { status: 404, code: 'not_found', message: '记录不存在' };
  383. Object.entries(bodyData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
  384. object.set('updatedBy', activeRequest.user.id);
  385. await object.save(null, { useMasterKey: true });
  386. return safeValue(object.toJSON(), 0);
  387. }
  388. async function appendAudit(workspaceId, action, entityType, entityId, metadata) {
  389. const audit = new Parse.Object('VocAuditLog');
  390. audit.set('publicId', publicId()); audit.set('workspaceId', workspaceId);
  391. audit.set('actorUserId', activeRequest.user.id); audit.set('action', action);
  392. audit.set('entityType', entityType); audit.set('entityId', entityId || null);
  393. audit.set('metadata', safeValue(metadata || {}, 0));
  394. await audit.save(null, { useMasterKey: true });
  395. }
  396. async function createIdempotent(className, workspaceId, keyField, keyValue, params) {
  397. if (keyValue) {
  398. const existing = await findObject(className, workspaceId, keyField, keyValue);
  399. if (existing) return { value: safeValue(existing.toJSON(), 0), idempotent: true };
  400. }
  401. const object = new Parse.Object(className);
  402. Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
  403. if (!object.get('publicId')) object.set('publicId', publicId());
  404. if (keyValue) object.set(keyField, keyValue);
  405. object.set('createdBy', activeRequest.user.id);
  406. await object.save(null, { useMasterKey: true });
  407. return { value: safeValue(object.toJSON(), 0), idempotent: false };
  408. }
  409. async function writeObject(className, params, workspaceId) {
  410. const object = new Parse.Object(className);
  411. Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
  412. object.set('createdBy', activeRequest.user.id);
  413. await object.save(null, { useMasterKey: true });
  414. return safeValue(object.toJSON(), 0);
  415. }
  416. async function aiChat(params) {
  417. const env = typeof process !== 'undefined' && process.env ? process.env : {};
  418. const token = env.FMODE_AI_TOKEN || env.AI_API_KEY || '';
  419. if (!token) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务尚未配置' };
  420. if (!Array.isArray(params.messages) || params.messages.length < 1 || params.messages.length > 100) throw { status: 400, code: 'invalid_ai_messages', message: '消息参数无效' };
  421. const body = { messages: params.messages, model: String(params.model || env.FMODE_AI_MODEL || '').slice(0, 120), stream: false };
  422. ['temperature', 'presence_penalty', 'frequency_penalty', 'max_tokens', 'response_format', 'thinking', 'websearch'].forEach((key) => { if (params[key] !== undefined) body[key] = safeValue(params[key], 0); });
  423. const baseUrl = String(env.FMODE_AI_BASE_URL || '').replace(/\/+$/, '');
  424. if (!/^https:\/\//i.test(baseUrl)) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务地址未配置' };
  425. const response = await fetch(baseUrl + '/v1/chat/completions', { method: 'POST', headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
  426. const data = await response.json().catch(() => null);
  427. if (!response.ok) throw { status: 502, code: 'ai_upstream_failed', message: 'AI 上游请求失败' };
  428. return data;
  429. }
  430. async function fixedUpstream(provider, params) {
  431. const path = normalizeUpstreamPath(params.path);
  432. if (!path || !UPSTREAM_PATHS[provider].some((pattern) => pattern.test(path))) throw { status: 400, code: 'upstream_path_not_allowed', message: '上游路径不在白名单中' };
  433. const operation = String(params.operation || '').trim().toLowerCase();
  434. if (!UPSTREAM_OPERATIONS[provider].has(operation)) throw { status: 400, code: 'upstream_operation_not_allowed', message: '上游操作不在白名单中' };
  435. const env = typeof process !== 'undefined' && process.env ? process.env : {};
  436. const base = String(env[provider.toUpperCase() + '_BASE_URL'] || (provider === 'domestic' ? env.FMODE_BASE_URL || '' : '')).replace(/\/+$/, '');
  437. const token = String(env[provider.toUpperCase() + '_API_KEY'] || (provider === 'domestic' ? env.FMODE_API_KEY || '' : ''));
  438. if (!/^https:\/\//i.test(base) || !token) throw { status: 503, code: 'upstream_not_configured', message: '上游数据源尚未配置' };
  439. const method = operation === 'post' || operation === 'forward' ? 'POST' : 'GET';
  440. const url = new URL(path.replace(/^\/+/, ''), base + '/');
  441. const query = params.query && typeof params.query === 'object' ? params.query : params.params && typeof params.params === 'object' ? params.params : {};
  442. if (method === 'GET') Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); });
  443. let response;
  444. try {
  445. response = await fetch(url, { method, headers: { accept: 'application/json', authorization: 'Bearer ' + token, ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) }, ...(method === 'POST' ? { body: JSON.stringify(params.body || query) } : {}), signal: AbortSignal.timeout(Number(env.UPSTREAM_TIMEOUT_MS || 30000)) });
  446. } catch (error) {
  447. const name = error && error.name;
  448. if (name === 'TimeoutError' || name === 'AbortError') throw { status: 504, code: 'upstream_timeout', message: '上游请求超时' };
  449. throw { status: 502, code: 'upstream_unreachable', message: '上游数据源不可达' };
  450. }
  451. const data = await response.json().catch(() => null);
  452. if (response.status === 401 || response.status === 403) throw { status: 502, code: 'upstream_auth_failed', message: '上游鉴权失败' };
  453. if (response.status === 429) throw { status: 503, code: 'upstream_rate_limited', message: '上游请求受限' };
  454. if (!response.ok) throw { status: 502, code: 'upstream_request_failed', message: '上游请求失败' };
  455. return safeValue(data, 0);
  456. }
  457. function normalizeUpstreamPath(value) {
  458. const path = String(value || '').trim();
  459. if (!path || path.length > 300 || path.includes('://') || path.includes('\\') || path.includes('?') || path.includes('#')) return '';
  460. return '/' + path.replace(/^\/+/, '');
  461. }
  462. async function snapshot(workspaceId, params, productIds) {
  463. const [products, reviews, metrics, relations, imports] = await Promise.all([
  464. readAll('VocProduct', workspaceId, {}, productIds, 10000),
  465. readAll('VocReview', workspaceId, {}, productIds, 10000),
  466. readAll('VocDailyMetric', workspaceId, {}, productIds, 20000),
  467. readAll('VocProductRelation', workspaceId, {}, productIds, 10000),
  468. readMany('VocImportBatch', workspaceId, { limit: 1 }, null),
  469. ]);
  470. const importRow = imports.items[0] || {};
  471. const dailyTotals = aggregateDailyMetrics(metrics);
  472. const productById = new Map(products.map((product) => [product.productId, product]));
  473. const grouped = new Map();
  474. relations.forEach((relation) => {
  475. const key = relation.ownProductId;
  476. if (!grouped.has(key)) grouped.set(key, { ownProductId: key, ownProductKey: relation.ownProductKey, model: productById.get(key)?.model || relation.ownModel || '', category: relation.category || productById.get(key)?.category3 || '', competitors: [] });
  477. grouped.get(key).competitors.push(relation);
  478. });
  479. const categories2 = new Set(products.map((product) => product.category2).filter(Boolean));
  480. const categories3 = new Set(products.map((product) => product.category3).filter(Boolean));
  481. return {
  482. schemaVersion: 1,
  483. generatedAt: new Date().toISOString(),
  484. caseName: importRow.caseName || workspaceId,
  485. platform: params.platform || 'jd',
  486. source: { sourceFile: importRow.sourceFile || '', sourceHash: importRow.sourceHash || '', sheets: importRow.sheets || [], dateRange: { start: importRow.sourceDateStart || '', end: importRow.sourceDateEnd || '' } },
  487. summary: { metricRows: metrics.length, metricProducts: new Set(metrics.map((metric) => metric.productId)).size, mappingRows: relations.length, relations: relations.length, uniqueCompetitorProducts: new Set(relations.map((relation) => relation.competitorProductId)).size, category2Count: categories2.size, category3Count: categories3.size, reviewCount: reviews.length },
  488. dailyTotals,
  489. products,
  490. mappingGroups: [...grouped.values()],
  491. relations,
  492. reviews: reviews.map((review) => ({ productId: review.productId, reviewId: review.reviewKey || review.sourceReviewId || review.objectId, rating: Number(review.rating || 0), content: review.content || '', ...(review.reviewDate ? { reviewDate: review.reviewDate } : {}) })),
  493. quality: importRow.quality || { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
  494. };
  495. }
  496. function aggregateDailyMetrics(metrics) {
  497. const totals = new Map();
  498. const numericFields = ['gmv', 'soldUnits', 'transactionOrders', 'transactionCustomers', 'impressions', 'clicks', 'views', 'visitors', 'cartUnits', 'orderAmount', 'orderUnits', 'orderCount', 'refundAmount', 'refundUnits', 'refundOrders'];
  499. metrics.forEach((metric) => {
  500. const date = String(metric.metricDate || metric.date || '');
  501. if (!date) return;
  502. if (!totals.has(date)) totals.set(date, { date, gmv: 0, soldUnits: 0, transactionOrders: 0, transactionCustomers: 0, impressions: 0, clicks: 0, views: 0, visitors: 0, cartUnits: 0, orderAmount: 0, orderUnits: 0, orderCount: 0, refundAmount: 0, refundUnits: 0, refundOrders: 0, conversionRate: 0, clickThroughRate: 0, averageUnitPrice: 0, refundToGmvRate: 0 });
  503. const total = totals.get(date);
  504. numericFields.forEach((field) => { total[field] += Number(metric[field] || 0); });
  505. });
  506. return [...totals.values()].sort((left, right) => left.date.localeCompare(right.date)).map((total) => ({ ...total, conversionRate: total.visitors ? total.orderCount / total.visitors : 0, clickThroughRate: total.impressions ? total.clicks / total.impressions : 0, averageUnitPrice: total.soldUnits ? total.gmv / total.soldUnits : 0, refundToGmvRate: total.gmv ? total.refundAmount / total.gmv : 0 }));
  507. }
  508. async function runHandler(request, response) {
  509. activeRequest = request;
  510. activeResponse = response;
  511. const input = paramsOf();
  512. const action = input.action;
  513. if (!ACTIONS.has(action)) return fail(400, 'cloud_action_not_allowed', '不支持的业务操作');
  514. if (!activeRequest.user) return fail(401, 'unauthenticated', '需要登录');
  515. let workspaceId = String(input.workspaceId || '').trim();
  516. if (!workspaceId && (action === 'context.get' || action === 'workspace.list')) {
  517. const membershipQuery = new Parse.Query('VocWorkspaceMember');
  518. membershipQuery.equalTo('userId', activeRequest.user && activeRequest.user.id);
  519. membershipQuery.equalTo('status', 'active');
  520. const membership = await membershipQuery.first({ useMasterKey: true });
  521. workspaceId = membership ? String(membership.get('workspaceId') || '') : '';
  522. }
  523. if (!workspaceId || workspaceId.length > 200) return fail(400, 'workspace_id_invalid', 'workspaceId 无效');
  524. try {
  525. const auth = await authorize(action, workspaceId);
  526. if (action === 'context.get') return activeResponse.json({ success: true, data: { principal: { userId: activeRequest.user.id, email: activeRequest.user.get('email') || '', displayName: activeRequest.user.get('username') || '' }, workspaces: await accessibleWorkspaces(), capabilities: {}, productScope: { all: auth.productIds === null, productIds: auth.productIds || [] } }, requestId: requestId() });
  527. if (action === 'workspace.list') return activeResponse.json({ success: true, data: { items: await accessibleWorkspaces(), nextCursor: null }, requestId: requestId() });
  528. if (action === 'domestic.snapshot') return activeResponse.json({ success: true, data: await snapshot(workspaceId, input, auth.productIds), requestId: requestId() });
  529. if (action === 'domestic.product.get') return activeResponse.json({ success: true, data: { product: await readOne('VocProduct', workspaceId, input.productId, auth.productIds) }, requestId: requestId() });
  530. if (action === 'sync.job.get') return activeResponse.json({ success: true, data: { job: await readOne('VocSyncJob', workspaceId, input.jobId, auth.productIds) }, requestId: requestId() });
  531. if (action === 'insight-decision.get') return activeResponse.json({ success: true, data: { decision: await readOne('VocInsightDecision', workspaceId, input.decisionId, auth.productIds) }, requestId: requestId() });
  532. if (action === 'competitor.run.get') {
  533. const run = await findObject('VocCompetitorListingRefreshRun', workspaceId, 'publicId', input.runId); if (!run) throw { status: 404, code: 'competitor_run_not_found', message: '刷新任务不存在' };
  534. return activeResponse.json({ success: true, data: { run: safeValue(run.toJSON(), 0) }, requestId: requestId() });
  535. }
  536. if (action === 'competitor.history') {
  537. if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  538. const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
  539. const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
  540. return activeResponse.json({ success: true, data: { workspaceId, platform: input.platform || 'jd', productId: input.productId, snapshots, changes, collectionRuns: [] }, requestId: requestId() });
  541. }
  542. if (action === 'competitor.overview') {
  543. const products = await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000);
  544. const targets = products.filter((item) => item.role === 'competitor');
  545. const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
  546. const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
  547. const latest = new Map(); snapshots.forEach((item) => { if (!latest.has(item.productId)) latest.set(item.productId, item); });
  548. const latestChange = new Map(); changes.forEach((item) => { if (!latestChange.has(item.productId)) latestChange.set(item.productId, item); });
  549. const items = targets.map((product) => ({ productId: product.productId, platform: input.platform || 'jd', title: product.title || null, brand: product.brand || null, mainImageUrl: product.detail?.imageUrl || null, category: product.category3 || product.category2 || null, relatedProducts: [], currentSnapshot: latest.get(product.productId) || null, latestChange: latestChange.get(product.productId) || null, latestCollectionResult: null, monitorStatus: latest.has(product.productId) ? (latestChange.has(product.productId) ? 'changed' : 'unchanged') : 'not_initialized' }));
  550. const facets = (key) => [...new Set(items.map((item) => item[key]).filter(Boolean))].map((value) => ({ value, count: items.filter((item) => item[key] === value).length }));
  551. return activeResponse.json({ success: true, data: { summary: { targetTotal: items.length, baselineTotal: latest.size, comparedTotal: 0, changedIn7d: changes.length, priceChangedIn7d: changes.filter((item) => item.changeTypes?.includes('price')).length, contentChangedIn7d: changes.filter((item) => item.changeTypes?.some((type) => type !== 'price')).length, latestFailedTotal: 0, lastRefreshAt: null, lastSuccessfulRefreshAt: null, historyStatus: latest.size ? 'baseline_only' : 'not_initialized' }, items, facets: { brands: facets('brand'), categories: facets('category') } }, requestId: requestId() });
  552. }
  553. if (action === 'competitor.alerts') {
  554. const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
  555. const alerts = changes.flatMap((change) => (change.changeTypes || []).map((field) => ({ id: change.publicId || change.objectId, workspaceId, platform: input.platform || 'jd', rule: field === 'main_image' ? 'main_image_change' : field === 'key_specifications' ? 'specification_change' : `${field}_change`, productIds: [change.productId], changeIds: [change.publicId || change.objectId], detectedAt: change.detectedAt, evidence: change.changes || [] })));
  556. return activeResponse.json({ success: true, data: { alerts }, requestId: requestId() });
  557. }
  558. if (action === 'competitor.impacts') {
  559. const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
  560. const relationQuery = new Parse.Query('VocProductRelation'); relationQuery.equalTo('workspaceId', workspaceId); relationQuery.limit(10000); const relations = await relationQuery.find({ useMasterKey: true });
  561. const ownByCompetitor = new Map(relations.map((relation) => [String(relation.get('competitorProductId') || ''), String(relation.get('ownProductId') || '')]));
  562. const impacts = changes.map((change) => ({ competitorProductId: change.productId, ownProductId: ownByCompetitor.get(change.productId) || '', changeId: change.publicId || change.objectId, currentSnapshotId: change.currentSnapshotId, previousSnapshotId: change.previousSnapshotId, weakestDimension: null, improvementPotential: null, impactScore: null, scoreSource: null, explanation: '当前变更已记录;未发现正式本品评分,暂不计算影响分数。' })).filter((item) => item.ownProductId);
  563. return activeResponse.json({ success: true, data: { impacts }, requestId: requestId() });
  564. }
  565. if (action === 'competitor.refresh') {
  566. const activeRunQuery = new Parse.Query('VocCompetitorListingRefreshRun'); activeRunQuery.equalTo('workspaceId', workspaceId); activeRunQuery.equalTo('platform', input.platform || 'jd'); activeRunQuery.containedIn('status', ['queued', 'running']);
  567. if (await activeRunQuery.first({ useMasterKey: true })) throw { status: 409, code: 'competitor_listing_refresh_running', message: '竞品刷新任务正在运行' };
  568. const targets = (await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000)).filter((item) => item.role === 'competitor');
  569. const run = await writeObject('VocCompetitorListingRefreshRun', { publicId: publicId(), workspaceId, platform: input.platform || 'jd', trigger: 'manual', status: 'queued', total: targets.length, completed: 0, baseline: 0, unchanged: 0, changed: 0, failed: 0, itemResults: [], requestedAt: new Date(), startedAt: null, completedAt: null }, workspaceId);
  570. await appendAudit(workspaceId, 'competitor.refresh.requested', 'competitor_refresh_run', run.publicId, { total: targets.length });
  571. return activeResponse.status(202).json({ success: true, data: { run }, requestId: requestId() });
  572. }
  573. if (action === 'listing.product.get') {
  574. if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  575. const source = (await findObjects('VocListingSourceSnapshot', workspaceId, { productId: input.productId, isCurrent: true }, 1, auth.productIds))[0];
  576. if (!source) throw { status: 404, code: 'listing_product_not_found', message: 'Listing 商品不存在' };
  577. const scores = await readAllWhere('VocListingCurrentScore', workspaceId, { productId: input.productId }, auth.productIds, 100);
  578. const versions = await readAllWhere('VocListingVersion', workspaceId, { productId: input.productId }, auth.productIds, 100);
  579. const scorePayloads = scores.map((item) => item.payload || item);
  580. const jdVocScores = scorePayloads.filter((item) => item.rubricVersion === 'jd-voc-v0.5' && (item.scoreKind === 'jd_voc_hybrid_ai' || item.scoreKind === 'jd_voc_rules'));
  581. const jdVocScore = jdVocScores.find((item) => item.scoreKind === 'jd_voc_hybrid_ai' && item.aiReview && item.aiReview.status === 'completed') || jdVocScores.find((item) => item.scoreKind === 'jd_voc_rules') || null;
  582. const legacyScore = scorePayloads.find((item) => item.scoreKind === 'hybrid_ai' && item.aiStatus === 'completed') || scorePayloads.find((item) => item.scoreKind === 'rules') || null;
  583. const displayJdVoc = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true' && jdVocScore;
  584. return activeResponse.json({ success: true, data: { source: source.payload || source, coverage: displayJdVoc ? jdVocScore.coverage : legacyScore && legacyScore.coverage || null, displayScoreKind: displayJdVoc ? 'jd_voc' : 'legacy', currentScore: legacyScore, rulePrecheck: scorePayloads.find((item) => item.scoreKind === 'rules') || null, jdVocScore, versionsSummary: { items: versions, nextCursor: null } }, requestId: requestId() });
  585. }
  586. if (action === 'listing.product.score') {
  587. const allScores = (await findObjects('VocListingCurrentScore', workspaceId, { productId: input.productId }, 100, auth.productIds)).map((item) => item.payload || item);
  588. const score = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true'
  589. ? allScores.find((item) => item.rubricVersion === 'jd-voc-v0.5' && item.scoreKind === 'jd_voc_hybrid_ai') || allScores.find((item) => item.rubricVersion === 'jd-voc-v0.5' && item.scoreKind === 'jd_voc_rules')
  590. : allScores.find((item) => item.scoreKind === 'hybrid_ai') || allScores.find((item) => item.scoreKind === 'rules');
  591. if (!score) throw { status: 404, code: 'listing_score_not_found', message: 'Listing 评分不存在' };
  592. return activeResponse.json({ success: true, data: { score, displayScoreKind: String(score.scoreKind || '').startsWith('jd_voc_') ? 'jd_voc' : 'legacy' }, requestId: requestId() });
  593. }
  594. if (action === 'listing.jd-voc-score.get') {
  595. if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  596. const score = (await findObjects('VocListingCurrentScore', workspaceId, { productId: input.productId }, 100, auth.productIds)).map((item) => item.payload || item).find((item) => item.rubricVersion === 'jd-voc-v0.5' && (item.scoreKind === 'jd_voc_rules' || item.scoreKind === 'jd_voc_hybrid_ai'));
  597. if (!score) throw { status: 404, code: 'jd_voc_score_not_found', message: 'JD-VOC 评分不存在' };
  598. return activeResponse.json({ success: true, data: { score }, requestId: requestId() });
  599. }
  600. if (action === 'listing.jd-voc-image-review.run') throw { status: 409, code: 'jd_voc_image_review_not_deployed', message: '影子识图需由后端服务执行,当前托管函数未启用该开关' };
  601. if (action === 'listing.overview') {
  602. const sources = await readAllWhere('VocListingSourceSnapshot', workspaceId, { isCurrent: true }, auth.productIds, 10000);
  603. const scores = await readAllWhere('VocListingCurrentScore', workspaceId, {}, auth.productIds, 20000);
  604. const scoreByProduct = new Map(scores.map((score) => [score.productId, score]));
  605. const rows = sources.map((source) => { const score = scoreByProduct.get(source.productId); const payload = source.payload || source; const scorePayload = score?.payload || score; return { productId: source.productId, title: source.title || payload.title || null, imageUrl: payload.imageUrl || null, categoryId: source.categoryIds?.[0] || null, categoryName: null, categoryPath: [], categoryIds: source.categoryIds || [], itemStatusLabel: source.itemStatus || '', scoreNature: score ? 'formal_ai' : 'unscored', scoreNatureLabel: score ? '正式评分' : '未评分', overallScore: score?.overallScore ?? null, overallRate: score?.overallScore == null ? null : Number(score.overallScore) / 100, dimensions: scorePayload?.dimensions || {}, weakestDimension: null, improvementPotential: null, scoredAt: score?.scoredAt || null, syncedAt: source.observedAt }; });
  606. const scored = rows.filter((row) => row.overallScore !== null);
  607. return activeResponse.json({ success: true, data: { items: rows.slice(0, boundedLimit(input.limit, 25)), nextCursor: null, summary: { sourceTotal: rows.length, matchedTotal: rows.length, scoredTotal: scored.length, simulationTotal: 0, averageScore: scored.length ? scored.reduce((sum, row) => sum + row.overallScore, 0) / scored.length : null, medianScore: null, scoreDistribution: [], dimensionStats: {}, categoryFacets: [], scoreNatureFacets: [], snapshotId: 'managed-current', generatedAt: new Date().toISOString() } }, requestId: requestId() });
  608. }
  609. if (action === 'listing.score-job.get') {
  610. const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
  611. return activeResponse.json({ success: true, data: { job: presentListingJob(safeValue(job.toJSON(), 0)) }, requestId: requestId() });
  612. }
  613. if (action === 'listing.score-job.items') return activeResponse.json({ success: true, data: { items: (await findObjects('VocListingScoreItem', workspaceId, { jobId: input.jobId, status: input.status }, input.limit, auth.productIds)).map(presentListingJobItem), nextCursor: null }, requestId: requestId() });
  614. if (action === 'listing.products.list') {
  615. const page = await readMany('VocListingSourceSnapshot', workspaceId, { ...input, limit: input.limit || 25 }, auth.productIds);
  616. const items = page.items.map((item) => presentReadItem(action, { ...item, ...item.payload, productId: item.productId, syncedAt: item.observedAt }));
  617. return activeResponse.json({ success: true, data: { items, nextCursor: page.nextCursor, summary: { sourceTotal: items.length, eligible: items.filter((item) => item.coverageStatus === 'eligible').length, scored: items.filter((item) => item.scoreStatus === 'scored').length, partial: items.filter((item) => item.scoreStatus === 'partial').length, blocked: items.filter((item) => item.coverageStatus === 'blocked').length, failed: items.filter((item) => item.scoreStatus === 'failed').length, averageScore: null, lastCatalogSyncAt: null } }, requestId: requestId() });
  618. }
  619. if (action === 'competitor.tasks.list') {
  620. const page = await readMany('VocCompetitorOptimizationTask', workspaceId, input, auth.productIds);
  621. return activeResponse.json({ success: true, data: { tasks: page.items.map((item) => presentReadItem(action, item)) }, requestId: requestId() });
  622. }
  623. if (action === 'listing.versions.list') {
  624. const page = await readMany('VocListingVersion', workspaceId, input, auth.productIds);
  625. page.items = page.items.map((item) => ({ ...presentReadItem(action, item), ...item.payload, content: item.payload?.content || item.content || null }));
  626. return activeResponse.json({ success: true, data: page, requestId: requestId() });
  627. }
  628. if (CLASS_BY_READ_ACTION[action]) return activeResponse.json({ success: true, data: presentReadPage(action, await readMany(CLASS_BY_READ_ACTION[action], workspaceId, input, auth.productIds)), requestId: requestId() });
  629. if (action === 'ai.status') { const env = typeof process !== 'undefined' && process.env ? process.env : {}; return activeResponse.json({ success: true, data: { service: 'Fmode AI', configured: Boolean(env.FMODE_AI_TOKEN || env.AI_API_KEY), baseUrl: '', defaultModel: env.FMODE_AI_MODEL || '', proxyEndpoint: 'ai.chat' }, requestId: requestId() }); }
  630. if (action === 'ai.test') { const data = await aiChat({ messages: [{ role: 'user', content: '只回复 OK' }], model: input.model }); return activeResponse.json({ success: true, data: { ok: true, model: data && data.model || input.model || '', message: data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content || 'OK' }, requestId: requestId() }); }
  631. if (action === 'ai.chat') return activeResponse.json({ success: true, data: await aiChat(input), requestId: requestId() });
  632. if (action === 'workspace.member.update') {
  633. if (!input.userId || !['owner','admin','editor','viewer'].includes(input.role)) throw { status: 400, code: 'invalid_member', message: '成员参数无效' };
  634. const naturalKey = workspaceId + ':' + input.userId;
  635. const result = await createIdempotent('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey, { ...input, status: input.status || 'active' });
  636. if (result.idempotent) {
  637. result.value = await updateByPublicId('VocWorkspaceMember', workspaceId, result.value.publicId || result.value.objectId, input).catch(async () => {
  638. const object = await findObject('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey);
  639. Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value));
  640. await object.save(null, { useMasterKey: true }); return safeValue(object.toJSON(), 0);
  641. });
  642. }
  643. await appendAudit(workspaceId, 'member.updated', 'workspace_member', input.userId, { role: input.role, status: input.status });
  644. return activeResponse.json({ success: true, data: { member: result.value }, requestId: requestId() });
  645. }
  646. if (action === 'sync.enqueue') {
  647. if (!Array.isArray(input.productIds) || input.productIds.length < 1 || input.productIds.length > 100) throw { status: 400, code: 'invalid_sync_scope', message: '同步商品范围无效' };
  648. if (auth.productIds !== null && input.productIds.some((id) => !auth.productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '包含无权访问的商品' };
  649. const key = String(input.idempotencyKey || '').trim();
  650. if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
  651. const result = await createIdempotent('VocSyncJob', workspaceId, 'naturalKey', workspaceId + ':' + key, { ...input, publicId: publicId(), status: 'pending', progress: 0, attempts: 0, maxAttempts: 3, requestedAt: new Date() });
  652. await appendAudit(workspaceId, result.idempotent ? 'sync.reused' : 'sync.requested', 'sync_job', result.value.publicId, { productCount: input.productIds.length });
  653. return activeResponse.json({ success: true, data: { job: result.value, idempotent: result.idempotent }, requestId: requestId() });
  654. }
  655. if (action === 'sync.job.retry' || action === 'sync.job.cancel') {
  656. const job = await findObject('VocSyncJob', workspaceId, 'publicId', input.jobId);
  657. if (!job) throw { status: 404, code: 'job_not_found', message: '任务不存在' };
  658. const status = String(job.get('status') || '');
  659. if (action.endsWith('retry') && !['failed','partial','cancelled'].includes(status)) throw { status: 409, code: 'sync_job_not_retryable', message: '任务不可重试' };
  660. if (action.endsWith('cancel') && !['pending','processing'].includes(status)) throw { status: 409, code: 'sync_job_not_cancellable', message: '任务不可取消' };
  661. job.set('status', action.endsWith('retry') ? 'pending' : 'cancelled'); job.set('progress', action.endsWith('retry') ? 0 : job.get('progress') || 0);
  662. await job.save(null, { useMasterKey: true });
  663. await appendAudit(workspaceId, action.endsWith('retry') ? 'sync.retried' : 'sync.cancelled', 'sync_job', input.jobId, {});
  664. return activeResponse.json({ success: true, data: { job: safeValue(job.toJSON(), 0) }, requestId: requestId() });
  665. }
  666. if (action === 'analysis.create') {
  667. const result = await createIdempotent('VocAnalysisRun', workspaceId, 'publicId', input.idempotencyKey || publicId(), { ...input, status: 'pending', requestedBy: activeRequest.user.id, requestedAt: new Date(), result: null, evidenceCount: 0 });
  668. await appendAudit(workspaceId, result.idempotent ? 'analysis.reused' : 'analysis.created', 'analysis_run', result.value.publicId, {});
  669. return activeResponse.json({ success: true, data: { analysis: result.value, idempotent: result.idempotent }, requestId: requestId() });
  670. }
  671. if (action === 'analysis.update') return activeResponse.json({ success: true, data: { analysis: await updateByPublicId('VocAnalysisRun', workspaceId, input.analysisId, input) }, requestId: requestId() });
  672. if (action === 'insight-decision.create') {
  673. if (!input.sourceAnalysisId || !input.sourceInsightId || !['confirmed','rejected','needs_more_evidence'].includes(input.decision)) throw { status: 400, code: 'invalid_decision', message: '决策参数无效' };
  674. const currentQuery = new Parse.Query('VocInsightDecision'); currentQuery.equalTo('workspaceId', workspaceId); currentQuery.equalTo('sourceAnalysisId', input.sourceAnalysisId); currentQuery.equalTo('sourceInsightId', input.sourceInsightId); currentQuery.equalTo('isCurrent', true);
  675. const current = await currentQuery.first({ useMasterKey: true });
  676. const decision = await writeObject('VocInsightDecision', { ...input, publicId: publicId(), decidedBy: activeRequest.user.id, decidedAt: new Date(), version: Number(current && current.get('version') || 0) + 1, supersedesId: current && current.get('publicId') || null, isCurrent: true }, workspaceId);
  677. if (current) { current.set('isCurrent', false); await current.save(null, { useMasterKey: true }); }
  678. await appendAudit(workspaceId, 'decision.created', 'insight_decision', decision.publicId, { version: decision.version });
  679. return activeResponse.json({ success: true, data: { decision }, requestId: requestId() });
  680. }
  681. if (action === 'action.create') {
  682. const key = String(input.creationKey || input.idempotencyKey || '').trim();
  683. if (key.length < 8) throw { status: 400, code: 'creation_key_required', message: '缺少 creationKey' };
  684. const result = await createIdempotent('VocActionItem', workspaceId, 'creationKey', key, { ...input, publicId: publicId(), createdBy: activeRequest.user.id });
  685. await appendAudit(workspaceId, result.idempotent ? 'action.reused' : 'action.created', 'action_item', result.value.publicId, {});
  686. return activeResponse.json({ success: true, data: { action: result.value, idempotent: result.idempotent }, requestId: requestId() });
  687. }
  688. if (action === 'action.update') return activeResponse.json({ success: true, data: { action: await updateByPublicId('VocActionItem', workspaceId, input.actionId, input) }, requestId: requestId() });
  689. if (action === 'alert.create') {
  690. const alert = await writeObject('VocAlert', { ...input, publicId: publicId(), status: 'open', detectedAt: new Date() }, workspaceId);
  691. await appendAudit(workspaceId, 'alert.created', 'alert', alert.publicId, {});
  692. return activeResponse.json({ success: true, data: { alert }, requestId: requestId() });
  693. }
  694. if (action === 'alert.update') return activeResponse.json({ success: true, data: { alert: await updateByPublicId('VocAlert', workspaceId, input.alertId, input) }, requestId: requestId() });
  695. if (action === 'knowledge.product.upsert') {
  696. if (!input.productKey || !input.productId) throw { status: 400, code: 'invalid_knowledge_product', message: '商品知识参数无效' };
  697. if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  698. const naturalKey = workspaceId + ':' + input.productKey;
  699. let object = await findObject('VocProductKnowledge', workspaceId, 'naturalKey', naturalKey);
  700. if (!object) { object = new Parse.Object('VocProductKnowledge'); object.set('naturalKey', naturalKey); object.set('workspaceId', workspaceId); object.set('createdBy', activeRequest.user.id); }
  701. Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value)); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
  702. await appendAudit(workspaceId, 'knowledge.upserted', 'product_knowledge', input.productKey, {});
  703. return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
  704. }
  705. if (action === 'knowledge.product.delete') {
  706. const object = await findObject('VocProductKnowledge', workspaceId, 'productKey', input.productKey); if (!object) throw { status: 404, code: 'knowledge_not_found', message: '商品知识不存在' };
  707. object.set('status', 'archived'); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
  708. await appendAudit(workspaceId, 'knowledge.archived', 'product_knowledge', input.productKey, {});
  709. return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
  710. }
  711. if (action === 'ai.prompt.update') {
  712. if (!input.promptKey) throw { status: 400, code: 'prompt_key_required', message: '缺少 promptKey' };
  713. let prompt = await findObject('VocPromptConfig', workspaceId, 'promptKey', input.promptKey); if (!prompt) { prompt = new Parse.Object('VocPromptConfig'); prompt.set('workspaceId', workspaceId); prompt.set('promptKey', input.promptKey); prompt.set('naturalKey', workspaceId + ':' + input.promptKey); }
  714. Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => prompt.set(key, value)); prompt.set('updatedBy', activeRequest.user.id); await prompt.save(null, { useMasterKey: true });
  715. await appendAudit(workspaceId, 'prompt.updated', 'prompt_config', input.promptKey, {});
  716. return activeResponse.json({ success: true, data: safeValue(prompt.toJSON(), 0), requestId: requestId() });
  717. }
  718. if (action === 'listing.score-job.create') {
  719. const key = String(input.idempotencyKey || '').trim(); if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
  720. const scope = input.scope; const scoringMode = input.scoringMode === 'rules' ? 'rules' : 'ai'; const rescorePolicy = input.rescorePolicy === 'force' ? 'force' : 'reuse';
  721. const jdVocEnabled = typeof process !== 'undefined' && process.env && process.env.JD_VOC_ENABLED === 'true';
  722. const rubricVersion = input.rubricVersion || (jdVocEnabled ? 'jd-voc-v0.5' : scoringMode === 'ai' ? 'listing-jd-ai-v5' : 'listing-jd-v7');
  723. if (rubricVersion === 'jd-voc-v0.5' && !jdVocEnabled) throw { status: 409, code: 'jd_voc_disabled', message: 'JD-VOC 评分开关未启用' };
  724. const requestHash = JSON.stringify({ platform: input.platform || 'jd', scope: safeValue(scope, 0), scoringMode, rubricVersion, rescorePolicy });
  725. const existing = await findObject('VocListingScoreJob', workspaceId, 'idempotencyKey', key);
  726. if (existing) {
  727. const value = safeValue(existing.toJSON(), 0); const job = presentListingJob(value);
  728. if (job.requestHash !== requestHash) throw { status: 409, code: 'idempotency_conflict', message: '幂等键对应的评分请求不同' };
  729. await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', job.id, {});
  730. return activeResponse.json({ success: true, data: { job, idempotent: true }, requestId: requestId() });
  731. }
  732. const sources = await resolveListingScoreSources(workspaceId, scope, auth.productIds);
  733. if (scoringMode === 'ai' && sources.length > 10) throw { status: 429, code: 'listing_ai_budget_exceeded', message: 'AI 评分商品数量超过单次上限' };
  734. const requestedAt = new Date().toISOString(); const jobId = publicId();
  735. const job = { id: jobId, workspaceId, platform: input.platform || 'jd', idempotencyKey: key, requestHash, rubricVersion, includeAiSuggestions: scoringMode === 'ai', rescorePolicy, scope: safeValue(scope, 0), status: sources.length ? 'initializing' : 'completed', total: sources.length, processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0, requestedBy: activeRequest.user.id, requestedAt, startedAt: null, completedAt: sources.length ? null : requestedAt, updatedAt: requestedAt };
  736. const result = await createIdempotent('VocListingScoreJob', workspaceId, 'idempotencyKey', key, { publicId: jobId, naturalKey: workspaceId + '|' + key, platform: job.platform, idempotencyKey: key, requestHash, status: job.status, payload: job, requestedAt: new Date(requestedAt) });
  737. if (result.idempotent) {
  738. const existingJob = presentListingJob(result.value);
  739. await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', existingJob.id, {});
  740. return activeResponse.json({ success: true, data: { job: existingJob, idempotent: true }, requestId: requestId() });
  741. }
  742. try {
  743. if (sources.length) {
  744. await createListingScoreItems(workspaceId, jobId, sources, requestedAt);
  745. job.status = 'queued';
  746. job.updatedAt = new Date().toISOString();
  747. const activated = await activateListingScoreJob(workspaceId, jobId, job);
  748. await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: sources.length, scoringMode });
  749. return activeResponse.status(202).json({ success: true, data: { job: activated, idempotent: false }, requestId: requestId() });
  750. }
  751. await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: 0, scoringMode });
  752. return activeResponse.status(202).json({ success: true, data: { job, idempotent: false }, requestId: requestId() });
  753. } catch (error) {
  754. const failed = { ...job, status: 'failed', failed: sources.length ? sources.length : 1, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), errorCode: 'listing_score_queue_failed' };
  755. const failedObject = await findObject('VocListingScoreJob', workspaceId, jobId);
  756. if (failedObject) { failedObject.set('status', 'failed'); failedObject.set('payload', failed); await failedObject.save(null, { useMasterKey: true }); }
  757. throw error;
  758. }
  759. }
  760. if (action === 'listing.score-job.retry' || action === 'listing.score-job.cancel') {
  761. const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
  762. const status = String(job.get('status') || '');
  763. if (action.endsWith('retry') && !['failed','partial'].includes(status)) throw { status: 409, code: 'score_job_not_retryable', message: '评分任务不可重试' };
  764. if (action.endsWith('cancel') && !['queued','running'].includes(status)) throw { status: 409, code: 'score_job_not_cancellable', message: '评分任务不可取消' };
  765. const nextStatus = action.endsWith('retry') ? 'queued' : 'cancelled'; const now = new Date().toISOString(); const payload = { ...presentListingJob(safeValue(job.toJSON(), 0)), status: nextStatus, completedAt: nextStatus === 'cancelled' ? now : null, updatedAt: now };
  766. if (nextStatus === 'queued') {
  767. const failedItems = await findObjects('VocListingScoreItem', workspaceId, { jobId: input.jobId, status: 'failed' }, 10000, auth.productIds);
  768. for (const value of failedItems) { const item = await findObject('VocListingScoreItem', workspaceId, 'publicId', value.publicId); if (item) { const itemPayload = { ...presentListingJobItem(value), status: 'queued', errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: now }; item.set('status', 'queued'); item.set('payload', itemPayload); await item.save(null, { useMasterKey: true }); } }
  769. }
  770. job.set('status', nextStatus); job.set('payload', payload); await job.save(null, { useMasterKey: true });
  771. await appendAudit(workspaceId, action.endsWith('retry') ? 'listing.score.retried' : 'listing.score.cancelled', 'listing_score_job', input.jobId, {});
  772. return activeResponse.json({ success: true, data: { job: payload }, requestId: requestId() });
  773. }
  774. if (action === 'listing.version.create') {
  775. if (!input.productId || !input.baseSourceHash || !input.content) throw { status: 400, code: 'invalid_listing_version', message: 'Listing 版本参数无效' };
  776. if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  777. const versions = await findObjects('VocListingVersion', workspaceId, { productId: input.productId }, 100, auth.productIds);
  778. const versionNo = versions.reduce((max, item) => Math.max(max, Number(item.versionNo || 0)), 0) + 1;
  779. const version = await writeObject('VocListingVersion', { publicId: publicId(), naturalKey: workspaceId + ':' + input.productId + ':' + versionNo, productId: input.productId, versionNo, baseSourceHash: input.baseSourceHash, status: 'draft', payload: { content: safeValue(input.content, 0), createdBy: activeRequest.user.id }, versionCreatedAt: new Date() }, workspaceId);
  780. await appendAudit(workspaceId, 'listing.version.created', 'listing_version', version.publicId, { productId: input.productId, versionNo });
  781. return activeResponse.json({ success: true, data: { version }, requestId: requestId() });
  782. }
  783. if (action === 'listing.version.adopt') {
  784. const version = await findObject('VocListingVersion', workspaceId, 'publicId', input.versionId); if (!version) throw { status: 404, code: 'listing_version_not_found', message: 'Listing 版本不存在' };
  785. const productId = version.get('productId'); if (auth.productIds !== null && !auth.productIds.includes(productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  786. version.set('status', 'adopted'); version.set('adoptedAt', new Date()); version.set('adoptedBy', activeRequest.user.id); await version.save(null, { useMasterKey: true });
  787. await appendAudit(workspaceId, 'listing.version.adopted', 'listing_version', input.versionId, { productId });
  788. return activeResponse.json({ success: true, data: { version: safeValue(version.toJSON(), 0) }, requestId: requestId() });
  789. }
  790. if (action === 'competitor.tasks.create') {
  791. if (!input.competitorSnapshotId || !input.ownProductId || !input.dimension) throw { status: 400, code: 'invalid_competitor_task', message: '优化任务参数无效' };
  792. if (auth.productIds !== null && !auth.productIds.includes(input.ownProductId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  793. const key = input.competitorSnapshotId + ':' + input.ownProductId + ':' + input.dimension;
  794. const result = await createIdempotent('VocCompetitorOptimizationTask', workspaceId, 'creationKey', key, { publicId: publicId(), competitorSnapshotId: input.competitorSnapshotId, ownProductId: input.ownProductId, dimension: input.dimension, creationKey: key, status: 'open', impactScore: input.impactScore || null, beforeScore: null, afterScore: null, delta: null, effectiveness: 'not_measured' });
  795. await appendAudit(workspaceId, result.idempotent ? 'competitor.task.reused' : 'competitor.task.created', 'competitor_optimization_task', result.value.publicId, {});
  796. return activeResponse.json({ success: true, data: { task: result.value, idempotent: result.idempotent }, requestId: requestId() });
  797. }
  798. if (action === 'competitor.tasks.update') {
  799. const task = await findObject('VocCompetitorOptimizationTask', workspaceId, 'publicId', input.taskId); if (!task) throw { status: 404, code: 'competitor_task_not_found', message: '优化任务不存在' };
  800. if (auth.productIds !== null && !auth.productIds.includes(task.get('ownProductId'))) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
  801. ['status','beforeScore','afterScore'].forEach((field) => { if (input[field] !== undefined) task.set(field, input[field]); });
  802. const before = Number(task.get('beforeScore')); const after = Number(task.get('afterScore'));
  803. if (Number.isFinite(before) && Number.isFinite(after)) { const delta = after - before; task.set('delta', delta); task.set('effectiveness', delta > 5 ? 'effective' : delta > 0 ? 'partially_effective' : 'ineffective'); }
  804. await task.save(null, { useMasterKey: true }); await appendAudit(workspaceId, 'competitor.task.updated', 'competitor_optimization_task', input.taskId, {});
  805. return activeResponse.json({ success: true, data: { task: safeValue(task.toJSON(), 0) }, requestId: requestId() });
  806. }
  807. if (action.startsWith('upstream.')) return activeResponse.json({ success: true, data: await fixedUpstream(action.slice('upstream.'.length), input), requestId: requestId() });
  808. return fail(501, 'cloud_action_not_implemented', '业务操作暂未配置');
  809. } catch (error) {
  810. const status = Number(error && error.status) || 500;
  811. return fail(status, error && error.code || 'internal_error', status >= 500 ? '服务暂不可用,请稍后重试' : error.message || '请求失败');
  812. }
  813. }