repository.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. /*
  2. * Data repository utilities for SaaS VOC Cloud Functions
  3. * Provides read operations for Parse objects
  4. */
  5. /**
  6. * Limit a value to a bounded range
  7. */
  8. function boundedLimit(value, fallback, maximum = 100) {
  9. const limit = Number(value || fallback);
  10. return Number.isInteger(limit) ? Math.max(1, Math.min(limit, maximum)) : fallback;
  11. }
  12. /**
  13. * Safely serialize a value, removing sensitive fields
  14. */
  15. function safeValue(value, depth) {
  16. if (depth > 5 || value === null || value === undefined) return value;
  17. if (value instanceof Date) return value;
  18. if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
  19. if (typeof value !== 'object') return value;
  20. const output = {};
  21. Object.keys(value).slice(0, 100).forEach((key) => {
  22. if (/token|secret|password|credential|authorization|master/i.test(key)) return;
  23. output[key] = safeValue(value[key], depth + 1);
  24. });
  25. return output;
  26. }
  27. // Allowed actions - maps action to Parse class names for generic read operations
  28. const CLASS_BY_READ_ACTION = {
  29. 'workspace.list': 'VocWorkspace',
  30. 'workspace.members.list': 'VocWorkspaceMember',
  31. 'data-source.list': 'VocSourceConnection',
  32. 'import.list': 'VocImportBatch',
  33. 'audit.list': 'VocAuditLog',
  34. 'domestic.products.list': 'VocProduct',
  35. 'domestic.reviews.list': 'VocReview',
  36. 'domestic.relations.list': 'VocProductRelation',
  37. 'sync.jobs.list': 'VocSyncJob',
  38. 'sync.job.events': 'VocSyncJobEvent',
  39. 'analysis.list': 'VocAnalysisRun',
  40. 'insight-decision.list': 'VocInsightDecision',
  41. 'action.list': 'VocActionItem',
  42. 'alert.list': 'VocAlert',
  43. 'knowledge.products.list': 'VocProductKnowledge',
  44. 'ai.prompts.list': 'VocPromptConfig',
  45. 'listing.products.list': 'VocListingSourceSnapshot',
  46. 'listing.score-job.list': 'VocListingScoreJob',
  47. 'listing.versions.list': 'VocListingVersion',
  48. 'competitor.tasks.list': 'VocCompetitorOptimizationTask',
  49. };
  50. // Fields used for product scope filtering
  51. const PRODUCT_SCOPE_FIELDS = {
  52. VocProduct: 'productId',
  53. VocReview: 'productId',
  54. VocDailyMetric: 'productId',
  55. VocProductRelation: 'ownProductId',
  56. VocProductKnowledge: 'productId',
  57. VocListingSourceSnapshot: 'productId',
  58. VocListingCurrentScore: 'productId',
  59. VocListingVersion: 'productId',
  60. VocCompetitorListingSnapshot: 'productId',
  61. VocCompetitorListingChange: 'productId',
  62. VocCompetitorOptimizationTask: 'ownProductId',
  63. };
  64. // Fields used for read filtering by class
  65. const READ_FILTER_FIELDS = {
  66. VocProduct: ['platform', 'role'],
  67. VocReview: ['platform', 'productId'],
  68. VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
  69. VocSyncJob: ['status', 'platform'],
  70. VocSyncJobEvent: ['jobId', 'eventType'],
  71. VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
  72. VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
  73. VocActionItem: ['status', 'actionType', 'productKey'],
  74. VocAlert: ['status', 'alertType', 'productKey'],
  75. VocProductKnowledge: ['productId', 'productKey', 'status'],
  76. VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
  77. VocListingScoreJob: ['status', 'platform'],
  78. VocListingVersion: ['productId', 'status'],
  79. VocCompetitorListingSnapshot: ['platform', 'productId'],
  80. VocCompetitorListingChange: ['platform', 'productId'],
  81. VocCompetitorOptimizationTask: ['status', 'ownProductId'],
  82. };
  83. /**
  84. * Read a paginated list of objects
  85. */
  86. async function readMany(className, workspaceId, params = {}, productIds = null, maximum = 100) {
  87. const query = new Parse.Query(className);
  88. const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId';
  89. query.equalTo(idField, workspaceId);
  90. applyReadFilters(query, className, params);
  91. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
  92. const visibleIds = await visibleProductIds(className, workspaceId, productIds);
  93. query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
  94. }
  95. const limit = boundedLimit(params.limit, 50, maximum);
  96. // Handle cursor-based pagination
  97. if (typeof params.cursor === 'string' && params.cursor) {
  98. const cursorDate = new Date(params.cursor);
  99. if (Number.isNaN(cursorDate.getTime())) {
  100. throw { status: 400, code: 'invalid_cursor', message: '分页游标无效' };
  101. }
  102. query.lessThan('createdAt', cursorDate);
  103. }
  104. if (Number.isInteger(params.skip) && params.skip >= 0) {
  105. query.skip(Math.min(params.skip, 100000));
  106. }
  107. query.limit(limit);
  108. query.descending('createdAt');
  109. const rows = await query.find({ useMasterKey: true });
  110. const last = rows[rows.length - 1];
  111. const nextCursor = Number.isInteger(params.skip)
  112. ? (rows.length === limit ? String(params.skip + rows.length) : null)
  113. : (rows.length === limit && last && last.createdAt ? last.createdAt.toISOString() : null);
  114. return {
  115. items: rows.map((row) => safeValue(row.toJSON(), 0)),
  116. nextCursor,
  117. };
  118. }
  119. /**
  120. * Apply read filters to a query based on class and params
  121. */
  122. function applyReadFilters(query, className, params) {
  123. const fields = READ_FILTER_FIELDS[className] || [];
  124. for (const field of fields) {
  125. const value = params?.[field];
  126. if (value !== undefined && value !== null && value !== '') {
  127. query.equalTo(field, value);
  128. }
  129. }
  130. // Special case for product search
  131. if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) {
  132. query.contains('title', params.search.trim().slice(0, 200));
  133. }
  134. }
  135. /**
  136. * Read all objects (with pagination)
  137. */
  138. async function readAll(className, workspaceId, params = {}, productIds = null, maximum = 10000) {
  139. const items = [];
  140. while (items.length < maximum) {
  141. const page = await readMany(
  142. className,
  143. workspaceId,
  144. { ...params, limit: Math.min(100, maximum - items.length), skip: items.length },
  145. productIds,
  146. 100
  147. );
  148. items.push(...page.items);
  149. if (!page.nextCursor || !page.items.length) break;
  150. }
  151. return items;
  152. }
  153. /**
  154. * Read all objects matching specific filters
  155. */
  156. async function readAllWhere(className, workspaceId, filters = {}, productIds = null, maximum = 10000) {
  157. const items = [];
  158. while (items.length < maximum) {
  159. const query = new Parse.Query(className);
  160. const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId';
  161. query.equalTo(idField, workspaceId);
  162. Object.entries(filters || {}).forEach(([key, value]) => {
  163. if (value !== undefined && value !== null && value !== '') {
  164. query.equalTo(key, value);
  165. }
  166. });
  167. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
  168. const visibleIds = await visibleProductIds(className, workspaceId, productIds);
  169. query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
  170. }
  171. const pageSize = Math.min(100, maximum - items.length);
  172. query.skip(items.length);
  173. query.limit(pageSize);
  174. query.descending('createdAt');
  175. const rows = await query.find({ useMasterKey: true });
  176. items.push(...rows.map((row) => safeValue(row.toJSON(), 0)));
  177. if (rows.length < pageSize) break;
  178. }
  179. return items;
  180. }
  181. /**
  182. * Read a single object by ID
  183. */
  184. async function readOne(className, workspaceId, objectId, productIds = null) {
  185. if (!objectId || typeof objectId !== 'string' || objectId.length > 200) {
  186. throw { status: 400, code: 'invalid_id', message: '标识无效' };
  187. }
  188. const query = new Parse.Query(className);
  189. query.equalTo('workspaceId', workspaceId);
  190. const idField = className === 'VocProduct' ? 'productId' : 'publicId';
  191. query.equalTo(idField, objectId);
  192. if (productIds !== null && className === 'VocProduct') {
  193. query.containedIn('productId', productIds);
  194. }
  195. const row = await query.first({ useMasterKey: true });
  196. return row ? safeValue(row.toJSON(), 0) : null;
  197. }
  198. /**
  199. * Find a single object by a specific field
  200. */
  201. async function findObject(className, workspaceId, field, value) {
  202. const query = new Parse.Query(className);
  203. query.equalTo('workspaceId', workspaceId);
  204. query.equalTo(field, value);
  205. return query.first({ useMasterKey: true });
  206. }
  207. /**
  208. * Find multiple objects matching filters
  209. */
  210. async function findObjects(className, workspaceId, filters = {}, limit = 50, productIds = null) {
  211. const query = new Parse.Query(className);
  212. query.equalTo('workspaceId', workspaceId);
  213. Object.entries(filters || {}).forEach(([key, value]) => {
  214. if (value !== undefined && value !== null && value !== '') {
  215. query.equalTo(key, value);
  216. }
  217. });
  218. if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
  219. const visibleIds = await visibleProductIds(className, workspaceId, productIds);
  220. query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
  221. }
  222. query.limit(boundedLimit(limit, 50));
  223. query.descending('createdAt');
  224. const rows = await query.find({ useMasterKey: true });
  225. return rows.map((row) => safeValue(row.toJSON(), 0));
  226. }
  227. /**
  228. * Get product IDs visible for a class (including competitors)
  229. */
  230. async function visibleProductIds(className, workspaceId, productIds) {
  231. if (!productIds || !['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'].includes(className)) {
  232. return productIds;
  233. }
  234. const relationQuery = new Parse.Query('VocProductRelation');
  235. relationQuery.equalTo('workspaceId', workspaceId);
  236. relationQuery.containedIn('ownProductId', productIds);
  237. relationQuery.limit(10000);
  238. const relations = await relationQuery.find({ useMasterKey: true });
  239. const competitorIds = relations
  240. .map((relation) => String(relation.get('competitorProductId') || ''))
  241. .filter(Boolean);
  242. return [...new Set(productIds.concat(competitorIds))];
  243. }
  244. /**
  245. * Present a read item with normalized fields
  246. */
  247. function presentReadItem(action, item) {
  248. if (action === 'listing.score-job.list') return presentListingJob(item);
  249. const output = { ...item };
  250. if (!output.id) {
  251. output.id = output.publicId || output.objectId || output.productId;
  252. }
  253. if (action === 'data-source.list') {
  254. output.kind = output.kind || output.connectionKind || '';
  255. output.credentialStorage = output.credentialStorage || output.metadata?.credentialStorage || 'external_secret';
  256. }
  257. if (action === 'import.list') {
  258. output.id = output.id || output.publicId;
  259. }
  260. if (action === 'domestic.reviews.list') {
  261. output.reviewId = output.reviewId || output.reviewKey || output.sourceReviewId || output.id;
  262. }
  263. if (action === 'sync.jobs.list') {
  264. output.id = output.publicId || output.id;
  265. }
  266. if (action === 'sync.job.events') {
  267. output.type = output.type || output.eventType || '';
  268. }
  269. if (action === 'listing.versions.list') {
  270. output.id = output.publicId || output.id;
  271. }
  272. return output;
  273. }
  274. /**
  275. * Present a listing job item
  276. */
  277. function presentListingJob(item) {
  278. const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
  279. return {
  280. ...payload,
  281. id: payload.id || item.publicId || item.objectId,
  282. workspaceId: payload.workspaceId || item.workspaceId,
  283. platform: payload.platform || item.platform || 'jd',
  284. idempotencyKey: payload.idempotencyKey || item.idempotencyKey,
  285. requestHash: payload.requestHash || item.requestHash,
  286. status: item.status || payload.status,
  287. };
  288. }
  289. /**
  290. * Present a listing job item
  291. */
  292. function presentListingJobItem(item) {
  293. const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
  294. return {
  295. ...payload,
  296. id: payload.id || item.publicId || item.objectId,
  297. jobId: payload.jobId || item.jobId,
  298. workspaceId: payload.workspaceId || item.workspaceId,
  299. productId: payload.productId || item.productId,
  300. sourceHash: payload.sourceHash || item.sourceHash,
  301. status: item.status || payload.status,
  302. };
  303. }
  304. /**
  305. * Present a read page with normalized items
  306. */
  307. function presentReadPage(action, page) {
  308. return {
  309. ...page,
  310. items: page.items.map((item) => presentReadItem(action, item)),
  311. };
  312. }
  313. /**
  314. * Safe value serializer (lazy loaded to avoid circular dependency)
  315. */
  316. function safeValue(value, depth) {
  317. if (depth > 5 || value === null || value === undefined) return value;
  318. if (value instanceof Date) return value;
  319. if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
  320. if (typeof value !== 'object') return value;
  321. const output = {};
  322. Object.keys(value).slice(0, 100).forEach((key) => {
  323. if (/token|secret|password|credential|authorization|master/i.test(key)) return;
  324. output[key] = safeValue(value[key], depth + 1);
  325. });
  326. return output;
  327. }
  328. /*
  329. // module.exports = {
  330. // boundedLimit,
  331. // readMany,
  332. // applyReadFilters,
  333. // readAll,
  334. // readAllWhere,
  335. // readOne,
  336. // findObject,
  337. // findObjects,
  338. // visibleProductIds,
  339. // presentReadItem,
  340. // presentListingJob,
  341. // presentListingJobItem,
  342. // presentReadPage,
  343. // safeValue,
  344. // CLASS_BY_READ_ACTION,
  345. // };
  346. */