/* * Data repository utilities for SaaS VOC Cloud Functions * Provides read operations for Parse objects */ /** * Limit a value to a bounded range */ function boundedLimit(value, fallback, maximum = 100) { const limit = Number(value || fallback); return Number.isInteger(limit) ? Math.max(1, Math.min(limit, maximum)) : fallback; } /** * Safely serialize a value, removing sensitive fields */ 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; } // Allowed actions - maps action to Parse class names for generic read operations 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', }; // Fields used for product scope filtering 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', }; // Fields used for read filtering by class 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'], }; /** * Read a paginated list of objects */ async function readMany(className, workspaceId, params = {}, productIds = null, maximum = 100) { const query = new Parse.Query(className); const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId'; query.equalTo(idField, workspaceId); applyReadFilters(query, className, params); if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) { const visibleIds = await visibleProductIds(className, workspaceId, productIds); query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds); } const limit = boundedLimit(params.limit, 50, maximum); // Handle cursor-based pagination 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, }; } /** * Apply read filters to a query based on class and params */ function applyReadFilters(query, className, params) { const fields = READ_FILTER_FIELDS[className] || []; for (const field of fields) { const value = params?.[field]; if (value !== undefined && value !== null && value !== '') { query.equalTo(field, value); } } // Special case for product search if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) { query.contains('title', params.search.trim().slice(0, 200)); } } /** * Read all objects (with pagination) */ async function readAll(className, workspaceId, params = {}, productIds = null, 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; } /** * Read all objects matching specific filters */ async function readAllWhere(className, workspaceId, filters = {}, productIds = null, maximum = 10000) { const items = []; while (items.length < maximum) { const query = new Parse.Query(className); const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId'; query.equalTo(idField, workspaceId); Object.entries(filters || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { query.equalTo(key, value); } }); if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) { const visibleIds = await visibleProductIds(className, workspaceId, productIds); query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds); } 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; } /** * Read a single object by ID */ async function readOne(className, workspaceId, objectId, productIds = null) { 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); const idField = className === 'VocProduct' ? 'productId' : 'publicId'; query.equalTo(idField, objectId); if (productIds !== null && className === 'VocProduct') { query.containedIn('productId', productIds); } const row = await query.first({ useMasterKey: true }); return row ? safeValue(row.toJSON(), 0) : null; } /** * Find a single object by a specific field */ 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 }); } /** * Find multiple objects matching filters */ async function findObjects(className, workspaceId, filters = {}, limit = 50, productIds = null) { 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]) { const visibleIds = await visibleProductIds(className, workspaceId, productIds); query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds); } query.limit(boundedLimit(limit, 50)); query.descending('createdAt'); const rows = await query.find({ useMasterKey: true }); return rows.map((row) => safeValue(row.toJSON(), 0)); } /** * Get product IDs visible for a class (including competitors) */ 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))]; } /** * Present a read item with normalized fields */ 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; } /** * Present a listing job item */ function presentListingJob(item) { const payload = 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, }; } /** * Present a listing job item */ function presentListingJobItem(item) { const payload = 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, }; } /** * Present a read page with normalized items */ function presentReadPage(action, page) { return { ...page, items: page.items.map((item) => presentReadItem(action, item)), }; } /** * Safe value serializer (lazy loaded to avoid circular dependency) */ 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; } /* // module.exports = { // boundedLimit, // readMany, // applyReadFilters, // readAll, // readAllWhere, // readOne, // findObject, // findObjects, // visibleProductIds, // presentReadItem, // presentListingJob, // presentListingJobItem, // presentReadPage, // safeValue, // CLASS_BY_READ_ACTION, // }; */