chore: migrate to pnpm workspaces monorepo with Turborepo

- Restructure directories: apps/api, apps/admin, apps/website
- Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore
- Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website
- Add shared packages: packages/tsconfig, packages/shared-types
- Add pnpm.onlyBuiltDependencies for native builds
- Update docs: README.md, structs.md
- All three projects build successfully
This commit is contained in:
yeuimu
2026-07-11 16:54:05 +08:00
parent 69945b8749
commit 7e04877bb6
155 changed files with 20134 additions and 14393 deletions
+33
View File
@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import type { SyncLog } from '@prisma/client';
export class SyncLogDto {
@ApiProperty()
id!: string;
@ApiProperty()
type!: 'CATEGORIES' | 'PRODUCTS';
@ApiProperty()
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
@ApiProperty({ nullable: true })
message!: string | null;
@ApiProperty()
startedAt!: string;
@ApiProperty({ nullable: true })
finishedAt!: string | null;
static from(log: SyncLog): SyncLogDto {
return {
id: log.id.toString(),
type: log.type,
status: log.status,
message: log.message,
startedAt: log.startedAt.toISOString(),
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
};
}
}
+102
View File
@@ -0,0 +1,102 @@
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;
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;
}
/**
* 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';
}
/**
* Fetches the SDS category tree of type 3 (products category).
* Body matches the legacy inkpod client.
*/
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
const url = `${this.baseUrl}/category/tree/3`;
const body = {
withActivityArea: true,
withPrivate: true,
onlyHaveProduct: true,
};
const { data } = await firstValueFrom(
this.http.post<SdsCategoryTreeNode[]>(url, body, { headers: POD_HEADERS }),
);
return Array.isArray(data) ? data : [];
}
/**
* Fetches one page of products for a given SDS category.
*/
async fetchProductsPage(
categoryId: string | number,
page = 1,
size = 50,
): Promise<SdsProductsPage> {
const url = `${this.baseUrl}/products/page`;
const { data } = await firstValueFrom(
this.http.get<SdsProductsPage>(url, {
headers: POD_HEADERS,
params: { categoryId, page, size },
}),
);
return data ?? {};
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
Controller,
DefaultValuePipe,
Get,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { SyncService } from './sync.service';
import { SyncLogDto } from './dto/sync-log.dto';
@ApiTags('sync')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('sync')
export class SyncController {
constructor(private readonly service: SyncService) {}
@Post('categories')
@ApiOperation({ summary: 'Manually trigger category sync' })
syncCategories() {
return this.service.syncCategories();
}
@Post('products')
@ApiOperation({ summary: 'Manually trigger product sync' })
syncProducts() {
return this.service.syncProducts();
}
@Get('status')
@ApiOperation({ summary: 'Recent sync log entries' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async status(
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
): Promise<SyncLogDto[]> {
const logs = await this.service.getStatus(limit);
return logs.map((l) => SyncLogDto.from(l));
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { HttpModule } from '@nestjs/axios';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
@Module({
imports: [ScheduleModule.forRoot(), HttpModule],
controllers: [SyncController],
providers: [SyncService, SdsClientService],
exports: [SyncService, SdsClientService],
})
export class SyncModule {}
+178
View File
@@ -0,0 +1,178 @@
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
import { PrismaService } from '../prisma/prisma.service';
describe('SyncService', () => {
let service: SyncService;
let sds: jest.Mocked<SdsClientService>;
let prisma: PrismaService;
const createdSdsCategoryIds: string[] = [];
const createdSdsGoodIds: string[] = [];
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [
SyncService,
{ provide: SdsClientService, useValue: sdsMock },
PrismaService,
],
}).compile();
service = moduleRef.get(SyncService);
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdSdsCategoryIds.length) {
await prisma.category.deleteMany({
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
});
}
if (createdSdsGoodIds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSdsGoodIds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('flattenCategoryTree', () => {
it('flattens a nested SDS tree and preserves parent linkage', () => {
const tree = [
{
id: 1,
name: 'Root',
children: [
{ id: 11, name: 'Child A' },
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
],
},
];
const flat = service.flattenCategoryTree(tree);
expect(flat).toHaveLength(4);
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
expect(byId['1'].name).toBe('Root');
expect(byId['1'].parentSdsId).toBeUndefined();
expect(byId['11'].parentSdsId).toBe('1');
expect(byId['12'].parentSdsId).toBe('1');
expect(byId['121'].parentSdsId).toBe('12');
});
it('skips nodes without a usable id', () => {
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
expect(flat).toHaveLength(0);
});
});
describe('syncCategories', () => {
it('inserts + updates rows and links parents', async () => {
const stamp = Date.now();
sds.fetchCategoryTree.mockResolvedValueOnce([
{
id: `r-${stamp}`,
name: `Root ${stamp}`,
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
},
]);
const result = await service.syncCategories();
expect(result.total).toBe(2);
expect(result.inserted).toBe(2);
expect(result.updated).toBe(0);
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
const child = await prisma.category.findUnique({
where: { sdsCategoryId: `c-${stamp}` },
});
const root = await prisma.category.findUnique({
where: { sdsCategoryId: `r-${stamp}` },
});
expect(child?.parentCategoryId).toBe(root?.id);
});
it('marks SyncLog SUCCESS', async () => {
const logs = await prisma.syncLog.findMany({
where: { type: 'CATEGORIES' },
orderBy: { startedAt: 'desc' },
take: 1,
});
expect(logs[0]?.status).toBe('SUCCESS');
});
});
describe('syncProducts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
// Seed a leaf category with sdsCategoryId so the sync has work.
const stamp = Date.now();
const leaf = await prisma.category.create({
data: {
sdsCategoryId: `leaf-${stamp}`,
categoryName: `Leaf ${stamp}`,
},
});
createdSdsCategoryIds.push(`leaf-${stamp}`);
// Default mock returns a single page with 2 products, then
// breaks the loop because content.length < 50.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
],
};
}
// For any other (already-existing) category, return empty
// so the loop terminates immediately.
return { content: [] };
});
const result1 = await service.syncProducts();
expect(result1.inserted).toBeGreaterThanOrEqual(2);
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
// Re-run with updated name -> should be `updated`, not `inserted`.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
],
};
}
return { content: [] };
});
const result2 = await service.syncProducts();
expect(result2.updated).toBeGreaterThanOrEqual(1);
const row = await prisma.originGood.findUnique({
where: { sdsGoodId: `p-${stamp}-1` },
});
expect(row?.goodName).toBe('Product 1 renamed');
});
});
describe('getStatus', () => {
it('returns recent logs ordered by startedAt desc', async () => {
const logs = await service.getStatus(5);
expect(Array.isArray(logs)).toBe(true);
expect(logs.length).toBeGreaterThan(0);
});
});
});
+289
View File
@@ -0,0 +1,289 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
export interface CategorySyncResult {
inserted: number;
updated: number;
total: number;
}
export interface ProductSyncResult {
inserted: number;
updated: number;
total: number;
leafCategories: number;
}
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private running = { categories: false, products: false };
constructor(
private readonly prisma: PrismaService,
private readonly sds: SdsClientService,
) {}
/**
* Hourly full sync — runs `syncCategories` first (since product
* sync depends on knowing which leaf categories exist) and then
* `syncProducts`.
*/
@Cron(CronExpression.EVERY_HOUR)
async hourlyCron(): Promise<void> {
try {
await this.syncCategories();
await this.syncProducts();
} catch (err) {
this.logger.error('Hourly cron sync failed', err as Error);
}
}
async syncCategories(): Promise<CategorySyncResult> {
if (this.running.categories) {
throw new Error('Category sync already in progress');
}
this.running.categories = true;
const log = await this.prisma.syncLog.create({
data: { type: 'CATEGORIES', status: 'RUNNING' },
});
try {
const tree = await this.sds.fetchCategoryTree();
const flat = this.flattenCategoryTree(tree);
this.logger.log(`Fetched ${flat.length} SDS categories`);
let inserted = 0;
let updated = 0;
for (const node of flat) {
const existing = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
if (!existing) {
await this.prisma.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
inserted++;
} else {
await this.prisma.category.update({
where: { id: existing.id },
data: {
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
updated++;
}
}
// Second pass: wire up parents by sdsCategoryId.
for (const node of flat) {
if (!node.parentSdsId) continue;
const child = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
const parent = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.parentSdsId },
});
if (child && parent && child.parentCategoryId !== parent.id) {
await this.prisma.category.update({
where: { id: child.id },
data: { parentCategoryId: parent.id },
});
}
}
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
},
});
return { inserted, updated, total: flat.length };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
message,
},
});
throw err;
} finally {
this.running.categories = false;
}
}
async syncProducts(): Promise<ProductSyncResult> {
if (this.running.products) {
throw new Error('Product sync already in progress');
}
this.running.products = true;
const log = await this.prisma.syncLog.create({
data: { type: 'PRODUCTS', status: 'RUNNING' },
});
try {
// Identify leaf categories — those with no children.
const all = await this.prisma.category.findMany({
select: { id: true, sdsCategoryId: true },
});
const parents = await this.prisma.category.findMany({
where: { parent: { isNot: null } },
select: { parentCategoryId: true },
});
const parentIds = new Set(parents.map((p) => p.parentCategoryId!));
const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId);
let inserted = 0;
let updated = 0;
let total = 0;
for (const leaf of leafRows) {
const sdsCategoryId = leaf.sdsCategoryId!;
let page = 1;
// eslint-disable-next-line no-constant-condition
while (true) {
const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50);
const products = resp.items ?? resp.content ?? [];
if (products.length === 0) break;
for (const product of products) {
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
if (upserted === 'inserted') inserted++;
else updated++;
total++;
}
if (products.length < 50) break;
page++;
if (page > 200) {
// Safety net — at most 10k products per category.
this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`);
break;
}
}
}
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`,
},
});
return { inserted, updated, total, leafCategories: leafRows.length };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
message,
},
});
throw err;
} finally {
this.running.products = false;
}
}
async getStatus(limit = 20) {
return this.prisma.syncLog.findMany({
orderBy: { startedAt: 'desc' },
take: limit,
});
}
/**
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
*/
flattenCategoryTree(
nodes: SdsCategoryTreeNode[],
parentSdsId?: string,
): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> {
const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = [];
const walk = (node: SdsCategoryTreeNode, parent?: string) => {
const sdsId = String(node.id);
if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return;
out.push({
sdsId,
parentSdsId: parent,
name: String(node.name ?? node.title ?? sdsId),
icon: node.icon ? String(node.icon) : undefined,
});
if (Array.isArray(node.children)) {
for (const child of node.children) walk(child, sdsId);
}
};
for (const root of nodes) walk(root, parentSdsId);
return out;
}
private async upsertOriginGood(
product: SdsProduct,
sdsCategoryId: string,
): Promise<'inserted' | 'updated'> {
const sdsGoodId = String(product.id);
const existing = await this.prisma.originGood.findUnique({
where: { sdsGoodId },
});
const goodName = String(product.name ?? product.title ?? sdsGoodId);
const goodImage = product.psd_img_url
? String(product.psd_img_url)
: product.blankDesignUrl
? String(product.blankDesignUrl)
: product.thumbImgUrl
? String(product.thumbImgUrl)
: product.show_img
? String(product.show_img)
: product.img_url
? String(product.img_url)
: product.pic
? String(product.pic)
: product.image
? String(product.image)
: null;
const priceValue = product.currentPrice ?? product.price;
let goodPrice: Prisma.Decimal | null = null;
if (priceValue !== undefined && priceValue !== null) {
const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue;
if (Number.isFinite(n)) {
goodPrice = new Prisma.Decimal(n);
}
}
const data: Prisma.OriginGoodUncheckedUpdateInput = {
sdsCategoryId,
goodName,
goodImage,
goodPrice,
};
if (!existing) {
await this.prisma.originGood.create({
data: {
sdsGoodId,
sdsCategoryId,
goodName,
goodImage,
goodPrice,
},
});
return 'inserted';
}
await this.prisma.originGood.update({
where: { id: existing.id },
data,
});
return 'updated';
}
}