- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
234 lines
6.8 KiB
TypeScript
234 lines
6.8 KiB
TypeScript
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
|
|
'https://mapi.sdspod.com';
|
|
}
|
|
|
|
private async request<T>(
|
|
method: 'post' | 'get',
|
|
url: string,
|
|
body?: unknown,
|
|
params?: Record<string, unknown>,
|
|
): Promise<T> {
|
|
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<T>(url, body, config)
|
|
: this.http.get<T>(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<SdsCategoryTreeNode[]> {
|
|
const url = `${this.baseUrl}/category/tree/3`;
|
|
const body = {
|
|
withActivityArea: true,
|
|
withPrivate: true,
|
|
onlyHaveProduct: true,
|
|
};
|
|
const data = await this.request<unknown>('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<SdsProductsPage> {
|
|
const url = `${this.baseUrl}/products/page`;
|
|
const data = await this.request<unknown>('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<SdsProductDetail> {
|
|
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
|
|
const data = await this.request<unknown>('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;
|
|
}
|
|
}
|