feat(deploy): production deployment setup and fixes
- 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
This commit is contained in:
@@ -1,55 +1,55 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
items?: SdsProduct[];
|
||||
content?: SdsProduct[];
|
||||
totalCount?: number;
|
||||
totalElements?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductVariant extends Record<string, unknown> {
|
||||
@@ -131,90 +131,90 @@ export interface SdsProductDetail extends Record<string, unknown> {
|
||||
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[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user