|
|
@@ -0,0 +1,855 @@
|
|
|
+/*
|
|
|
+ * Source uploaded to Parse's Function table by scripts/deploy-cloud-function.mjs.
|
|
|
+ * The Fmode runtime injects Parse, request and response. Keep this file free of
|
|
|
+ * imports so it can run in the managed standalone-function evaluator.
|
|
|
+ */
|
|
|
+const ACTIONS = new Set([
|
|
|
+ 'context.get', 'workspace.list', 'workspace.members.list', 'workspace.member.update', 'data-source.list', 'import.list', 'audit.list',
|
|
|
+ 'domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list',
|
|
|
+ 'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
|
|
|
+ 'analysis.list', 'analysis.create', 'analysis.update', 'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
|
|
|
+ 'action.list', 'action.create', 'action.update', 'alert.list', 'alert.create', 'alert.update',
|
|
|
+ 'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
|
|
|
+ 'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
|
|
|
+ 'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
|
|
|
+ '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',
|
|
|
+ 'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel',
|
|
|
+ 'listing.versions.list', 'listing.version.create', 'listing.version.adopt',
|
|
|
+ 'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
|
|
|
+ 'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic'
|
|
|
+]);
|
|
|
+
|
|
|
+const CLASS_BY_READ_ACTION = {
|
|
|
+ 'workspace.list': 'VocWorkspace',
|
|
|
+ 'workspace.members.list': 'VocWorkspaceMember',
|
|
|
+ 'data-source.list': 'VocSourceConnection',
|
|
|
+ 'import.list': 'VocImportBatch',
|
|
|
+ 'audit.list': 'VocAuditLog',
|
|
|
+ 'domestic.products.list': 'VocProduct',
|
|
|
+ 'domestic.reviews.list': 'VocReview',
|
|
|
+ 'domestic.relations.list': 'VocProductRelation',
|
|
|
+ 'sync.jobs.list': 'VocSyncJob',
|
|
|
+ 'sync.job.events': 'VocSyncJobEvent',
|
|
|
+ 'analysis.list': 'VocAnalysisRun',
|
|
|
+ 'insight-decision.list': 'VocInsightDecision',
|
|
|
+ 'action.list': 'VocActionItem',
|
|
|
+ 'alert.list': 'VocAlert',
|
|
|
+ 'knowledge.products.list': 'VocProductKnowledge',
|
|
|
+ 'ai.prompts.list': 'VocPromptConfig',
|
|
|
+ 'listing.products.list': 'VocListingSourceSnapshot',
|
|
|
+ 'listing.score-job.list': 'VocListingScoreJob',
|
|
|
+ 'listing.versions.list': 'VocListingVersion',
|
|
|
+ 'competitor.tasks.list': 'VocCompetitorOptimizationTask',
|
|
|
+};
|
|
|
+
|
|
|
+const WRITE_ROLES = new Set(['owner', 'admin', 'editor']);
|
|
|
+const ADMIN_ROLES = new Set(['owner', 'admin']);
|
|
|
+const PRODUCT_SCOPE_FIELDS = {
|
|
|
+ VocProduct: 'productId', VocReview: 'productId', VocDailyMetric: 'productId',
|
|
|
+ VocProductRelation: 'ownProductId', VocProductKnowledge: 'productId',
|
|
|
+ VocListingSourceSnapshot: 'productId', VocListingCurrentScore: 'productId', VocListingVersion: 'productId'
|
|
|
+ ,VocCompetitorListingSnapshot: 'productId', VocCompetitorListingChange: 'productId', VocCompetitorOptimizationTask: 'ownProductId'
|
|
|
+};
|
|
|
+const READ_FILTER_FIELDS = {
|
|
|
+ VocProduct: ['platform', 'role'],
|
|
|
+ VocReview: ['platform', 'productId'],
|
|
|
+ VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
|
|
|
+ VocSyncJob: ['status', 'platform'],
|
|
|
+ VocSyncJobEvent: ['jobId', 'eventType'],
|
|
|
+ VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
|
|
|
+ VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
|
|
|
+ VocActionItem: ['status', 'actionType', 'productKey'],
|
|
|
+ VocAlert: ['status', 'alertType', 'productKey'],
|
|
|
+ VocProductKnowledge: ['productId', 'productKey', 'status'],
|
|
|
+ VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
|
|
|
+ VocListingScoreJob: ['status', 'platform'],
|
|
|
+ VocListingVersion: ['productId', 'status'],
|
|
|
+ VocCompetitorListingSnapshot: ['platform', 'productId'],
|
|
|
+ VocCompetitorListingChange: ['platform', 'productId'],
|
|
|
+ VocCompetitorOptimizationTask: ['status', 'ownProductId'],
|
|
|
+};
|
|
|
+const UPSTREAM_PATHS = {
|
|
|
+ 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$/],
|
|
|
+ sorftime: [/^\/api\/(?:CategoryTree|CategoryRequest|CategoryProducts|ProductQuery|ProductRequest|AsinSalesVolume|SimilarProductRealtimeRequest|SimilarProductRealtimeRequestStatusQuery|SimilarProductRealtimeRequestCollection|ProductReviewsQuery|MonitorQuery|KeywordQuery|ASINRequestKeyword|KeywordProductRanking|KeywordSearchResultTrend|ProductVariationHistory)$/],
|
|
|
+ tikhub: [/^\/v1\/(?:tiktok|instagram)\/[A-Za-z0-9._~/-]+$/],
|
|
|
+ domestic: [/^\/jd\/(?:get-item-detail|get-item-comments|search-item-list)\/v1$/],
|
|
|
+};
|
|
|
+const UPSTREAM_OPERATIONS = {
|
|
|
+ amazon: new Set(['get', 'post']),
|
|
|
+ sorftime: new Set(['get', 'post', 'forward']),
|
|
|
+ tikhub: new Set(['get', 'post', 'forward']),
|
|
|
+ domestic: new Set(['gateway.get']),
|
|
|
+};
|
|
|
+let activeRequest;
|
|
|
+let activeResponse;
|
|
|
+let functionQueue = Promise.resolve();
|
|
|
+
|
|
|
+async function handler(request, response) {
|
|
|
+ let release;
|
|
|
+ const previous = functionQueue;
|
|
|
+ functionQueue = new Promise((resolve) => { release = resolve; });
|
|
|
+ await previous;
|
|
|
+ try {
|
|
|
+ return await runHandler(request, response);
|
|
|
+ } finally {
|
|
|
+ activeRequest = null;
|
|
|
+ activeResponse = null;
|
|
|
+ release();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function fail(status, code, message) {
|
|
|
+ activeResponse.status(status).json({ success: false, code, message, requestId: requestId() });
|
|
|
+}
|
|
|
+
|
|
|
+function requestId() {
|
|
|
+ return activeRequest.headers && (activeRequest.headers['x-request-id'] || activeRequest.headers['X-Request-Id']) || 'cloud-' + Date.now().toString(36);
|
|
|
+}
|
|
|
+
|
|
|
+function paramsOf() {
|
|
|
+ const value = activeRequest.body && activeRequest.body.params;
|
|
|
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
|
+ const payload = value.payload && typeof value.payload === 'object' && !Array.isArray(value.payload) ? value.payload : {};
|
|
|
+ return { ...payload, action: value.action, workspaceId: value.workspaceId, platform: value.platform, idempotencyKey: value.idempotencyKey };
|
|
|
+}
|
|
|
+
|
|
|
+async function activeMember(workspaceId) {
|
|
|
+ const user = activeRequest.user;
|
|
|
+ if (!user) return null;
|
|
|
+ const query = new Parse.Query('VocWorkspaceMember');
|
|
|
+ query.equalTo('workspaceId', workspaceId);
|
|
|
+ query.equalTo('userId', user.id);
|
|
|
+ query.equalTo('status', 'active');
|
|
|
+ return query.first({ useMasterKey: true });
|
|
|
+}
|
|
|
+
|
|
|
+async function accessibleWorkspaces() {
|
|
|
+ const memberQuery = new Parse.Query('VocWorkspaceMember');
|
|
|
+ memberQuery.equalTo('userId', activeRequest.user.id);
|
|
|
+ memberQuery.equalTo('status', 'active');
|
|
|
+ memberQuery.limit(100);
|
|
|
+ const members = await memberQuery.find({ useMasterKey: true });
|
|
|
+ const ids = [...new Set(members.map((member) => String(member.get('workspaceId') || '')).filter(Boolean))];
|
|
|
+ if (!ids.length) return [];
|
|
|
+ const workspaceQuery = new Parse.Query('VocWorkspace');
|
|
|
+ workspaceQuery.containedIn('publicId', ids);
|
|
|
+ workspaceQuery.equalTo('status', 'active');
|
|
|
+ workspaceQuery.limit(100);
|
|
|
+ const workspaces = await workspaceQuery.find({ useMasterKey: true });
|
|
|
+ const roleByWorkspace = new Map(members.map((member) => [String(member.get('workspaceId') || ''), String(member.get('role') || 'viewer')]));
|
|
|
+ return workspaces.map((workspace) => ({ ...safeValue(workspace.toJSON(), 0), role: roleByWorkspace.get(String(workspace.get('publicId') || '')) || 'viewer' }));
|
|
|
+}
|
|
|
+
|
|
|
+async function authorize(action, workspaceId) {
|
|
|
+ if (!activeRequest.user) throw { status: 401, code: 'unauthenticated', message: '需要登录' };
|
|
|
+ const member = await activeMember(workspaceId);
|
|
|
+ if (!member) throw { status: 403, code: 'workspace_access_denied', message: '无权访问该 workspace' };
|
|
|
+ const role = String(member.get('role') || 'viewer');
|
|
|
+ if (action === 'workspace.members.list' && !ADMIN_ROLES.has(role)) throw { status: 403, code: 'forbidden', message: '权限不足' };
|
|
|
+ if (isWriteAction(action) && !WRITE_ROLES.has(role)) throw { status: 403, code: 'viewer_write_forbidden', message: 'Viewer 不能执行写入操作' };
|
|
|
+ const configuredProductIds = member.get('productIds');
|
|
|
+ const productIds = Array.isArray(configuredProductIds)
|
|
|
+ ? [...new Set(configuredProductIds.filter((value) => typeof value === 'string' && value.length <= 200))]
|
|
|
+ : (ADMIN_ROLES.has(role) ? null : []);
|
|
|
+ return { member, role, productIds };
|
|
|
+}
|
|
|
+
|
|
|
+function isWriteAction(action) {
|
|
|
+ return /\.create$|\.update$|\.upsert$|\.delete$|\.enqueue$|\.retry$|\.cancel$|\.adopt$|\.run$|^competitor\.refresh$/.test(action)
|
|
|
+ || action === 'ai.chat' || action === 'ai.test';
|
|
|
+}
|
|
|
+
|
|
|
+function boundedLimit(value, fallback) {
|
|
|
+ const limit = Number(value || fallback);
|
|
|
+ return Number.isInteger(limit) ? Math.max(1, Math.min(limit, 100)) : fallback;
|
|
|
+}
|
|
|
+
|
|
|
+function safeValue(value, depth) {
|
|
|
+ if (depth > 5 || value === null || value === undefined) return value;
|
|
|
+ if (value instanceof Date) return value;
|
|
|
+ if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
|
|
|
+ if (typeof value !== 'object') return value;
|
|
|
+ const output = {};
|
|
|
+ Object.keys(value).slice(0, 100).forEach((key) => {
|
|
|
+ if (/token|secret|password|credential|authorization|master/i.test(key)) return;
|
|
|
+ output[key] = safeValue(value[key], depth + 1);
|
|
|
+ });
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+async function readMany(className, workspaceId, params, productIds, maximum = 100) {
|
|
|
+ const query = new Parse.Query(className);
|
|
|
+ query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
|
|
|
+ applyReadFilters(query, className, params);
|
|
|
+ if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
|
|
|
+ query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
|
|
|
+ }
|
|
|
+ const limit = boundedLimit(params.limit, 50, maximum);
|
|
|
+ if (typeof params.cursor === 'string' && params.cursor) {
|
|
|
+ const cursorDate = new Date(params.cursor);
|
|
|
+ if (Number.isNaN(cursorDate.getTime())) throw { status: 400, code: 'invalid_cursor', message: '分页游标无效' };
|
|
|
+ query.lessThan('createdAt', cursorDate);
|
|
|
+ }
|
|
|
+ if (Number.isInteger(params.skip) && params.skip >= 0) query.skip(Math.min(params.skip, 100000));
|
|
|
+ query.limit(limit);
|
|
|
+ query.descending('createdAt');
|
|
|
+ const rows = await query.find({ useMasterKey: true });
|
|
|
+ const last = rows[rows.length - 1];
|
|
|
+ const nextCursor = Number.isInteger(params.skip)
|
|
|
+ ? (rows.length === limit ? String(params.skip + rows.length) : null)
|
|
|
+ : (rows.length === limit && last && last.createdAt ? last.createdAt.toISOString() : null);
|
|
|
+ return { items: rows.map((row) => safeValue(row.toJSON(), 0)), nextCursor };
|
|
|
+}
|
|
|
+
|
|
|
+function applyReadFilters(query, className, params) {
|
|
|
+ const fields = READ_FILTER_FIELDS[className] || [];
|
|
|
+ for (const field of fields) {
|
|
|
+ const value = params && params[field];
|
|
|
+ if (value !== undefined && value !== null && value !== '') query.equalTo(field, value);
|
|
|
+ }
|
|
|
+ if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) {
|
|
|
+ query.contains('title', params.search.trim().slice(0, 200));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function readAll(className, workspaceId, params, productIds, maximum = 10000) {
|
|
|
+ const items = [];
|
|
|
+ while (items.length < maximum) {
|
|
|
+ const page = await readMany(className, workspaceId, { ...params, limit: Math.min(100, maximum - items.length), skip: items.length }, productIds, 100);
|
|
|
+ items.push(...page.items);
|
|
|
+ if (!page.nextCursor || !page.items.length) break;
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+}
|
|
|
+
|
|
|
+async function readAllWhere(className, workspaceId, filters, productIds, maximum = 10000) {
|
|
|
+ const items = [];
|
|
|
+ while (items.length < maximum) {
|
|
|
+ const query = new Parse.Query(className);
|
|
|
+ query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
|
|
|
+ Object.entries(filters || {}).forEach(([key, value]) => {
|
|
|
+ if (value !== undefined && value !== null && value !== '') query.equalTo(key, value);
|
|
|
+ });
|
|
|
+ if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
|
|
|
+ query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
|
|
|
+ }
|
|
|
+ const pageSize = Math.min(100, maximum - items.length);
|
|
|
+ query.skip(items.length); query.limit(pageSize); query.descending('createdAt');
|
|
|
+ const rows = await query.find({ useMasterKey: true });
|
|
|
+ items.push(...rows.map((row) => safeValue(row.toJSON(), 0)));
|
|
|
+ if (rows.length < pageSize) break;
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+}
|
|
|
+
|
|
|
+async function readOne(className, workspaceId, objectId, productIds) {
|
|
|
+ if (!objectId || typeof objectId !== 'string' || objectId.length > 200) throw { status: 400, code: 'invalid_id', message: '标识无效' };
|
|
|
+ const query = new Parse.Query(className);
|
|
|
+ query.equalTo('workspaceId', workspaceId);
|
|
|
+ query.equalTo(className === 'VocProduct' ? 'productId' : 'publicId', objectId);
|
|
|
+ if (productIds !== null && className === 'VocProduct') query.containedIn('productId', productIds);
|
|
|
+ const row = await query.first({ useMasterKey: true });
|
|
|
+ return row ? safeValue(row.toJSON(), 0) : null;
|
|
|
+}
|
|
|
+
|
|
|
+function bodyData(params, workspaceId) {
|
|
|
+ const output = {};
|
|
|
+ Object.keys(params).forEach((key) => {
|
|
|
+ if (!['action', 'workspaceId', 'platform', 'payload', 'idempotencyKey', 'userId', 'jobId', 'productId', 'decisionId', 'analysisId', 'actionId', 'alertId', 'taskId', 'runId', 'versionId'].includes(key)) output[key] = safeValue(params[key], 0);
|
|
|
+ });
|
|
|
+ output.workspaceId = workspaceId;
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+function storageData(params, workspaceId) {
|
|
|
+ const output = {};
|
|
|
+ Object.keys(params).forEach((key) => {
|
|
|
+ if (!['action', 'workspaceId', 'userId'].includes(key)) output[key] = safeValue(params[key], 0);
|
|
|
+ });
|
|
|
+ output.workspaceId = workspaceId;
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+function publicId() {
|
|
|
+ return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
|
|
+}
|
|
|
+
|
|
|
+async function findObject(className, workspaceId, field, value) {
|
|
|
+ const query = new Parse.Query(className);
|
|
|
+ query.equalTo('workspaceId', workspaceId);
|
|
|
+ query.equalTo(field, value);
|
|
|
+ return query.first({ useMasterKey: true });
|
|
|
+}
|
|
|
+
|
|
|
+async function findObjects(className, workspaceId, filters, limit, productIds) {
|
|
|
+ const query = new Parse.Query(className); query.equalTo('workspaceId', workspaceId);
|
|
|
+ Object.entries(filters || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') query.equalTo(key, value); });
|
|
|
+ if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
|
|
|
+ query.limit(boundedLimit(limit, 50)); query.descending('createdAt');
|
|
|
+ return (await query.find({ useMasterKey: true })).map((item) => safeValue(item.toJSON(), 0));
|
|
|
+}
|
|
|
+
|
|
|
+function presentReadItem(action, item) {
|
|
|
+ if (action === 'listing.score-job.list') return presentListingJob(item);
|
|
|
+ const output = { ...item };
|
|
|
+ if (!output.id) output.id = output.publicId || output.objectId || output.productId;
|
|
|
+ if (action === 'data-source.list') {
|
|
|
+ output.kind = output.kind || output.connectionKind || '';
|
|
|
+ output.credentialStorage = output.credentialStorage || output.metadata?.credentialStorage || 'external_secret';
|
|
|
+ }
|
|
|
+ if (action === 'import.list') output.id = output.id || output.publicId;
|
|
|
+ if (action === 'domestic.reviews.list') output.reviewId = output.reviewId || output.reviewKey || output.sourceReviewId || output.id;
|
|
|
+ if (action === 'sync.jobs.list') output.id = output.publicId || output.id;
|
|
|
+ if (action === 'sync.job.events') output.type = output.type || output.eventType || '';
|
|
|
+ if (action === 'listing.versions.list') output.id = output.publicId || output.id;
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+function presentListingJob(item) {
|
|
|
+ const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
|
|
|
+ return {
|
|
|
+ ...payload,
|
|
|
+ id: payload.id || item.publicId || item.objectId,
|
|
|
+ workspaceId: payload.workspaceId || item.workspaceId,
|
|
|
+ platform: payload.platform || item.platform || 'jd',
|
|
|
+ idempotencyKey: payload.idempotencyKey || item.idempotencyKey,
|
|
|
+ requestHash: payload.requestHash || item.requestHash,
|
|
|
+ status: item.status || payload.status,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function presentListingJobItem(item) {
|
|
|
+ const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
|
|
|
+ return {
|
|
|
+ ...payload,
|
|
|
+ id: payload.id || item.publicId || item.objectId,
|
|
|
+ jobId: payload.jobId || item.jobId,
|
|
|
+ workspaceId: payload.workspaceId || item.workspaceId,
|
|
|
+ productId: payload.productId || item.productId,
|
|
|
+ sourceHash: payload.sourceHash || item.sourceHash,
|
|
|
+ status: item.status || payload.status,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function listingSourcePayload(item) {
|
|
|
+ return item && item.payload && typeof item.payload === 'object' ? item.payload : item;
|
|
|
+}
|
|
|
+
|
|
|
+function listingSourceMatches(item, filter) {
|
|
|
+ const source = listingSourcePayload(item);
|
|
|
+ const normalizedSearch = String(filter.search || '').trim().toLowerCase();
|
|
|
+ if (normalizedSearch && !`${source.productId || ''} ${source.title || ''}`.toLowerCase().includes(normalizedSearch)) return false;
|
|
|
+ if (filter.categoryId && !(Array.isArray(source.categoryIds) ? source.categoryIds : []).includes(filter.categoryId)) return false;
|
|
|
+ if (filter.itemStatus && source.itemStatus !== filter.itemStatus) return false;
|
|
|
+ if (filter.coverageStatus && item.coverageStatus !== filter.coverageStatus) return false;
|
|
|
+ if (filter.scoreStatus && item.scoreStatus !== filter.scoreStatus) return false;
|
|
|
+ if (filter.aiScoreStatus && item.aiScoreStatus !== filter.aiScoreStatus) return false;
|
|
|
+ const score = Number(item.latestOverallScore);
|
|
|
+ if (filter.minScore !== undefined && (!Number.isFinite(score) || score < Number(filter.minScore))) return false;
|
|
|
+ if (filter.maxScore !== undefined && (!Number.isFinite(score) || score > Number(filter.maxScore))) return false;
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+async function resolveListingScoreSources(workspaceId, scope, productIds) {
|
|
|
+ if (!scope || !['selected', 'filter'].includes(scope.mode)) throw { status: 400, code: 'listing_scope_invalid', message: '评分范围无效' };
|
|
|
+ const allRows = await readAll('VocListingSourceSnapshot', workspaceId, {}, productIds, 10000);
|
|
|
+ const latestByProduct = new Map();
|
|
|
+ allRows.filter((row) => row.platform === 'jd' && row.isCurrent !== false).forEach((row) => {
|
|
|
+ const source = listingSourcePayload(row);
|
|
|
+ const productId = String(source.productId || row.productId || '').trim();
|
|
|
+ if (productId && !latestByProduct.has(productId)) latestByProduct.set(productId, row);
|
|
|
+ });
|
|
|
+ if (scope.mode === 'selected') {
|
|
|
+ const selectedIds = [...new Set(Array.isArray(scope.productIds) ? scope.productIds.map((value) => String(value).trim()).filter(Boolean) : [])];
|
|
|
+ if (!selectedIds.length || selectedIds.length > 100) throw { status: 400, code: 'listing_scope_invalid', message: '评分商品范围无效' };
|
|
|
+ if (productIds !== null && selectedIds.some((id) => !productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '评分范围无效' };
|
|
|
+ const selected = selectedIds.map((id) => latestByProduct.get(id)).filter(Boolean);
|
|
|
+ if (selected.length !== selectedIds.length) throw { status: 422, code: 'listing_source_incomplete', message: '评分商品数据不完整' };
|
|
|
+ return selected;
|
|
|
+ }
|
|
|
+ const filter = scope.filter && typeof scope.filter === 'object' ? scope.filter : {};
|
|
|
+ return [...latestByProduct.values()].filter((row) => listingSourceMatches(row, filter));
|
|
|
+}
|
|
|
+
|
|
|
+async function createListingScoreItems(workspaceId, jobId, sources, requestedAt) {
|
|
|
+ const objects = sources.map((row) => {
|
|
|
+ const source = listingSourcePayload(row);
|
|
|
+ const productId = String(source.productId || row.productId || '');
|
|
|
+ const sourceHash = String(source.sourceHash || row.sourceHash || '');
|
|
|
+ const id = publicId();
|
|
|
+ const payload = { id, jobId, workspaceId, productId, sourceHash, status: 'queued', attempts: 0, errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: requestedAt };
|
|
|
+ const item = new Parse.Object('VocListingScoreItem');
|
|
|
+ item.set('publicId', id); item.set('naturalKey', `${jobId}|${productId}|${sourceHash}`); item.set('workspaceId', workspaceId);
|
|
|
+ item.set('jobId', jobId); item.set('productId', productId); item.set('sourceHash', sourceHash); item.set('status', 'queued'); item.set('payload', payload);
|
|
|
+ return item;
|
|
|
+ });
|
|
|
+ for (let index = 0; index < objects.length; index += 50) await Parse.Object.saveAll(objects.slice(index, index + 50), { useMasterKey: true });
|
|
|
+}
|
|
|
+
|
|
|
+async function activateListingScoreJob(workspaceId, jobId, job) {
|
|
|
+ const object = await findObject('VocListingScoreJob', workspaceId, 'publicId', jobId);
|
|
|
+ if (!object) throw { status: 500, code: 'listing_score_job_missing', message: '评分任务保存失败' };
|
|
|
+ const activated = { ...job, status: 'queued', updatedAt: new Date().toISOString() };
|
|
|
+ object.set('status', 'queued'); object.set('payload', activated);
|
|
|
+ await object.save(null, { useMasterKey: true });
|
|
|
+ return activated;
|
|
|
+}
|
|
|
+
|
|
|
+function presentReadPage(action, page) {
|
|
|
+ return { ...page, items: page.items.map((item) => presentReadItem(action, item)) };
|
|
|
+}
|
|
|
+
|
|
|
+async function visibleProductIds(className, workspaceId, productIds) {
|
|
|
+ if (!productIds || !['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'].includes(className)) return productIds;
|
|
|
+ const relationQuery = new Parse.Query('VocProductRelation');
|
|
|
+ relationQuery.equalTo('workspaceId', workspaceId);
|
|
|
+ relationQuery.containedIn('ownProductId', productIds);
|
|
|
+ relationQuery.limit(10000);
|
|
|
+ const relations = await relationQuery.find({ useMasterKey: true });
|
|
|
+ const competitorIds = relations.map((relation) => String(relation.get('competitorProductId') || '')).filter(Boolean);
|
|
|
+ return [...new Set(productIds.concat(competitorIds))];
|
|
|
+}
|
|
|
+
|
|
|
+async function updateByPublicId(className, workspaceId, id, params) {
|
|
|
+ const object = await findObject(className, workspaceId, 'publicId', id);
|
|
|
+ if (!object) throw { status: 404, code: 'not_found', message: '记录不存在' };
|
|
|
+ Object.entries(bodyData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
|
|
|
+ object.set('updatedBy', activeRequest.user.id);
|
|
|
+ await object.save(null, { useMasterKey: true });
|
|
|
+ return safeValue(object.toJSON(), 0);
|
|
|
+}
|
|
|
+
|
|
|
+async function appendAudit(workspaceId, action, entityType, entityId, metadata) {
|
|
|
+ const audit = new Parse.Object('VocAuditLog');
|
|
|
+ audit.set('publicId', publicId()); audit.set('workspaceId', workspaceId);
|
|
|
+ audit.set('actorUserId', activeRequest.user.id); audit.set('action', action);
|
|
|
+ audit.set('entityType', entityType); audit.set('entityId', entityId || null);
|
|
|
+ audit.set('metadata', safeValue(metadata || {}, 0));
|
|
|
+ await audit.save(null, { useMasterKey: true });
|
|
|
+}
|
|
|
+
|
|
|
+async function createIdempotent(className, workspaceId, keyField, keyValue, params) {
|
|
|
+ if (keyValue) {
|
|
|
+ const existing = await findObject(className, workspaceId, keyField, keyValue);
|
|
|
+ if (existing) return { value: safeValue(existing.toJSON(), 0), idempotent: true };
|
|
|
+ }
|
|
|
+ const object = new Parse.Object(className);
|
|
|
+ Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
|
|
|
+ if (!object.get('publicId')) object.set('publicId', publicId());
|
|
|
+ if (keyValue) object.set(keyField, keyValue);
|
|
|
+ object.set('createdBy', activeRequest.user.id);
|
|
|
+ await object.save(null, { useMasterKey: true });
|
|
|
+ return { value: safeValue(object.toJSON(), 0), idempotent: false };
|
|
|
+}
|
|
|
+
|
|
|
+async function writeObject(className, params, workspaceId) {
|
|
|
+ const object = new Parse.Object(className);
|
|
|
+ Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
|
|
|
+ object.set('createdBy', activeRequest.user.id);
|
|
|
+ await object.save(null, { useMasterKey: true });
|
|
|
+ return safeValue(object.toJSON(), 0);
|
|
|
+}
|
|
|
+
|
|
|
+async function aiChat(params) {
|
|
|
+ const env = typeof process !== 'undefined' && process.env ? process.env : {};
|
|
|
+ const token = env.FMODE_AI_TOKEN || env.AI_API_KEY || '';
|
|
|
+ if (!token) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务尚未配置' };
|
|
|
+ if (!Array.isArray(params.messages) || params.messages.length < 1 || params.messages.length > 100) throw { status: 400, code: 'invalid_ai_messages', message: '消息参数无效' };
|
|
|
+ const body = { messages: params.messages, model: String(params.model || env.FMODE_AI_MODEL || '').slice(0, 120), stream: false };
|
|
|
+ ['temperature', 'presence_penalty', 'frequency_penalty', 'max_tokens', 'response_format', 'thinking', 'websearch'].forEach((key) => { if (params[key] !== undefined) body[key] = safeValue(params[key], 0); });
|
|
|
+ const baseUrl = String(env.FMODE_AI_BASE_URL || '').replace(/\/+$/, '');
|
|
|
+ if (!/^https:\/\//i.test(baseUrl)) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务地址未配置' };
|
|
|
+ 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) });
|
|
|
+ const data = await response.json().catch(() => null);
|
|
|
+ if (!response.ok) throw { status: 502, code: 'ai_upstream_failed', message: 'AI 上游请求失败' };
|
|
|
+ return data;
|
|
|
+}
|
|
|
+
|
|
|
+async function fixedUpstream(provider, params) {
|
|
|
+ const path = normalizeUpstreamPath(params.path);
|
|
|
+ if (!path || !UPSTREAM_PATHS[provider].some((pattern) => pattern.test(path))) throw { status: 400, code: 'upstream_path_not_allowed', message: '上游路径不在白名单中' };
|
|
|
+ const operation = String(params.operation || '').trim().toLowerCase();
|
|
|
+ if (!UPSTREAM_OPERATIONS[provider].has(operation)) throw { status: 400, code: 'upstream_operation_not_allowed', message: '上游操作不在白名单中' };
|
|
|
+ const env = typeof process !== 'undefined' && process.env ? process.env : {};
|
|
|
+ const base = String(env[provider.toUpperCase() + '_BASE_URL'] || (provider === 'domestic' ? env.FMODE_BASE_URL || '' : '')).replace(/\/+$/, '');
|
|
|
+ const token = String(env[provider.toUpperCase() + '_API_KEY'] || (provider === 'domestic' ? env.FMODE_API_KEY || '' : ''));
|
|
|
+ if (!/^https:\/\//i.test(base) || !token) throw { status: 503, code: 'upstream_not_configured', message: '上游数据源尚未配置' };
|
|
|
+ const method = operation === 'post' || operation === 'forward' ? 'POST' : 'GET';
|
|
|
+ const url = new URL(path.replace(/^\/+/, ''), base + '/');
|
|
|
+ const query = params.query && typeof params.query === 'object' ? params.query : params.params && typeof params.params === 'object' ? params.params : {};
|
|
|
+ if (method === 'GET') Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); });
|
|
|
+ let response;
|
|
|
+ try {
|
|
|
+ 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)) });
|
|
|
+ } catch (error) {
|
|
|
+ const name = error && error.name;
|
|
|
+ if (name === 'TimeoutError' || name === 'AbortError') throw { status: 504, code: 'upstream_timeout', message: '上游请求超时' };
|
|
|
+ throw { status: 502, code: 'upstream_unreachable', message: '上游数据源不可达' };
|
|
|
+ }
|
|
|
+ const data = await response.json().catch(() => null);
|
|
|
+ if (response.status === 401 || response.status === 403) throw { status: 502, code: 'upstream_auth_failed', message: '上游鉴权失败' };
|
|
|
+ if (response.status === 429) throw { status: 503, code: 'upstream_rate_limited', message: '上游请求受限' };
|
|
|
+ if (!response.ok) throw { status: 502, code: 'upstream_request_failed', message: '上游请求失败' };
|
|
|
+ return safeValue(data, 0);
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeUpstreamPath(value) {
|
|
|
+ const path = String(value || '').trim();
|
|
|
+ if (!path || path.length > 300 || path.includes('://') || path.includes('\\') || path.includes('?') || path.includes('#')) return '';
|
|
|
+ return '/' + path.replace(/^\/+/, '');
|
|
|
+}
|
|
|
+
|
|
|
+async function snapshot(workspaceId, params, productIds) {
|
|
|
+ const [products, reviews, metrics, relations, imports] = await Promise.all([
|
|
|
+ readAll('VocProduct', workspaceId, {}, productIds, 10000),
|
|
|
+ readAll('VocReview', workspaceId, {}, productIds, 10000),
|
|
|
+ readAll('VocDailyMetric', workspaceId, {}, productIds, 20000),
|
|
|
+ readAll('VocProductRelation', workspaceId, {}, productIds, 10000),
|
|
|
+ readMany('VocImportBatch', workspaceId, { limit: 1 }, null),
|
|
|
+ ]);
|
|
|
+ const importRow = imports.items[0] || {};
|
|
|
+ const dailyTotals = aggregateDailyMetrics(metrics);
|
|
|
+ const productById = new Map(products.map((product) => [product.productId, product]));
|
|
|
+ const grouped = new Map();
|
|
|
+ relations.forEach((relation) => {
|
|
|
+ const key = relation.ownProductId;
|
|
|
+ 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: [] });
|
|
|
+ grouped.get(key).competitors.push(relation);
|
|
|
+ });
|
|
|
+ const categories2 = new Set(products.map((product) => product.category2).filter(Boolean));
|
|
|
+ const categories3 = new Set(products.map((product) => product.category3).filter(Boolean));
|
|
|
+ return {
|
|
|
+ schemaVersion: 1,
|
|
|
+ generatedAt: new Date().toISOString(),
|
|
|
+ caseName: importRow.caseName || workspaceId,
|
|
|
+ platform: params.platform || 'jd',
|
|
|
+ source: { sourceFile: importRow.sourceFile || '', sourceHash: importRow.sourceHash || '', sheets: importRow.sheets || [], dateRange: { start: importRow.sourceDateStart || '', end: importRow.sourceDateEnd || '' } },
|
|
|
+ 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 },
|
|
|
+ dailyTotals,
|
|
|
+ products,
|
|
|
+ mappingGroups: [...grouped.values()],
|
|
|
+ relations,
|
|
|
+ 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 } : {}) })),
|
|
|
+ quality: importRow.quality || { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function aggregateDailyMetrics(metrics) {
|
|
|
+ const totals = new Map();
|
|
|
+ const numericFields = ['gmv', 'soldUnits', 'transactionOrders', 'transactionCustomers', 'impressions', 'clicks', 'views', 'visitors', 'cartUnits', 'orderAmount', 'orderUnits', 'orderCount', 'refundAmount', 'refundUnits', 'refundOrders'];
|
|
|
+ metrics.forEach((metric) => {
|
|
|
+ const date = String(metric.metricDate || metric.date || '');
|
|
|
+ if (!date) return;
|
|
|
+ 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 });
|
|
|
+ const total = totals.get(date);
|
|
|
+ numericFields.forEach((field) => { total[field] += Number(metric[field] || 0); });
|
|
|
+ });
|
|
|
+ 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 }));
|
|
|
+}
|
|
|
+
|
|
|
+async function runHandler(request, response) {
|
|
|
+ activeRequest = request;
|
|
|
+ activeResponse = response;
|
|
|
+ const input = paramsOf();
|
|
|
+ const action = input.action;
|
|
|
+ if (!ACTIONS.has(action)) return fail(400, 'cloud_action_not_allowed', '不支持的业务操作');
|
|
|
+ if (!activeRequest.user) return fail(401, 'unauthenticated', '需要登录');
|
|
|
+ let workspaceId = String(input.workspaceId || '').trim();
|
|
|
+ if (!workspaceId && (action === 'context.get' || action === 'workspace.list')) {
|
|
|
+ const membershipQuery = new Parse.Query('VocWorkspaceMember');
|
|
|
+ membershipQuery.equalTo('userId', activeRequest.user && activeRequest.user.id);
|
|
|
+ membershipQuery.equalTo('status', 'active');
|
|
|
+ const membership = await membershipQuery.first({ useMasterKey: true });
|
|
|
+ workspaceId = membership ? String(membership.get('workspaceId') || '') : '';
|
|
|
+ }
|
|
|
+ if (!workspaceId || workspaceId.length > 200) return fail(400, 'workspace_id_invalid', 'workspaceId 无效');
|
|
|
+ try {
|
|
|
+ const auth = await authorize(action, workspaceId);
|
|
|
+ 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() });
|
|
|
+ if (action === 'workspace.list') return activeResponse.json({ success: true, data: { items: await accessibleWorkspaces(), nextCursor: null }, requestId: requestId() });
|
|
|
+ if (action === 'domestic.snapshot') return activeResponse.json({ success: true, data: await snapshot(workspaceId, input, auth.productIds), requestId: requestId() });
|
|
|
+ if (action === 'domestic.product.get') return activeResponse.json({ success: true, data: { product: await readOne('VocProduct', workspaceId, input.productId, auth.productIds) }, requestId: requestId() });
|
|
|
+ if (action === 'sync.job.get') return activeResponse.json({ success: true, data: { job: await readOne('VocSyncJob', workspaceId, input.jobId, auth.productIds) }, requestId: requestId() });
|
|
|
+ if (action === 'insight-decision.get') return activeResponse.json({ success: true, data: { decision: await readOne('VocInsightDecision', workspaceId, input.decisionId, auth.productIds) }, requestId: requestId() });
|
|
|
+ if (action === 'competitor.run.get') {
|
|
|
+ const run = await findObject('VocCompetitorListingRefreshRun', workspaceId, 'publicId', input.runId); if (!run) throw { status: 404, code: 'competitor_run_not_found', message: '刷新任务不存在' };
|
|
|
+ return activeResponse.json({ success: true, data: { run: safeValue(run.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.history') {
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ return activeResponse.json({ success: true, data: { workspaceId, platform: input.platform || 'jd', productId: input.productId, snapshots, changes, collectionRuns: [] }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.overview') {
|
|
|
+ const products = await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000);
|
|
|
+ const targets = products.filter((item) => item.role === 'competitor');
|
|
|
+ const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ const latest = new Map(); snapshots.forEach((item) => { if (!latest.has(item.productId)) latest.set(item.productId, item); });
|
|
|
+ const latestChange = new Map(); changes.forEach((item) => { if (!latestChange.has(item.productId)) latestChange.set(item.productId, item); });
|
|
|
+ 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' }));
|
|
|
+ const facets = (key) => [...new Set(items.map((item) => item[key]).filter(Boolean))].map((value) => ({ value, count: items.filter((item) => item[key] === value).length }));
|
|
|
+ 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() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.alerts') {
|
|
|
+ const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ 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 || [] })));
|
|
|
+ return activeResponse.json({ success: true, data: { alerts }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.impacts') {
|
|
|
+ const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
|
|
|
+ const relationQuery = new Parse.Query('VocProductRelation'); relationQuery.equalTo('workspaceId', workspaceId); relationQuery.limit(10000); const relations = await relationQuery.find({ useMasterKey: true });
|
|
|
+ const ownByCompetitor = new Map(relations.map((relation) => [String(relation.get('competitorProductId') || ''), String(relation.get('ownProductId') || '')]));
|
|
|
+ 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);
|
|
|
+ return activeResponse.json({ success: true, data: { impacts }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.refresh') {
|
|
|
+ const activeRunQuery = new Parse.Query('VocCompetitorListingRefreshRun'); activeRunQuery.equalTo('workspaceId', workspaceId); activeRunQuery.equalTo('platform', input.platform || 'jd'); activeRunQuery.containedIn('status', ['queued', 'running']);
|
|
|
+ if (await activeRunQuery.first({ useMasterKey: true })) throw { status: 409, code: 'competitor_listing_refresh_running', message: '竞品刷新任务正在运行' };
|
|
|
+ const targets = (await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000)).filter((item) => item.role === 'competitor');
|
|
|
+ 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);
|
|
|
+ await appendAudit(workspaceId, 'competitor.refresh.requested', 'competitor_refresh_run', run.publicId, { total: targets.length });
|
|
|
+ return activeResponse.status(202).json({ success: true, data: { run }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.product.get') {
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ const source = (await findObjects('VocListingSourceSnapshot', workspaceId, { productId: input.productId, isCurrent: true }, 1, auth.productIds))[0];
|
|
|
+ if (!source) throw { status: 404, code: 'listing_product_not_found', message: 'Listing 商品不存在' };
|
|
|
+ const scores = await readAllWhere('VocListingCurrentScore', workspaceId, { productId: input.productId }, auth.productIds, 100);
|
|
|
+ const versions = await readAllWhere('VocListingVersion', workspaceId, { productId: input.productId }, auth.productIds, 100);
|
|
|
+ const scorePayloads = scores.map((item) => item.payload || item);
|
|
|
+ const jdVocScores = scorePayloads.filter((item) => item.rubricVersion === 'jd-voc-v0.5' && (item.scoreKind === 'jd_voc_hybrid_ai' || item.scoreKind === 'jd_voc_rules'));
|
|
|
+ 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;
|
|
|
+ const legacyScore = scorePayloads.find((item) => item.scoreKind === 'hybrid_ai' && item.aiStatus === 'completed') || scorePayloads.find((item) => item.scoreKind === 'rules') || null;
|
|
|
+ const displayJdVoc = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true' && jdVocScore;
|
|
|
+ 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() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.product.score') {
|
|
|
+ const allScores = (await findObjects('VocListingCurrentScore', workspaceId, { productId: input.productId }, 100, auth.productIds)).map((item) => item.payload || item);
|
|
|
+ const score = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true'
|
|
|
+ ? 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')
|
|
|
+ : allScores.find((item) => item.scoreKind === 'hybrid_ai') || allScores.find((item) => item.scoreKind === 'rules');
|
|
|
+ if (!score) throw { status: 404, code: 'listing_score_not_found', message: 'Listing 评分不存在' };
|
|
|
+ return activeResponse.json({ success: true, data: { score, displayScoreKind: String(score.scoreKind || '').startsWith('jd_voc_') ? 'jd_voc' : 'legacy' }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.jd-voc-score.get') {
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ 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'));
|
|
|
+ if (!score) throw { status: 404, code: 'jd_voc_score_not_found', message: 'JD-VOC 评分不存在' };
|
|
|
+ return activeResponse.json({ success: true, data: { score }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.jd-voc-image-review.run') throw { status: 409, code: 'jd_voc_image_review_not_deployed', message: '影子识图需由后端服务执行,当前托管函数未启用该开关' };
|
|
|
+ if (action === 'listing.overview') {
|
|
|
+ const sources = await readAllWhere('VocListingSourceSnapshot', workspaceId, { isCurrent: true }, auth.productIds, 10000);
|
|
|
+ const scores = await readAllWhere('VocListingCurrentScore', workspaceId, {}, auth.productIds, 20000);
|
|
|
+ const scoreByProduct = new Map(scores.map((score) => [score.productId, score]));
|
|
|
+ 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 }; });
|
|
|
+ const scored = rows.filter((row) => row.overallScore !== null);
|
|
|
+ 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() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.score-job.get') {
|
|
|
+ const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
|
|
|
+ return activeResponse.json({ success: true, data: { job: presentListingJob(safeValue(job.toJSON(), 0)) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ 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() });
|
|
|
+ if (action === 'listing.products.list') {
|
|
|
+ const page = await readMany('VocListingSourceSnapshot', workspaceId, { ...input, limit: input.limit || 25 }, auth.productIds);
|
|
|
+ const items = page.items.map((item) => presentReadItem(action, { ...item, ...item.payload, productId: item.productId, syncedAt: item.observedAt }));
|
|
|
+ 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() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.tasks.list') {
|
|
|
+ const page = await readMany('VocCompetitorOptimizationTask', workspaceId, input, auth.productIds);
|
|
|
+ return activeResponse.json({ success: true, data: { tasks: page.items.map((item) => presentReadItem(action, item)) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.versions.list') {
|
|
|
+ const page = await readMany('VocListingVersion', workspaceId, input, auth.productIds);
|
|
|
+ page.items = page.items.map((item) => ({ ...presentReadItem(action, item), ...item.payload, content: item.payload?.content || item.content || null }));
|
|
|
+ return activeResponse.json({ success: true, data: page, requestId: requestId() });
|
|
|
+ }
|
|
|
+ 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() });
|
|
|
+ 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() }); }
|
|
|
+ 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() }); }
|
|
|
+ if (action === 'ai.chat') return activeResponse.json({ success: true, data: await aiChat(input), requestId: requestId() });
|
|
|
+ if (action === 'workspace.member.update') {
|
|
|
+ if (!input.userId || !['owner','admin','editor','viewer'].includes(input.role)) throw { status: 400, code: 'invalid_member', message: '成员参数无效' };
|
|
|
+ const naturalKey = workspaceId + ':' + input.userId;
|
|
|
+ const result = await createIdempotent('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey, { ...input, status: input.status || 'active' });
|
|
|
+ if (result.idempotent) {
|
|
|
+ result.value = await updateByPublicId('VocWorkspaceMember', workspaceId, result.value.publicId || result.value.objectId, input).catch(async () => {
|
|
|
+ const object = await findObject('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey);
|
|
|
+ Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value));
|
|
|
+ await object.save(null, { useMasterKey: true }); return safeValue(object.toJSON(), 0);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ await appendAudit(workspaceId, 'member.updated', 'workspace_member', input.userId, { role: input.role, status: input.status });
|
|
|
+ return activeResponse.json({ success: true, data: { member: result.value }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'sync.enqueue') {
|
|
|
+ if (!Array.isArray(input.productIds) || input.productIds.length < 1 || input.productIds.length > 100) throw { status: 400, code: 'invalid_sync_scope', message: '同步商品范围无效' };
|
|
|
+ if (auth.productIds !== null && input.productIds.some((id) => !auth.productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '包含无权访问的商品' };
|
|
|
+ const key = String(input.idempotencyKey || '').trim();
|
|
|
+ if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
|
|
|
+ const result = await createIdempotent('VocSyncJob', workspaceId, 'naturalKey', workspaceId + ':' + key, { ...input, publicId: publicId(), status: 'pending', progress: 0, attempts: 0, maxAttempts: 3, requestedAt: new Date() });
|
|
|
+ await appendAudit(workspaceId, result.idempotent ? 'sync.reused' : 'sync.requested', 'sync_job', result.value.publicId, { productCount: input.productIds.length });
|
|
|
+ return activeResponse.json({ success: true, data: { job: result.value, idempotent: result.idempotent }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'sync.job.retry' || action === 'sync.job.cancel') {
|
|
|
+ const job = await findObject('VocSyncJob', workspaceId, 'publicId', input.jobId);
|
|
|
+ if (!job) throw { status: 404, code: 'job_not_found', message: '任务不存在' };
|
|
|
+ const status = String(job.get('status') || '');
|
|
|
+ if (action.endsWith('retry') && !['failed','partial','cancelled'].includes(status)) throw { status: 409, code: 'sync_job_not_retryable', message: '任务不可重试' };
|
|
|
+ if (action.endsWith('cancel') && !['pending','processing'].includes(status)) throw { status: 409, code: 'sync_job_not_cancellable', message: '任务不可取消' };
|
|
|
+ job.set('status', action.endsWith('retry') ? 'pending' : 'cancelled'); job.set('progress', action.endsWith('retry') ? 0 : job.get('progress') || 0);
|
|
|
+ await job.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, action.endsWith('retry') ? 'sync.retried' : 'sync.cancelled', 'sync_job', input.jobId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { job: safeValue(job.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'analysis.create') {
|
|
|
+ const result = await createIdempotent('VocAnalysisRun', workspaceId, 'publicId', input.idempotencyKey || publicId(), { ...input, status: 'pending', requestedBy: activeRequest.user.id, requestedAt: new Date(), result: null, evidenceCount: 0 });
|
|
|
+ await appendAudit(workspaceId, result.idempotent ? 'analysis.reused' : 'analysis.created', 'analysis_run', result.value.publicId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { analysis: result.value, idempotent: result.idempotent }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'analysis.update') return activeResponse.json({ success: true, data: { analysis: await updateByPublicId('VocAnalysisRun', workspaceId, input.analysisId, input) }, requestId: requestId() });
|
|
|
+ if (action === 'insight-decision.create') {
|
|
|
+ if (!input.sourceAnalysisId || !input.sourceInsightId || !['confirmed','rejected','needs_more_evidence'].includes(input.decision)) throw { status: 400, code: 'invalid_decision', message: '决策参数无效' };
|
|
|
+ const currentQuery = new Parse.Query('VocInsightDecision'); currentQuery.equalTo('workspaceId', workspaceId); currentQuery.equalTo('sourceAnalysisId', input.sourceAnalysisId); currentQuery.equalTo('sourceInsightId', input.sourceInsightId); currentQuery.equalTo('isCurrent', true);
|
|
|
+ const current = await currentQuery.first({ useMasterKey: true });
|
|
|
+ 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);
|
|
|
+ if (current) { current.set('isCurrent', false); await current.save(null, { useMasterKey: true }); }
|
|
|
+ await appendAudit(workspaceId, 'decision.created', 'insight_decision', decision.publicId, { version: decision.version });
|
|
|
+ return activeResponse.json({ success: true, data: { decision }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'action.create') {
|
|
|
+ const key = String(input.creationKey || input.idempotencyKey || '').trim();
|
|
|
+ if (key.length < 8) throw { status: 400, code: 'creation_key_required', message: '缺少 creationKey' };
|
|
|
+ const result = await createIdempotent('VocActionItem', workspaceId, 'creationKey', key, { ...input, publicId: publicId(), createdBy: activeRequest.user.id });
|
|
|
+ await appendAudit(workspaceId, result.idempotent ? 'action.reused' : 'action.created', 'action_item', result.value.publicId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { action: result.value, idempotent: result.idempotent }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'action.update') return activeResponse.json({ success: true, data: { action: await updateByPublicId('VocActionItem', workspaceId, input.actionId, input) }, requestId: requestId() });
|
|
|
+ if (action === 'alert.create') {
|
|
|
+ const alert = await writeObject('VocAlert', { ...input, publicId: publicId(), status: 'open', detectedAt: new Date() }, workspaceId);
|
|
|
+ await appendAudit(workspaceId, 'alert.created', 'alert', alert.publicId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { alert }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'alert.update') return activeResponse.json({ success: true, data: { alert: await updateByPublicId('VocAlert', workspaceId, input.alertId, input) }, requestId: requestId() });
|
|
|
+ if (action === 'knowledge.product.upsert') {
|
|
|
+ if (!input.productKey || !input.productId) throw { status: 400, code: 'invalid_knowledge_product', message: '商品知识参数无效' };
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ const naturalKey = workspaceId + ':' + input.productKey;
|
|
|
+ let object = await findObject('VocProductKnowledge', workspaceId, 'naturalKey', naturalKey);
|
|
|
+ if (!object) { object = new Parse.Object('VocProductKnowledge'); object.set('naturalKey', naturalKey); object.set('workspaceId', workspaceId); object.set('createdBy', activeRequest.user.id); }
|
|
|
+ Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value)); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, 'knowledge.upserted', 'product_knowledge', input.productKey, {});
|
|
|
+ return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'knowledge.product.delete') {
|
|
|
+ const object = await findObject('VocProductKnowledge', workspaceId, 'productKey', input.productKey); if (!object) throw { status: 404, code: 'knowledge_not_found', message: '商品知识不存在' };
|
|
|
+ object.set('status', 'archived'); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, 'knowledge.archived', 'product_knowledge', input.productKey, {});
|
|
|
+ return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'ai.prompt.update') {
|
|
|
+ if (!input.promptKey) throw { status: 400, code: 'prompt_key_required', message: '缺少 promptKey' };
|
|
|
+ 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); }
|
|
|
+ Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => prompt.set(key, value)); prompt.set('updatedBy', activeRequest.user.id); await prompt.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, 'prompt.updated', 'prompt_config', input.promptKey, {});
|
|
|
+ return activeResponse.json({ success: true, data: safeValue(prompt.toJSON(), 0), requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.score-job.create') {
|
|
|
+ const key = String(input.idempotencyKey || '').trim(); if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
|
|
|
+ const scope = input.scope; const scoringMode = input.scoringMode === 'rules' ? 'rules' : 'ai'; const rescorePolicy = input.rescorePolicy === 'force' ? 'force' : 'reuse';
|
|
|
+ const jdVocEnabled = typeof process !== 'undefined' && process.env && process.env.JD_VOC_ENABLED === 'true';
|
|
|
+ const rubricVersion = input.rubricVersion || (jdVocEnabled ? 'jd-voc-v0.5' : scoringMode === 'ai' ? 'listing-jd-ai-v5' : 'listing-jd-v7');
|
|
|
+ if (rubricVersion === 'jd-voc-v0.5' && !jdVocEnabled) throw { status: 409, code: 'jd_voc_disabled', message: 'JD-VOC 评分开关未启用' };
|
|
|
+ const requestHash = JSON.stringify({ platform: input.platform || 'jd', scope: safeValue(scope, 0), scoringMode, rubricVersion, rescorePolicy });
|
|
|
+ const existing = await findObject('VocListingScoreJob', workspaceId, 'idempotencyKey', key);
|
|
|
+ if (existing) {
|
|
|
+ const value = safeValue(existing.toJSON(), 0); const job = presentListingJob(value);
|
|
|
+ if (job.requestHash !== requestHash) throw { status: 409, code: 'idempotency_conflict', message: '幂等键对应的评分请求不同' };
|
|
|
+ await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', job.id, {});
|
|
|
+ return activeResponse.json({ success: true, data: { job, idempotent: true }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ const sources = await resolveListingScoreSources(workspaceId, scope, auth.productIds);
|
|
|
+ if (scoringMode === 'ai' && sources.length > 10) throw { status: 429, code: 'listing_ai_budget_exceeded', message: 'AI 评分商品数量超过单次上限' };
|
|
|
+ const requestedAt = new Date().toISOString(); const jobId = publicId();
|
|
|
+ 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 };
|
|
|
+ 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) });
|
|
|
+ if (result.idempotent) {
|
|
|
+ const existingJob = presentListingJob(result.value);
|
|
|
+ await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', existingJob.id, {});
|
|
|
+ return activeResponse.json({ success: true, data: { job: existingJob, idempotent: true }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ if (sources.length) {
|
|
|
+ await createListingScoreItems(workspaceId, jobId, sources, requestedAt);
|
|
|
+ job.status = 'queued';
|
|
|
+ job.updatedAt = new Date().toISOString();
|
|
|
+ const activated = await activateListingScoreJob(workspaceId, jobId, job);
|
|
|
+ await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: sources.length, scoringMode });
|
|
|
+ return activeResponse.status(202).json({ success: true, data: { job: activated, idempotent: false }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: 0, scoringMode });
|
|
|
+ return activeResponse.status(202).json({ success: true, data: { job, idempotent: false }, requestId: requestId() });
|
|
|
+ } catch (error) {
|
|
|
+ const failed = { ...job, status: 'failed', failed: sources.length ? sources.length : 1, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), errorCode: 'listing_score_queue_failed' };
|
|
|
+ const failedObject = await findObject('VocListingScoreJob', workspaceId, jobId);
|
|
|
+ if (failedObject) { failedObject.set('status', 'failed'); failedObject.set('payload', failed); await failedObject.save(null, { useMasterKey: true }); }
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (action === 'listing.score-job.retry' || action === 'listing.score-job.cancel') {
|
|
|
+ const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
|
|
|
+ const status = String(job.get('status') || '');
|
|
|
+ if (action.endsWith('retry') && !['failed','partial'].includes(status)) throw { status: 409, code: 'score_job_not_retryable', message: '评分任务不可重试' };
|
|
|
+ if (action.endsWith('cancel') && !['queued','running'].includes(status)) throw { status: 409, code: 'score_job_not_cancellable', message: '评分任务不可取消' };
|
|
|
+ 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 };
|
|
|
+ if (nextStatus === 'queued') {
|
|
|
+ const failedItems = await findObjects('VocListingScoreItem', workspaceId, { jobId: input.jobId, status: 'failed' }, 10000, auth.productIds);
|
|
|
+ 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 }); } }
|
|
|
+ }
|
|
|
+ job.set('status', nextStatus); job.set('payload', payload); await job.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, action.endsWith('retry') ? 'listing.score.retried' : 'listing.score.cancelled', 'listing_score_job', input.jobId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { job: payload }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.version.create') {
|
|
|
+ if (!input.productId || !input.baseSourceHash || !input.content) throw { status: 400, code: 'invalid_listing_version', message: 'Listing 版本参数无效' };
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ const versions = await findObjects('VocListingVersion', workspaceId, { productId: input.productId }, 100, auth.productIds);
|
|
|
+ const versionNo = versions.reduce((max, item) => Math.max(max, Number(item.versionNo || 0)), 0) + 1;
|
|
|
+ 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);
|
|
|
+ await appendAudit(workspaceId, 'listing.version.created', 'listing_version', version.publicId, { productId: input.productId, versionNo });
|
|
|
+ return activeResponse.json({ success: true, data: { version }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'listing.version.adopt') {
|
|
|
+ const version = await findObject('VocListingVersion', workspaceId, 'publicId', input.versionId); if (!version) throw { status: 404, code: 'listing_version_not_found', message: 'Listing 版本不存在' };
|
|
|
+ const productId = version.get('productId'); if (auth.productIds !== null && !auth.productIds.includes(productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ version.set('status', 'adopted'); version.set('adoptedAt', new Date()); version.set('adoptedBy', activeRequest.user.id); await version.save(null, { useMasterKey: true });
|
|
|
+ await appendAudit(workspaceId, 'listing.version.adopted', 'listing_version', input.versionId, { productId });
|
|
|
+ return activeResponse.json({ success: true, data: { version: safeValue(version.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.tasks.create') {
|
|
|
+ if (!input.competitorSnapshotId || !input.ownProductId || !input.dimension) throw { status: 400, code: 'invalid_competitor_task', message: '优化任务参数无效' };
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(input.ownProductId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ const key = input.competitorSnapshotId + ':' + input.ownProductId + ':' + input.dimension;
|
|
|
+ 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' });
|
|
|
+ await appendAudit(workspaceId, result.idempotent ? 'competitor.task.reused' : 'competitor.task.created', 'competitor_optimization_task', result.value.publicId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { task: result.value, idempotent: result.idempotent }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action === 'competitor.tasks.update') {
|
|
|
+ const task = await findObject('VocCompetitorOptimizationTask', workspaceId, 'publicId', input.taskId); if (!task) throw { status: 404, code: 'competitor_task_not_found', message: '优化任务不存在' };
|
|
|
+ if (auth.productIds !== null && !auth.productIds.includes(task.get('ownProductId'))) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
|
|
|
+ ['status','beforeScore','afterScore'].forEach((field) => { if (input[field] !== undefined) task.set(field, input[field]); });
|
|
|
+ const before = Number(task.get('beforeScore')); const after = Number(task.get('afterScore'));
|
|
|
+ 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'); }
|
|
|
+ await task.save(null, { useMasterKey: true }); await appendAudit(workspaceId, 'competitor.task.updated', 'competitor_optimization_task', input.taskId, {});
|
|
|
+ return activeResponse.json({ success: true, data: { task: safeValue(task.toJSON(), 0) }, requestId: requestId() });
|
|
|
+ }
|
|
|
+ if (action.startsWith('upstream.')) return activeResponse.json({ success: true, data: await fixedUpstream(action.slice('upstream.'.length), input), requestId: requestId() });
|
|
|
+ return fail(501, 'cloud_action_not_implemented', '业务操作暂未配置');
|
|
|
+ } catch (error) {
|
|
|
+ const status = Number(error && error.status) || 500;
|
|
|
+ return fail(status, error && error.code || 'internal_error', status >= 500 ? '服务暂不可用,请稍后重试' : error.message || '请求失败');
|
|
|
+ }
|
|
|
+}
|