| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- 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<string, any>, uniqueKey?: string): Observable<any> {
- 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<string, any>[], uniqueKey?: string): Observable<any[]> {
- 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<string, any>, options?: {
- limit?: number;
- skip?: number;
- orderBy?: string;
- descending?: boolean;
- include?: string[];
- select?: string[];
- exclude?: string[];
- }): Observable<any[]> {
- 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<any> {
- 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<string, any>): Observable<number> {
- 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<number> {
- 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<any> {
- 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<string, any>,
- uniqueKey?: string
- ): Promise<any> {
- // 注意:在 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<string, any>,
- options?: DataStoreQueryOptions
- ): Promise<any[]> {
- 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<string, any>,
- options?: DataStoreQueryOptions
- ): Promise<any[]> {
- 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<string, any>
- ): Promise<number> {
- 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<string>([
- ...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<object>());
- }
- 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<object> = new WeakSet<object>()): 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<string, any> = {};
- 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<string, any> {
- 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<string, any> = {};
- 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<string, any> {
- const objectId = value?.objectId || value?.id || '';
- const className = value?.className || '';
- const result: Record<string, any> = {};
- 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<string, any> | 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<string, any>
- ): Promise<Record<string, any> | 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<string, any>, allowedAsins: string[]): Record<string, any> | 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();
- }
- }
|