data-store.service.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { Injectable } from '@angular/core';
  2. import { Observable, from, of } from 'rxjs';
  3. import { map, catchError } from 'rxjs/operators';
  4. import { MSQParse } from '../../parse';
  5. import { PermissionService } from '../../../modules/shared/services/permission.service';
  6. interface DataStoreQueryOptions {
  7. limit?: number;
  8. skip?: number;
  9. orderBy?: string;
  10. descending?: boolean;
  11. include?: string[];
  12. select?: string[];
  13. exclude?: string[];
  14. }
  15. /**
  16. * 数据持久化服务
  17. * 使用 Parse 将 API 数据保存至数据库
  18. */
  19. @Injectable({ providedIn: 'root' })
  20. export class DataStoreService {
  21. private readonly OWN_PRODUCT_CLASSES = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']);
  22. private readonly SHOP_SAFE_SELECT_KEYS = [
  23. 'name',
  24. 'shopName',
  25. 'storeName',
  26. 'platform',
  27. 'region',
  28. 'marketplaceId',
  29. 'domain',
  30. 'nodeIds',
  31. 'sync_status',
  32. 'last_sync_at',
  33. 'status',
  34. 'deleted',
  35. 'disable',
  36. 'createdAt',
  37. 'updatedAt'
  38. ];
  39. private readonly GLOBAL_SENSITIVE_EXCLUDE_KEYS = [
  40. 'credentials',
  41. 'SpApiConfig',
  42. 'accessToken',
  43. 'access_token',
  44. 'accessTokenExpiresAt',
  45. 'refreshToken',
  46. 'refresh_token',
  47. 'clientSecret',
  48. 'client_secret',
  49. 'secret',
  50. 'authorization',
  51. 'shop.config',
  52. 'shop.credentials',
  53. 'shop.SpApiConfig',
  54. 'shop.config.SpApiConfig',
  55. 'shop.accessToken',
  56. 'shop.access_token',
  57. 'shop.accessTokenExpiresAt',
  58. 'shop.refreshToken',
  59. 'shop.refresh_token',
  60. 'shop.clientSecret',
  61. 'shop.client_secret',
  62. 'shop.clientId',
  63. 'shop.client_id',
  64. 'shop.sellerID',
  65. 'shop.sellerId'
  66. ];
  67. private readonly SHOP_SENSITIVE_EXCLUDE_KEYS = [
  68. 'config',
  69. 'credentials',
  70. 'SpApiConfig',
  71. 'accessToken',
  72. 'access_token',
  73. 'accessTokenExpiresAt',
  74. 'refreshToken',
  75. 'refresh_token',
  76. 'clientSecret',
  77. 'client_secret',
  78. 'clientId',
  79. 'client_id',
  80. 'sellerID',
  81. 'sellerId',
  82. 'secret',
  83. 'authorization'
  84. ];
  85. private readonly SENSITIVE_KEY_NAMES = new Set([
  86. 'credentials',
  87. 'spapiconfig',
  88. 'accesstoken',
  89. 'access_token',
  90. 'accesstokenexpiresat',
  91. 'refreshtoken',
  92. 'refresh_token',
  93. 'clientsecret',
  94. 'client_secret',
  95. 'secret',
  96. 'authorization'
  97. ]);
  98. constructor(private permissionService: PermissionService) { }
  99. /**
  100. * 保存数据到 Parse 数据库
  101. * @param className Parse 类名
  102. * @param data 要保存的数据
  103. * @param uniqueKey 唯一标识字段名(用于去重更新)
  104. */
  105. save(className: string, data: Record<string, any>, uniqueKey?: string): Observable<any> {
  106. return from(this.saveToParseClass(className, data, uniqueKey)).pipe(
  107. catchError(err => {
  108. console.warn(`[DataStore] 保存 ${className} 失败(已回退):`, err?.message || err);
  109. return of(null);
  110. })
  111. );
  112. }
  113. /**
  114. * 批量保存数据
  115. */
  116. saveBatch(className: string, items: Record<string, any>[], uniqueKey?: string): Observable<any[]> {
  117. const promises = items.map(item => this.saveToParseClass(className, item, uniqueKey));
  118. return from(Promise.all(promises)).pipe(
  119. catchError(err => {
  120. console.warn(`[DataStore] 批量保存 ${className} 失败(已回退):`, err?.message || err);
  121. return of([]);
  122. })
  123. );
  124. }
  125. /**
  126. * 查询 Parse 数据
  127. */
  128. query(className: string, filters?: Record<string, any>, options?: {
  129. limit?: number;
  130. skip?: number;
  131. orderBy?: string;
  132. descending?: boolean;
  133. include?: string[];
  134. select?: string[];
  135. exclude?: string[];
  136. }): Observable<any[]> {
  137. return from(this.queryParseClass(className, filters, options)).pipe(
  138. catchError(err => {
  139. console.warn(`[DataStore] 查询 ${className} 失败(已回退):`, err?.message || err);
  140. return of([]);
  141. })
  142. );
  143. }
  144. /**
  145. * 调用 Parse Cloud Function
  146. */
  147. cloudFunction(params: { id: string;[key: string]: any }): Observable<any> {
  148. return from(MSQParse.Cloud.function(params)).pipe(
  149. catchError(err => {
  150. console.warn(`[DataStore] Cloud Function 调用失败(已回退):`, err?.message || err);
  151. return of(null);
  152. })
  153. );
  154. }
  155. /**
  156. * 查询 Parse 数据条数
  157. */
  158. count(className: string, filters?: Record<string, any>): Observable<number> {
  159. return from(this.countParseClass(className, filters)).pipe(
  160. catchError(err => {
  161. console.warn(`[DataStore] 计数 ${className} 失败(已回退):`, err?.message || err);
  162. return of(0);
  163. })
  164. );
  165. }
  166. /**
  167. * 查询指定日期之后创建的记录数
  168. */
  169. countSince(className: string, sinceDate: Date): Observable<number> {
  170. return from((async () => {
  171. const query = new MSQParse.Query(className);
  172. query.greaterThanOrEqualTo('createdAt', sinceDate);
  173. return query.count();
  174. })()).pipe(
  175. catchError(err => {
  176. console.warn(`[DataStore] 计数 ${className} (since) 失败(已回退):`, err?.message || err);
  177. return of(0);
  178. })
  179. );
  180. }
  181. /**
  182. * 删除 Parse 数据库中的记录
  183. * @param className Parse 类名
  184. * @param objectId 要删除的记录 objectId
  185. */
  186. delete(className: string, objectId: string): Observable<any> {
  187. return from((async () => {
  188. const query = new MSQParse.Query(className);
  189. const obj = await query.get(objectId);
  190. if (obj) return obj.destroy();
  191. return null;
  192. })()).pipe(
  193. catchError(err => {
  194. console.warn(`[DataStore] 删除 ${className}/${objectId} 失败:`, err?.message || err);
  195. return of(null);
  196. })
  197. );
  198. }
  199. // ==================== 内部方法 ====================
  200. private async saveToParseClass(
  201. className: string,
  202. data: Record<string, any>,
  203. uniqueKey?: string
  204. ): Promise<any> {
  205. // 注意:在 fmode-ng 的 Parse 封装中,不推荐使用 Object.extend,
  206. // 需要直接使用字符串类名构造 Query / Object,否则可能落到默认 ExtendedClass
  207. // 如果有唯一键,先查询是否存在
  208. if (uniqueKey && data[uniqueKey]) {
  209. const query = new MSQParse.Query(className);
  210. query.equalTo(uniqueKey, data[uniqueKey]);
  211. const existing = await query.first();
  212. if (existing) {
  213. Object.entries(data).forEach(([key, value]) => {
  214. existing.set(key, value);
  215. });
  216. existing.set('updatedAt_custom', new Date().toISOString());
  217. return existing.save();
  218. }
  219. }
  220. // 创建新记录
  221. const obj = new MSQParse.Object(className);
  222. Object.entries(data).forEach(([key, value]) => {
  223. obj.set(key, value);
  224. });
  225. obj.set('createdAt_custom', new Date().toISOString());
  226. return obj.save();
  227. }
  228. private async queryParseClass(
  229. className: string,
  230. filters?: Record<string, any>,
  231. options?: DataStoreQueryOptions
  232. ): Promise<any[]> {
  233. const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
  234. if (permissionFilters === null) return [];
  235. // 检查是否有大数组 containedIn 需要分批
  236. const BATCH_THRESHOLD = 150;
  237. if (permissionFilters) {
  238. for (const [key, value] of Object.entries(permissionFilters)) {
  239. if (Array.isArray(value) && value.length > BATCH_THRESHOLD) {
  240. return this.batchedContainedInQuery(className, key, value, permissionFilters, options);
  241. }
  242. }
  243. }
  244. // 直接使用类名字符串构造 Query,确保真正访问到对应的 Parse 表
  245. const query = new MSQParse.Query(className);
  246. this.applyQueryFilters(query, permissionFilters);
  247. this.applyQueryProjection(query, className, options);
  248. if (options?.limit) query.limit(options.limit);
  249. if (options?.skip) query.skip(options.skip);
  250. if (options?.orderBy) {
  251. if (options.descending) {
  252. query.descending(options.orderBy);
  253. } else {
  254. query.ascending(options.orderBy);
  255. }
  256. }
  257. const results = await query.find();
  258. return results.map((r: any) => this.toClientRecord(className, r));
  259. }
  260. /** 自动分批 containedIn 查询,避免 URL 过长导致 ERR_FAILED */
  261. private async batchedContainedInQuery(
  262. className: string,
  263. arrayKey: string,
  264. arrayValues: any[],
  265. filters: Record<string, any>,
  266. options?: DataStoreQueryOptions
  267. ): Promise<any[]> {
  268. const BATCH_SIZE = 150;
  269. const perBatchLimit = options?.limit ? Math.min(options.limit, 1000) : 1000;
  270. const allResults: any[] = [];
  271. for (let i = 0; i < arrayValues.length; i += BATCH_SIZE) {
  272. const batch = arrayValues.slice(i, i + BATCH_SIZE);
  273. const query = new MSQParse.Query(className);
  274. // 设置当前批次的 containedIn
  275. (query as any).containedIn(arrayKey, batch);
  276. this.applyQueryFilters(query, filters, arrayKey);
  277. this.applyQueryProjection(query, className, options);
  278. query.limit(perBatchLimit);
  279. if (options?.orderBy) {
  280. if (options.descending) {
  281. query.descending(options.orderBy);
  282. } else {
  283. query.ascending(options.orderBy);
  284. }
  285. }
  286. const results = await query.find();
  287. allResults.push(...results.map((r: any) => this.toClientRecord(className, r)));
  288. }
  289. // 如果有全局 limit,截断
  290. if (options?.limit && allResults.length > options.limit) {
  291. return allResults.slice(0, options.limit);
  292. }
  293. return allResults;
  294. }
  295. private async countParseClass(
  296. className: string,
  297. filters?: Record<string, any>
  298. ): Promise<number> {
  299. const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
  300. if (permissionFilters === null) return 0;
  301. const query = new MSQParse.Query(className);
  302. this.applyQueryFilters(query, permissionFilters);
  303. return query.count();
  304. }
  305. private applyQueryProjection(query: any, className: string, options?: DataStoreQueryOptions): void {
  306. (options?.include || []).forEach(path => {
  307. if (path) query.include(path);
  308. });
  309. const selectKeys = options?.select?.length
  310. ? options.select
  311. : (className === 'Shop' ? this.SHOP_SAFE_SELECT_KEYS : []);
  312. if (selectKeys.length) query.select(selectKeys);
  313. const excludeKeys = this.getSensitiveExcludeKeys(className, options?.exclude || []);
  314. if (excludeKeys.length && typeof query.exclude === 'function') {
  315. query.exclude(excludeKeys);
  316. }
  317. }
  318. private getSensitiveExcludeKeys(className: string, extraExcludeKeys: string[]): string[] {
  319. const keys = new Set<string>([
  320. ...this.GLOBAL_SENSITIVE_EXCLUDE_KEYS,
  321. ...extraExcludeKeys
  322. ]);
  323. if (className === 'Shop') {
  324. this.SHOP_SENSITIVE_EXCLUDE_KEYS.forEach(key => keys.add(key));
  325. }
  326. return Array.from(keys);
  327. }
  328. private toClientRecord(className: string, row: any): any {
  329. const raw = this.safeToPlainObject(row);
  330. return this.sanitizeForClient(className, raw, [], new WeakSet<object>());
  331. }
  332. private safeToPlainObject(value: any): any {
  333. if (!value || typeof value !== 'object') return value;
  334. if (this.isParseObjectLike(value)) {
  335. return this.parseObjectToShallowJson(value, true);
  336. }
  337. if (typeof value.toJSON === 'function') {
  338. try {
  339. return value.toJSON();
  340. } catch {
  341. return this.parseObjectToShallowJson(value, true);
  342. }
  343. }
  344. return value;
  345. }
  346. private sanitizeForClient(className: string, value: any, path: string[] = [], seen: WeakSet<object> = new WeakSet<object>()): any {
  347. if (Array.isArray(value)) {
  348. return value.map(item => this.sanitizeForClient(className, item, path, seen));
  349. }
  350. if (!value || typeof value !== 'object') return value;
  351. if (value instanceof Date) return value.toISOString();
  352. if (path.length > 10) return this.circularReferenceToShallowJson(value);
  353. if (this.isParseObjectLike(value)) {
  354. return path.length === 0
  355. ? this.sanitizeForClient(className, this.safeToPlainObject(value), path, seen)
  356. : this.parseObjectToShallowJson(value);
  357. }
  358. if (seen.has(value)) return this.circularReferenceToShallowJson(value);
  359. seen.add(value);
  360. const sanitized: Record<string, any> = {};
  361. Object.entries(value).forEach(([key, nestedValue]) => {
  362. if (this.shouldDropSensitiveKey(className, key, path)) return;
  363. sanitized[key] = this.sanitizeForClient(className, nestedValue, [...path, key], seen);
  364. });
  365. return sanitized;
  366. }
  367. private isParseObjectLike(value: any): boolean {
  368. return !!value
  369. && typeof value === 'object'
  370. && (
  371. typeof value.toPointer === 'function'
  372. || value.__type === 'Pointer'
  373. || value.__type === 'Object'
  374. || (typeof value.className === 'string' && (value.attributes || value.id))
  375. );
  376. }
  377. private parseObjectToShallowJson(value: any, includeDataFields = false): Record<string, any> {
  378. const data = value?.data && typeof value.data === 'object'
  379. ? value.data
  380. : (value?.attributes && typeof value.attributes === 'object' ? value.attributes : value);
  381. const objectId = value?.id || value?.objectId || data?.objectId || '';
  382. const className = value?.className || data?.className || '';
  383. const result: Record<string, any> = {};
  384. if (className) result['className'] = className;
  385. if (objectId) result['objectId'] = objectId;
  386. if (includeDataFields && data && data !== value) {
  387. Object.entries(data).forEach(([key, nested]) => {
  388. if (nested !== undefined) result[key] = nested;
  389. });
  390. }
  391. [
  392. 'name',
  393. 'shopName',
  394. 'storeName',
  395. 'platform',
  396. 'region',
  397. 'marketplaceId',
  398. 'domain',
  399. 'status',
  400. 'deleted',
  401. 'disable',
  402. 'createdAt',
  403. 'updatedAt'
  404. ].forEach(key => {
  405. const nested = value?.[key] ?? data?.[key];
  406. if (nested === undefined || nested === null) return;
  407. result[key] = nested instanceof Date ? nested.toISOString() : nested;
  408. });
  409. if (!includeDataFields && !result['__type'] && className && objectId) result['__type'] = 'Pointer';
  410. return result;
  411. }
  412. private circularReferenceToShallowJson(value: any): Record<string, any> {
  413. const objectId = value?.objectId || value?.id || '';
  414. const className = value?.className || '';
  415. const result: Record<string, any> = {};
  416. if (className) result['className'] = className;
  417. if (objectId) result['objectId'] = objectId;
  418. if (className && objectId) result['__type'] = 'Pointer';
  419. return result;
  420. }
  421. private shouldDropSensitiveKey(className: string, key: string, path: string[]): boolean {
  422. const lowerKey = key.toLowerCase();
  423. const normalizedKey = key.replace(/[_-]/g, '').toLowerCase();
  424. const normalizedPath = path.map(part => part.replace(/[_-]/g, '').toLowerCase());
  425. const isShopObject = className === 'Shop' || normalizedPath.includes('shop');
  426. const isSensitiveContainer = normalizedPath.some(part => part === 'config' || part === 'credentials' || part === 'spapiconfig');
  427. if (lowerKey.startsWith('_')) return true;
  428. if (lowerKey === 'data' && normalizedPath.includes('shop')) return true;
  429. if (lowerKey === 'config' && isShopObject) return true;
  430. if (this.SENSITIVE_KEY_NAMES.has(lowerKey) || this.SENSITIVE_KEY_NAMES.has(normalizedKey)) {
  431. return isShopObject || isSensitiveContainer;
  432. }
  433. return false;
  434. }
  435. private applyQueryFilters(query: any, filters?: Record<string, any> | null, skipKey?: string): void {
  436. if (!filters) return;
  437. Object.entries(filters).forEach(([key, value]) => {
  438. if (key === skipKey || value === undefined || value === null) return;
  439. if (Array.isArray(value)) {
  440. if (value.length > 0) query.containedIn(key, value);
  441. return;
  442. }
  443. if (this.isOperatorFilter(value)) {
  444. Object.entries(value).forEach(([operator, raw]) => {
  445. const normalized = this.normalizeQueryValue(raw);
  446. switch (operator) {
  447. case '$ne':
  448. case '$neq':
  449. query.notEqualTo(key, normalized);
  450. break;
  451. case '$in':
  452. if (Array.isArray(normalized) && normalized.length > 0) query.containedIn(key, normalized);
  453. break;
  454. case '$nin':
  455. if (Array.isArray(normalized) && normalized.length > 0) query.notContainedIn(key, normalized);
  456. break;
  457. case '$gte':
  458. query.greaterThanOrEqualTo(key, normalized);
  459. break;
  460. case '$lte':
  461. query.lessThanOrEqualTo(key, normalized);
  462. break;
  463. case '$gt':
  464. query.greaterThan(key, normalized);
  465. break;
  466. case '$lt':
  467. query.lessThan(key, normalized);
  468. break;
  469. case '$exists':
  470. if (!!normalized) {
  471. query.exists(key);
  472. } else {
  473. query.doesNotExist(key);
  474. }
  475. break;
  476. }
  477. });
  478. return;
  479. }
  480. query.equalTo(key, this.normalizeQueryValue(value));
  481. });
  482. }
  483. private isOperatorFilter(value: any): boolean {
  484. return value && typeof value === 'object' && !Array.isArray(value)
  485. && Object.keys(value).some(key => key.startsWith('$'));
  486. }
  487. private normalizeQueryValue(value: any): any {
  488. if (Array.isArray(value)) return value.map(item => this.normalizeQueryValue(item));
  489. if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) {
  490. return new Date(value.iso);
  491. }
  492. return value;
  493. }
  494. private async applyOwnProductPermissionFilters(
  495. className: string,
  496. filters?: Record<string, any>
  497. ): Promise<Record<string, any> | null> {
  498. if (!this.OWN_PRODUCT_CLASSES.has(className)) return filters || {};
  499. const context = await this.permissionService.getOwnProductAccessContext();
  500. if (!context.restricted) return filters || {};
  501. if (!context.asins.size) return null;
  502. return this.mergeAllowedAsins(filters || {}, Array.from(context.asins));
  503. }
  504. private mergeAllowedAsins(filters: Record<string, any>, allowedAsins: string[]): Record<string, any> | null {
  505. const asinKey = ['asin', 'Asin', 'ASIN'].find(key => Object.prototype.hasOwnProperty.call(filters, key));
  506. if (!asinKey) {
  507. return { asin: allowedAsins, ...filters };
  508. }
  509. const rawValue = filters[asinKey];
  510. const allowedSet = new Set(allowedAsins.map(value => this.normalizeAsin(value)).filter(Boolean));
  511. let nextValue: any;
  512. if (Array.isArray(rawValue)) {
  513. nextValue = rawValue
  514. .map(value => String(value || '').trim())
  515. .filter(value => allowedSet.has(this.normalizeAsin(value)));
  516. if (!nextValue.length) return null;
  517. } else {
  518. const normalized = this.normalizeAsin(rawValue);
  519. if (!normalized || !allowedSet.has(normalized)) return null;
  520. nextValue = rawValue;
  521. }
  522. return { ...filters, [asinKey]: nextValue };
  523. }
  524. private normalizeAsin(value: any): string {
  525. return String(value || '').trim().toUpperCase();
  526. }
  527. }