import { Injectable } from '@angular/core'; import { Observable, from, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { MSQParse } from '../../parse'; import { PermissionService } from '../../../modules/shared/services/permission.service'; interface DataStoreQueryOptions { limit?: number; skip?: number; orderBy?: string; descending?: boolean; include?: string[]; select?: string[]; exclude?: string[]; } /** * 数据持久化服务 * 使用 Parse 将 API 数据保存至数据库 */ @Injectable({ providedIn: 'root' }) export class DataStoreService { private readonly OWN_PRODUCT_CLASSES = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']); private readonly SHOP_SAFE_SELECT_KEYS = [ 'name', 'shopName', 'storeName', 'platform', 'region', 'marketplaceId', 'domain', 'nodeIds', 'sync_status', 'last_sync_at', 'status', 'deleted', 'disable', 'createdAt', 'updatedAt' ]; private readonly GLOBAL_SENSITIVE_EXCLUDE_KEYS = [ 'credentials', 'SpApiConfig', 'accessToken', 'access_token', 'accessTokenExpiresAt', 'refreshToken', 'refresh_token', 'clientSecret', 'client_secret', 'secret', 'authorization', 'shop.config', 'shop.credentials', 'shop.SpApiConfig', 'shop.config.SpApiConfig', 'shop.accessToken', 'shop.access_token', 'shop.accessTokenExpiresAt', 'shop.refreshToken', 'shop.refresh_token', 'shop.clientSecret', 'shop.client_secret', 'shop.clientId', 'shop.client_id', 'shop.sellerID', 'shop.sellerId' ]; private readonly SHOP_SENSITIVE_EXCLUDE_KEYS = [ 'config', 'credentials', 'SpApiConfig', 'accessToken', 'access_token', 'accessTokenExpiresAt', 'refreshToken', 'refresh_token', 'clientSecret', 'client_secret', 'clientId', 'client_id', 'sellerID', 'sellerId', 'secret', 'authorization' ]; private readonly SENSITIVE_KEY_NAMES = new Set([ 'credentials', 'spapiconfig', 'accesstoken', 'access_token', 'accesstokenexpiresat', 'refreshtoken', 'refresh_token', 'clientsecret', 'client_secret', 'secret', 'authorization' ]); constructor(private permissionService: PermissionService) { } /** * 保存数据到 Parse 数据库 * @param className Parse 类名 * @param data 要保存的数据 * @param uniqueKey 唯一标识字段名(用于去重更新) */ save(className: string, data: Record, uniqueKey?: string): Observable { return from(this.saveToParseClass(className, data, uniqueKey)).pipe( catchError(err => { console.warn(`[DataStore] 保存 ${className} 失败(已回退):`, err?.message || err); return of(null); }) ); } /** * 批量保存数据 */ saveBatch(className: string, items: Record[], uniqueKey?: string): Observable { const promises = items.map(item => this.saveToParseClass(className, item, uniqueKey)); return from(Promise.all(promises)).pipe( catchError(err => { console.warn(`[DataStore] 批量保存 ${className} 失败(已回退):`, err?.message || err); return of([]); }) ); } /** * 查询 Parse 数据 */ query(className: string, filters?: Record, options?: { limit?: number; skip?: number; orderBy?: string; descending?: boolean; include?: string[]; select?: string[]; exclude?: string[]; }): Observable { return from(this.queryParseClass(className, filters, options)).pipe( catchError(err => { console.warn(`[DataStore] 查询 ${className} 失败(已回退):`, err?.message || err); return of([]); }) ); } /** * 调用 Parse Cloud Function */ cloudFunction(params: { id: string;[key: string]: any }): Observable { return from(MSQParse.Cloud.function(params)).pipe( catchError(err => { console.warn(`[DataStore] Cloud Function 调用失败(已回退):`, err?.message || err); return of(null); }) ); } /** * 查询 Parse 数据条数 */ count(className: string, filters?: Record): Observable { return from(this.countParseClass(className, filters)).pipe( catchError(err => { console.warn(`[DataStore] 计数 ${className} 失败(已回退):`, err?.message || err); return of(0); }) ); } /** * 查询指定日期之后创建的记录数 */ countSince(className: string, sinceDate: Date): Observable { return from((async () => { const query = new MSQParse.Query(className); query.greaterThanOrEqualTo('createdAt', sinceDate); return query.count(); })()).pipe( catchError(err => { console.warn(`[DataStore] 计数 ${className} (since) 失败(已回退):`, err?.message || err); return of(0); }) ); } /** * 删除 Parse 数据库中的记录 * @param className Parse 类名 * @param objectId 要删除的记录 objectId */ delete(className: string, objectId: string): Observable { return from((async () => { const query = new MSQParse.Query(className); const obj = await query.get(objectId); if (obj) return obj.destroy(); return null; })()).pipe( catchError(err => { console.warn(`[DataStore] 删除 ${className}/${objectId} 失败:`, err?.message || err); return of(null); }) ); } // ==================== 内部方法 ==================== private async saveToParseClass( className: string, data: Record, uniqueKey?: string ): Promise { // 注意:在 fmode-ng 的 Parse 封装中,不推荐使用 Object.extend, // 需要直接使用字符串类名构造 Query / Object,否则可能落到默认 ExtendedClass // 如果有唯一键,先查询是否存在 if (uniqueKey && data[uniqueKey]) { const query = new MSQParse.Query(className); query.equalTo(uniqueKey, data[uniqueKey]); const existing = await query.first(); if (existing) { Object.entries(data).forEach(([key, value]) => { existing.set(key, value); }); existing.set('updatedAt_custom', new Date().toISOString()); return existing.save(); } } // 创建新记录 const obj = new MSQParse.Object(className); Object.entries(data).forEach(([key, value]) => { obj.set(key, value); }); obj.set('createdAt_custom', new Date().toISOString()); return obj.save(); } private async queryParseClass( className: string, filters?: Record, options?: DataStoreQueryOptions ): Promise { const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters); if (permissionFilters === null) return []; // 检查是否有大数组 containedIn 需要分批 const BATCH_THRESHOLD = 150; if (permissionFilters) { for (const [key, value] of Object.entries(permissionFilters)) { if (Array.isArray(value) && value.length > BATCH_THRESHOLD) { return this.batchedContainedInQuery(className, key, value, permissionFilters, options); } } } // 直接使用类名字符串构造 Query,确保真正访问到对应的 Parse 表 const query = new MSQParse.Query(className); this.applyQueryFilters(query, permissionFilters); this.applyQueryProjection(query, className, options); if (options?.limit) query.limit(options.limit); if (options?.skip) query.skip(options.skip); if (options?.orderBy) { if (options.descending) { query.descending(options.orderBy); } else { query.ascending(options.orderBy); } } const results = await query.find(); return results.map((r: any) => this.toClientRecord(className, r)); } /** 自动分批 containedIn 查询,避免 URL 过长导致 ERR_FAILED */ private async batchedContainedInQuery( className: string, arrayKey: string, arrayValues: any[], filters: Record, options?: DataStoreQueryOptions ): Promise { const BATCH_SIZE = 150; const perBatchLimit = options?.limit ? Math.min(options.limit, 1000) : 1000; const allResults: any[] = []; for (let i = 0; i < arrayValues.length; i += BATCH_SIZE) { const batch = arrayValues.slice(i, i + BATCH_SIZE); const query = new MSQParse.Query(className); // 设置当前批次的 containedIn (query as any).containedIn(arrayKey, batch); this.applyQueryFilters(query, filters, arrayKey); this.applyQueryProjection(query, className, options); query.limit(perBatchLimit); if (options?.orderBy) { if (options.descending) { query.descending(options.orderBy); } else { query.ascending(options.orderBy); } } const results = await query.find(); allResults.push(...results.map((r: any) => this.toClientRecord(className, r))); } // 如果有全局 limit,截断 if (options?.limit && allResults.length > options.limit) { return allResults.slice(0, options.limit); } return allResults; } private async countParseClass( className: string, filters?: Record ): Promise { const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters); if (permissionFilters === null) return 0; const query = new MSQParse.Query(className); this.applyQueryFilters(query, permissionFilters); return query.count(); } private applyQueryProjection(query: any, className: string, options?: DataStoreQueryOptions): void { (options?.include || []).forEach(path => { if (path) query.include(path); }); const selectKeys = options?.select?.length ? options.select : (className === 'Shop' ? this.SHOP_SAFE_SELECT_KEYS : []); if (selectKeys.length) query.select(selectKeys); const excludeKeys = this.getSensitiveExcludeKeys(className, options?.exclude || []); if (excludeKeys.length && typeof query.exclude === 'function') { query.exclude(excludeKeys); } } private getSensitiveExcludeKeys(className: string, extraExcludeKeys: string[]): string[] { const keys = new Set([ ...this.GLOBAL_SENSITIVE_EXCLUDE_KEYS, ...extraExcludeKeys ]); if (className === 'Shop') { this.SHOP_SENSITIVE_EXCLUDE_KEYS.forEach(key => keys.add(key)); } return Array.from(keys); } private toClientRecord(className: string, row: any): any { const raw = this.safeToPlainObject(row); return this.sanitizeForClient(className, raw, [], new WeakSet()); } private safeToPlainObject(value: any): any { if (!value || typeof value !== 'object') return value; if (this.isParseObjectLike(value)) { return this.parseObjectToShallowJson(value, true); } if (typeof value.toJSON === 'function') { try { return value.toJSON(); } catch { return this.parseObjectToShallowJson(value, true); } } return value; } private sanitizeForClient(className: string, value: any, path: string[] = [], seen: WeakSet = new WeakSet()): any { if (Array.isArray(value)) { return value.map(item => this.sanitizeForClient(className, item, path, seen)); } if (!value || typeof value !== 'object') return value; if (value instanceof Date) return value.toISOString(); if (path.length > 10) return this.circularReferenceToShallowJson(value); if (this.isParseObjectLike(value)) { return path.length === 0 ? this.sanitizeForClient(className, this.safeToPlainObject(value), path, seen) : this.parseObjectToShallowJson(value); } if (seen.has(value)) return this.circularReferenceToShallowJson(value); seen.add(value); const sanitized: Record = {}; Object.entries(value).forEach(([key, nestedValue]) => { if (this.shouldDropSensitiveKey(className, key, path)) return; sanitized[key] = this.sanitizeForClient(className, nestedValue, [...path, key], seen); }); return sanitized; } private isParseObjectLike(value: any): boolean { return !!value && typeof value === 'object' && ( typeof value.toPointer === 'function' || value.__type === 'Pointer' || value.__type === 'Object' || (typeof value.className === 'string' && (value.attributes || value.id)) ); } private parseObjectToShallowJson(value: any, includeDataFields = false): Record { const data = value?.data && typeof value.data === 'object' ? value.data : (value?.attributes && typeof value.attributes === 'object' ? value.attributes : value); const objectId = value?.id || value?.objectId || data?.objectId || ''; const className = value?.className || data?.className || ''; const result: Record = {}; if (className) result['className'] = className; if (objectId) result['objectId'] = objectId; if (includeDataFields && data && data !== value) { Object.entries(data).forEach(([key, nested]) => { if (nested !== undefined) result[key] = nested; }); } [ 'name', 'shopName', 'storeName', 'platform', 'region', 'marketplaceId', 'domain', 'status', 'deleted', 'disable', 'createdAt', 'updatedAt' ].forEach(key => { const nested = value?.[key] ?? data?.[key]; if (nested === undefined || nested === null) return; result[key] = nested instanceof Date ? nested.toISOString() : nested; }); if (!includeDataFields && !result['__type'] && className && objectId) result['__type'] = 'Pointer'; return result; } private circularReferenceToShallowJson(value: any): Record { const objectId = value?.objectId || value?.id || ''; const className = value?.className || ''; const result: Record = {}; if (className) result['className'] = className; if (objectId) result['objectId'] = objectId; if (className && objectId) result['__type'] = 'Pointer'; return result; } private shouldDropSensitiveKey(className: string, key: string, path: string[]): boolean { const lowerKey = key.toLowerCase(); const normalizedKey = key.replace(/[_-]/g, '').toLowerCase(); const normalizedPath = path.map(part => part.replace(/[_-]/g, '').toLowerCase()); const isShopObject = className === 'Shop' || normalizedPath.includes('shop'); const isSensitiveContainer = normalizedPath.some(part => part === 'config' || part === 'credentials' || part === 'spapiconfig'); if (lowerKey.startsWith('_')) return true; if (lowerKey === 'data' && normalizedPath.includes('shop')) return true; if (lowerKey === 'config' && isShopObject) return true; if (this.SENSITIVE_KEY_NAMES.has(lowerKey) || this.SENSITIVE_KEY_NAMES.has(normalizedKey)) { return isShopObject || isSensitiveContainer; } return false; } private applyQueryFilters(query: any, filters?: Record | null, skipKey?: string): void { if (!filters) return; Object.entries(filters).forEach(([key, value]) => { if (key === skipKey || value === undefined || value === null) return; if (Array.isArray(value)) { if (value.length > 0) query.containedIn(key, value); return; } if (this.isOperatorFilter(value)) { Object.entries(value).forEach(([operator, raw]) => { const normalized = this.normalizeQueryValue(raw); switch (operator) { case '$ne': case '$neq': query.notEqualTo(key, normalized); break; case '$in': if (Array.isArray(normalized) && normalized.length > 0) query.containedIn(key, normalized); break; case '$nin': if (Array.isArray(normalized) && normalized.length > 0) query.notContainedIn(key, normalized); break; case '$gte': query.greaterThanOrEqualTo(key, normalized); break; case '$lte': query.lessThanOrEqualTo(key, normalized); break; case '$gt': query.greaterThan(key, normalized); break; case '$lt': query.lessThan(key, normalized); break; case '$exists': if (!!normalized) { query.exists(key); } else { query.doesNotExist(key); } break; } }); return; } query.equalTo(key, this.normalizeQueryValue(value)); }); } private isOperatorFilter(value: any): boolean { return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).some(key => key.startsWith('$')); } private normalizeQueryValue(value: any): any { if (Array.isArray(value)) return value.map(item => this.normalizeQueryValue(item)); if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) { return new Date(value.iso); } return value; } private async applyOwnProductPermissionFilters( className: string, filters?: Record ): Promise | null> { if (!this.OWN_PRODUCT_CLASSES.has(className)) return filters || {}; const context = await this.permissionService.getOwnProductAccessContext(); if (!context.restricted) return filters || {}; if (!context.asins.size) return null; return this.mergeAllowedAsins(filters || {}, Array.from(context.asins)); } private mergeAllowedAsins(filters: Record, allowedAsins: string[]): Record | null { const asinKey = ['asin', 'Asin', 'ASIN'].find(key => Object.prototype.hasOwnProperty.call(filters, key)); if (!asinKey) { return { asin: allowedAsins, ...filters }; } const rawValue = filters[asinKey]; const allowedSet = new Set(allowedAsins.map(value => this.normalizeAsin(value)).filter(Boolean)); let nextValue: any; if (Array.isArray(rawValue)) { nextValue = rawValue .map(value => String(value || '').trim()) .filter(value => allowedSet.has(this.normalizeAsin(value))); if (!nextValue.length) return null; } else { const normalized = this.normalizeAsin(rawValue); if (!normalized || !allowedSet.has(normalized)) return null; nextValue = rawValue; } return { ...filters, [asinKey]: nextValue }; } private normalizeAsin(value: any): string { return String(value || '').trim().toUpperCase(); } }