| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- /*
- * 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,
- // };
- */
|