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:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+134 -134
View File
@@ -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;
}