|
|
@@ -1,584 +1,61 @@
|
|
|
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[];
|
|
|
+import { Observable, catchError, forkJoin, of } from 'rxjs';
|
|
|
+import { CloudApiService, CloudRequest } from './cloud-api.service';
|
|
|
+import { RUNTIME_CONFIG } from '../config/runtime-config';
|
|
|
+
|
|
|
+// This is a protocol resource, not a user-controlled Parse class. The server
|
|
|
+// maps each value to a fixed projection and workspace-scoped repository query.
|
|
|
+const DATA_RESOURCES = new Set([
|
|
|
+ 'Shop', 'AmazonProduct', 'ProductDetail', 'Product', 'AsinSkuMapping', 'Listing', 'Order', 'OrderMetrics', 'Keyword', 'Category', 'Review',
|
|
|
+ 'SorftimeReviews', 'SorftimeProducts', 'SorftimeProduct', 'SelfCategory', 'AmazonCategory', 'MarketTrend', 'ReturnRecord', 'RealTimeVoc',
|
|
|
+ 'MonitoringStoreDetail', 'VocReviewAnnotation', 'WorkOrder', 'KnowledgeCompetitor', 'KnowledgeKeyword', 'KnowledgeInfluencer', 'KnowledgeInfluencerProfile', 'KnowledgeInfluencerPost', 'KnowledgePattern', 'KnowledgeHotProduct',
|
|
|
+ 'EmergingSocialTrendSnapshot', 'EmergingTrendSignal', 'EmergingTrendKeyword', 'EmergingOpportunity', 'SorftimeCategory', 'SorftimeMarketCapacity', 'RequirementStrength', 'RegionReturnData', 'TodoStatus', 'SocialMediaCache',
|
|
|
+]);
|
|
|
+
|
|
|
+function resourceFor(className: string): string {
|
|
|
+ if (!DATA_RESOURCES.has(className)) throw new Error('不支持的数据资源');
|
|
|
+ return className;
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * 数据持久化服务
|
|
|
- * 使用 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();
|
|
|
- }
|
|
|
+ constructor(private readonly cloud: CloudApiService) {}
|
|
|
+
|
|
|
+ save(className: string, data: Record<string, unknown>, uniqueKey?: string): Observable<unknown> {
|
|
|
+ if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null);
|
|
|
+ return this.cloud.run({ action: 'upstream.amazon', payload: { operation: 'data.save', resource: resourceFor(className), data, uniqueKey } }).pipe(catchError((error) => { console.warn('[DataStore] 写入失败:', error); return of(null); }));
|
|
|
+ }
|
|
|
+
|
|
|
+ saveBatch(className: string, items: Record<string, unknown>[], uniqueKey?: string): Observable<unknown[]> {
|
|
|
+ if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of([]);
|
|
|
+ if (items.length > 500) return of([]);
|
|
|
+ return forkJoin(items.map((item) => this.save(className, item, uniqueKey))).pipe(catchError((error) => { console.warn('[DataStore] 批量写入失败:', error); return of([]); }));
|
|
|
+ }
|
|
|
+
|
|
|
+ query(className: string, filters?: Record<string, unknown>, options?: Record<string, unknown>): Observable<any[]> {
|
|
|
+ if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of([]);
|
|
|
+ return this.cloud.run<{ items?: unknown[] }>({ action: 'upstream.amazon', payload: { operation: 'data.query', resource: resourceFor(className), filters: filters ?? {}, options: options ?? {} } }).pipe(
|
|
|
+ catchError((error) => { console.warn('[DataStore] 查询失败:', error); return of({ items: [] }); }),
|
|
|
+ // The legacy callers consume an array; pagination stays server-side.
|
|
|
+ // A missing/empty response is intentionally an empty result, never fake data.
|
|
|
+ (source) => new Observable<any[]>((subscriber) => source.subscribe({ next: (value) => subscriber.next(value.items ?? []), error: (error) => subscriber.error(error), complete: () => subscriber.complete() })),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ count(className: string, filters?: Record<string, unknown>): Observable<number> {
|
|
|
+ if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(0);
|
|
|
+ return this.cloud.run<{ count?: number }>({ action: 'upstream.amazon', payload: { operation: 'data.count', resource: resourceFor(className), filters: filters ?? {} } }).pipe(
|
|
|
+ catchError(() => of({ count: 0 })),
|
|
|
+ (source) => new Observable<number>((subscriber) => source.subscribe({ next: (value) => subscriber.next(Number(value.count ?? 0)), error: (error) => subscriber.error(error), complete: () => subscriber.complete() })),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ countSince(className: string, sinceDate: Date): Observable<number> { return this.count(className, { createdAt: { $gte: sinceDate.toISOString() } }); }
|
|
|
+ delete(className: string, objectId: string): Observable<unknown> { if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null); return this.cloud.run({ action: 'upstream.amazon', payload: { operation: 'data.delete', resource: resourceFor(className), objectId } }).pipe(catchError(() => of(null))); }
|
|
|
+
|
|
|
+ cloudFunction<T = unknown>(request: CloudRequest | Record<string, unknown>): Observable<T> {
|
|
|
+ if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null as T);
|
|
|
+ if (!('action' in request)) return of(null as T);
|
|
|
+ return this.cloud.run<T>(request as CloudRequest).pipe(catchError((error) => { console.warn('[DataStore] 云函数失败:', error); return of(null as T); }));
|
|
|
+ }
|
|
|
}
|