/* * 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 || '请求失败'); } }