import { Injectable, Logger } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; const POD_HEADERS = { 'Content-Type': 'application/json;charset=UTF-8', Origin: 'https://inkpod.vip', Referer: 'https://inkpod.vip/', } as const; /** * Minimum number of category nodes a healthy `category/tree/3` response contains. * Below this the response is treated as degenerate and rejected so the caller * never runs a destructive sync against a partial tree. */ export const MIN_SDS_CATEGORY_NODES = 10; export interface SdsCategoryTreeNode { id: number | string | null; name?: string; icon?: string; children?: SdsCategoryTreeNode[]; [key: string]: unknown; } export interface SdsProduct { id: number | string; name?: string; title?: string; pic?: string; image?: string; psd_img_url?: string; thumbImgUrl?: string; blankDesignUrl?: string; img_url?: string; show_img?: string; price?: number | string; currentPrice?: number | string; categoryId?: number | string; [key: string]: unknown; } export interface SdsProductsPage { items?: SdsProduct[]; content?: SdsProduct[]; totalCount?: number; totalElements?: number; total?: number; page?: number; size?: number; [key: string]: unknown; } export interface SdsProductVariant extends Record { id?: number | string; sku?: string; size?: string; sizeId?: number | string; sizeDto?: { id?: number | string; sizeName?: string }; colorId?: number | string; color_name?: string; color?: { colorId?: number | string; color?: string; color_name?: string; chineseName?: string; }; currentPrice?: number | string; originalPrice?: number | string; unit_price?: number | string; min_price?: number | string; weight?: number | string; box_length?: number | string; box_width?: number | string; box_height?: number | string; status?: number | string; delFlag?: number | string; size_sort?: number | string; attribute_sort?: string; psd_img_url?: string; img_url?: string; blankDesignUrl?: string; designPrototype?: { detailImgUrls?: Array<{ imageUrl?: string }>; prototypeResultGroups?: Array<{ resultImage?: string }>; [key: string]: unknown; }; } export interface SdsProductDetail extends Record { id: number | string; name?: string; sku?: string; english_name?: string; blankDesignUrl?: string; detailsPageVideoUrl?: string; productionCycle?: number | string; minWeight?: number | string; min_price?: number | string; updateTime?: number | string; psd_img_url?: string; img_url?: string; texture?: { name?: string }; product_details?: { reminder?: string; production_process?: string; material_description?: string; product_performance?: string; applicable_scenarios?: string; washing_instructions?: string; special_description?: string; design_explanation?: string; design_area?: string; picture_request?: string; product_size?: string; packaging_specification?: string; }; subproducts?: { attributers?: Array<{ size?: string; sizeId?: number | string; colors?: Array<{ colorId?: number | string; color?: string; color_name?: string; chineseName?: string; colorSort?: number | string; }>; }>; items?: SdsProductVariant[]; }; } /** * Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the * `SyncService` consumes. * * Exposed as its own service so it can be mocked cleanly in unit tests. */ @Injectable() export class SdsClientService { private readonly logger = new Logger(SdsClientService.name); private readonly baseUrl: string; constructor( private readonly http: HttpService, config: ConfigService, ) { this.baseUrl = config.get('SDS_API_BASE')?.replace(/\/$/, '') ?? 'https://mapi.sdspod.com'; } private async request( method: 'post' | 'get', url: string, body?: unknown, params?: Record, ): Promise { const MAX_RETRIES = 3; let lastError: unknown; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const config = { headers: POD_HEADERS, timeout: 30000, ...(params ? { params } : {}), }; const obs = method === 'post' ? this.http.post(url, body, config) : this.http.get(url, config); const { data } = await firstValueFrom(obs); return data as T; } catch (err) { lastError = err; const msg = err instanceof Error ? err.message : String(err); if (attempt < MAX_RETRIES) { this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`); await new Promise((r) => setTimeout(r, 1000 * attempt)); } } } throw lastError; } async fetchCategoryTree(): Promise { const url = `${this.baseUrl}/category/tree/3`; const body = { withActivityArea: true, withPrivate: true, onlyHaveProduct: true, }; const data = await this.request('post', url, body); if (!Array.isArray(data)) { throw new Error(`SDS category tree returned ${typeof data}, expected array`); } if (data.length < MIN_SDS_CATEGORY_NODES) { throw new Error( `SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` + `aborting to avoid destructive sync`, ); } return data as SdsCategoryTreeNode[]; } async fetchProductsPage( categoryId: string | number, page = 1, size = 50, ): Promise { const url = `${this.baseUrl}/products/page`; const data = await this.request('get', url, undefined, { categoryId, page, size }); if (!data || typeof data !== 'object') { throw new Error(`SDS products page returned ${typeof data}, expected object`); } return data as SdsProductsPage; } async fetchProductDetail(goodId: string | number): Promise { const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`; const data = await this.request('get', url); if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error(`SDS product detail returned ${typeof data}, expected object`); } const detail = data as SdsProductDetail; if (String(detail.id) !== String(goodId)) { throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`); } return detail; } }