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
+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';
}
}