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
@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Category as PrismaCategory } from '@prisma/client';
export class PublicCategoryNodeDto {
@ApiProperty()
id!: string;
@ApiProperty()
categoryName!: string;
@ApiProperty({ nullable: true })
categoryIcon!: string | null;
@ApiProperty({ nullable: true })
parentCategoryId!: string | null;
@ApiProperty({ type: [PublicCategoryNodeDto] })
children!: PublicCategoryNodeDto[];
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
return {
id: category.id.toString(),
categoryName: category.categoryName,
categoryIcon: category.categoryIcon,
parentCategoryId: category.parentCategoryId
? category.parentCategoryId.toString()
: null,
children,
};
}
}
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Country as PrismaCountry } from '@prisma/client';
export class PublicCountryDto {
@ApiProperty()
id!: string;
@ApiProperty()
countryName!: string;
@ApiProperty({ nullable: true })
countryIcon!: string | null;
static from(country: PrismaCountry): PublicCountryDto {
return {
id: country.id.toString(),
countryName: country.countryName,
countryIcon: country.countryIcon,
};
}
}
@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
export class PublicGoodDto {
@ApiProperty()
id!: string;
@ApiProperty()
goodName!: string;
@ApiProperty()
goodPriority!: number;
@ApiProperty()
country!: { id: string; countryName: string; countryIcon: string | null };
@ApiProperty()
category!: { id: string; categoryName: string; categoryIcon: string | null };
@ApiProperty({ nullable: true })
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null;
@ApiProperty({ type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }>;
@ApiProperty({ nullable: true })
position!: { id: string; indexVal: number } | null;
@ApiProperty({ nullable: true })
image!: string | null;
@ApiProperty({ nullable: true })
price!: string | null;
@ApiProperty()
createdAt!: string;
}
@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
countryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
@IsOptional()
@IsString()
tagIds?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
keyword?: string;
}
@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import type { TagGroup as PrismaTagGroup } from '@prisma/client';
export class PublicTagGroupDto {
@ApiProperty()
id!: string;
@ApiProperty()
groupName!: string;
@ApiProperty({ nullable: true })
groupIcon!: string | null;
@ApiProperty({ nullable: true })
groupColor!: string | null;
@ApiProperty()
sortOrder!: number;
static from(g: PrismaTagGroup): PublicTagGroupDto {
return {
id: g.id.toString(),
groupName: g.groupName,
groupIcon: g.groupIcon,
groupColor: g.groupColor,
sortOrder: g.sortOrder,
};
}
}
+39
View File
@@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Tag as PrismaTag } from '@prisma/client';
export class PublicTagDto {
@ApiProperty()
id!: string;
@ApiProperty()
tagName!: string;
@ApiProperty({ nullable: true })
tagColor!: string | null;
@ApiProperty({ nullable: true })
tagFontColor!: string | null;
@ApiProperty()
sortOrder!: number;
@ApiProperty({ nullable: true })
group!: { id: string; groupName: string; sortOrder: number } | null;
static from(tag: PrismaTag & { tagGroup?: { id: bigint; groupName: string; sortOrder: number } | null }): PublicTagDto {
return {
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
sortOrder: tag.sortOrder,
group: tag.tagGroup
? {
id: tag.tagGroup.id.toString(),
groupName: tag.tagGroup.groupName,
sortOrder: tag.tagGroup.sortOrder,
}
: null,
};
}
}
+54
View File
@@ -0,0 +1,54 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PublicService } from './public.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
@ApiTags('public')
@Controller('public')
export class PublicController {
constructor(private readonly service: PublicService) {}
@Get('categories')
@ApiOperation({ summary: 'Public list of categories that have goods' })
getCategories() {
return this.service.getCategoriesTree();
}
@Get('countries')
@ApiOperation({ summary: 'Public list of countries that have goods' })
getCountries() {
return this.service.getCountries();
}
@Get('tags')
@ApiOperation({ summary: 'Public list of tags that have goods' })
getTags(): Promise<PublicTagDto[]> {
return this.service.getTags();
}
@Get('tag-groups')
@ApiOperation({ summary: 'Public list of tag groups that have goods' })
getTagGroups(): Promise<PublicTagGroupDto[]> {
return this.service.getTagGroups();
}
@Get('goods')
@ApiOperation({ summary: 'Public paginated goods with filters' })
getGoods(@Query() query: PublicQueryGoodDto) {
return this.service.getGoods(query);
}
@Get('goods/:id')
@ApiOperation({ summary: 'Public good detail' })
getGood(@Param('id', ParseIntPipe) id: string) {
return this.service.getGood(BigInt(id));
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PublicController } from './public.controller';
import { PublicService } from './public.service';
@Module({
controllers: [PublicController],
providers: [PublicService],
})
export class PublicModule {}
+234
View File
@@ -0,0 +1,234 @@
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
describe('PublicService', () => {
let service: PublicService;
let prisma: PrismaService;
const stamp = Date.now();
let countryId: bigint;
let categoryId: bigint;
let childCategoryId: bigint;
let otherCategoryId: bigint;
let tagId: bigint;
let originGoodId: bigint;
let goodIds: bigint[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PublicService, PrismaService],
}).compile();
service = moduleRef.get(PublicService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `Pub Country ${stamp}` },
});
countryId = country.id;
const cat = await prisma.category.create({
data: { categoryName: `Pub Cat ${stamp}` },
});
categoryId = cat.id;
const child = await prisma.category.create({
data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id },
});
childCategoryId = child.id;
const otherCat = await prisma.category.create({
data: { categoryName: `Pub Other ${stamp}` },
});
otherCategoryId = otherCat.id;
const tag = await prisma.tag.create({
data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' },
});
tagId = tag.id;
const og = await prisma.originGood.create({
data: {
sdsGoodId: `pub-sds-${stamp}`,
goodName: `Origin ${stamp}`,
goodImage: 'http://img',
},
});
originGoodId = og.id;
// Seed 3 goods:
// high priority + position.indexVal=1
// mid priority + position.indexVal=5
// no priority + no position (falls back to createdAt)
const pos1 = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
const pos2 = await prisma.position.create({
data: { indexVal: 5, countryId, categoryId },
});
const g1 = await prisma.good.create({
data: {
goodName: `Pub High ${stamp}`,
originGoodId,
countryId,
categoryId,
goodPriority: 10,
positionId: pos1.id,
},
});
const g2 = await prisma.good.create({
data: {
goodName: `Pub Mid ${stamp}`,
originGoodId,
countryId,
categoryId,
goodPriority: 5,
positionId: pos2.id,
},
});
const g3 = await prisma.good.create({
data: {
goodName: `Pub NoPos ${stamp}`,
originGoodId,
countryId,
categoryId,
tagId,
goodPriority: 1,
},
});
goodIds = [g1.id, g2.id, g3.id];
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
// returns more than one category.
await prisma.good.create({
data: {
goodName: `Pub Other ${stamp}`,
originGoodId,
countryId,
categoryId: otherCategoryId,
goodPriority: 1,
},
});
// And seed a good in the *child* category, to verify categoryId
// recursion.
await prisma.good.create({
data: {
goodName: `Pub ChildGood ${stamp}`,
originGoodId,
countryId,
categoryId: childCategoryId,
goodPriority: 0,
},
});
});
afterAll(async () => {
if (goodIds.length) {
await prisma.good.deleteMany({ where: { id: { in: goodIds } } });
}
await prisma.good.deleteMany({
where: { goodName: { contains: `Pub ` } },
});
await prisma.position.deleteMany({
where: { countryId },
});
await prisma.tag.delete({ where: { id: tagId } });
await prisma.originGood.delete({ where: { id: originGoodId } });
// Delete children before parent (FK self-relation is RESTRICT).
await prisma.category.delete({ where: { id: childCategoryId } });
await prisma.category.delete({ where: { id: otherCategoryId } });
await prisma.category.delete({ where: { id: categoryId } });
await prisma.country.delete({ where: { id: countryId } });
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getCategoriesTree returns only categories that have goods', async () => {
const tree = await service.getCategoriesTree();
const allIds = new Set<string>();
const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => {
for (const n of list) {
allIds.add(n.id);
walk(n.children as any);
}
};
walk(tree as any);
// We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`.
expect(allIds.has(categoryId.toString())).toBe(true);
expect(allIds.has(childCategoryId.toString())).toBe(true);
expect(allIds.has(otherCategoryId.toString())).toBe(true);
});
it('getCountries returns only countries that have goods', async () => {
const countries = await service.getCountries();
expect(countries.find((c) => c.id === countryId.toString())).toBeDefined();
});
it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => {
const filtered = await service.getGoods({
page: 1,
pageSize: 50,
countryId: Number(countryId),
categoryId: Number(categoryId), // includes child
keyword: `Pub `,
});
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
});
it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => {
const result = await service.getGoods({
page: 1,
pageSize: 50,
countryId: Number(countryId),
keyword: `Pub `,
});
const priorities = result.items.map((g) => g.goodPriority);
// First verify primary descending priority.
const sorted = [...priorities].sort((a, b) => b - a);
expect(priorities).toEqual(sorted);
});
it('getGood returns detail and 404 for unknown id', async () => {
const first = await service.getGoods({
page: 1,
pageSize: 1,
countryId: Number(countryId),
keyword: `Pub `,
});
expect(first.items.length).toBe(1);
const detail = await service.getGood(BigInt(first.items[0].id));
expect(detail.id).toBe(first.items[0].id);
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('getTags returns tags with their group info, sorted by group then order', async () => {
const tags = await service.getTags();
expect(tags.length).toBeGreaterThan(0);
// Each tag in our seed (包邮/不包邮/...) should have a group
const grouped = tags.find((t) => t.tagName === '包邮');
if (grouped) {
expect(grouped.group).not.toBeNull();
expect(grouped.group!.groupName).toBe('物流渠道');
}
});
it('getTagGroups returns only groups that have goods', async () => {
const groups = await service.getTagGroups();
expect(groups.length).toBeGreaterThan(0);
const names = groups.map((g) => g.groupName);
expect(names).toContain('物流渠道');
expect(names).toContain('印刷位置');
expect(names).toContain('印刷工艺');
// Sorted by sortOrder
const sortOrders = groups.map((g) => g.sortOrder);
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
});
});
+253
View File
@@ -0,0 +1,253 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
import { PublicGoodDto } from './dto/public-good.dto';
export interface PublicPaginatedGoods {
items: PublicGoodDto[];
total: number;
page: number;
pageSize: number;
}
const PUBLIC_GOOD_INCLUDE = {
country: true,
category: true,
tag: { include: { tagGroup: true } },
position: true,
originGood: true,
goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
const leafCategories = await this.prisma.category.findMany({
where: { goods: { some: {} } },
orderBy: { id: 'asc' },
});
const ancestorIds = new Set<bigint>();
for (const leaf of leafCategories) {
let cursor: bigint | null = leaf.parentCategoryId;
while (cursor !== null && !ancestorIds.has(cursor)) {
ancestorIds.add(cursor);
const parent = await this.prisma.category.findUnique({
where: { id: cursor },
select: { id: true, parentCategoryId: true },
});
if (!parent) break;
cursor = parent.parentCategoryId;
}
}
const ancestorRows = ancestorIds.size > 0
? await this.prisma.category.findMany({
where: { id: { in: [...ancestorIds] } },
orderBy: { id: 'asc' },
})
: [];
const allRows = [...leafCategories, ...ancestorRows].filter(
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
);
allRows.sort((a, b) => Number(a.id - b.id));
return this.buildTree(allRows);
}
async getCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: {} } },
orderBy: { id: 'asc' },
});
return rows.map(PublicCountryDto.from);
}
async getTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: { goodTags: { some: {} } },
orderBy: [
{ tagGroup: { sortOrder: 'asc' } },
{ sortOrder: 'asc' },
{ id: 'asc' },
],
include: { tagGroup: true },
});
return rows.map(PublicTagDto.from);
}
async getTagGroups(): Promise<PublicTagGroupDto[]> {
const rows = await this.prisma.tagGroup.findMany({
where: { tags: { some: { goodTags: { some: {} } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.map(PublicTagGroupDto.from);
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = {};
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
if (query.tagIds) {
const ids = query.tagIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => BigInt(s));
if (ids.length > 0) {
// AND logic: 商品必须同时具备所有选中的 tag
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
}
}
if (query.keyword) {
where.goodName = { contains: query.keyword, mode: 'insensitive' };
}
if (query.categoryId !== undefined) {
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
where.categoryId = { in: ids };
}
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: PUBLIC_GOOD_INCLUDE,
// Server-side primary sort; PublicGoodDto retains original indexes
// for stable pagination but the final ORDER BY is mirrored below.
orderBy: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return {
items: rows.map((g) => this.toPublicGood(g)),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async getGood(id: bigint): Promise<PublicGoodDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: PUBLIC_GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return this.toPublicGood(good);
}
private toPublicGood(good: {
id: bigint;
goodName: string;
goodImage: string | null;
goodPriority: number;
country: { id: bigint; countryName: string; countryIcon: string | null };
category: { id: bigint; categoryName: string; categoryIcon: string | null };
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
position: { id: bigint; indexVal: number } | null;
originGood: {
goodImage: string | null;
goodPrice: { toString(): string } | null;
} | null;
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
createdAt: Date;
}): PublicGoodDto {
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
g
? {
id: g.id.toString(),
groupName: g.groupName,
sortOrder: g.sortOrder,
}
: null;
return {
id: good.id.toString(),
goodName: good.goodName,
goodPriority: good.goodPriority,
country: {
id: good.country.id.toString(),
countryName: good.country.countryName,
countryIcon: good.country.countryIcon,
},
category: {
id: good.category.id.toString(),
categoryName: good.category.categoryName,
categoryIcon: good.category.categoryIcon,
},
tag: good.tag
? {
id: good.tag.id.toString(),
tagName: good.tag.tagName,
tagColor: good.tag.tagColor,
tagFontColor: good.tag.tagFontColor,
group: formatGroup(good.tag.tagGroup),
}
: null,
tags: good.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
group: formatGroup(gt.tag.tagGroup),
})),
position: good.position
? {
id: good.position.id.toString(),
indexVal: good.position.indexVal,
}
: null,
image: good.goodImage ?? good.originGood?.goodImage ?? null,
price:
good.originGood?.goodPrice === null ||
good.originGood?.goodPrice === undefined
? null
: good.originGood.goodPrice.toString(),
createdAt: good.createdAt.toISOString(),
};
}
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length > 0) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (children.length === 0) break;
const childIds = children.map((c) => c.id);
ids.push(...childIds);
frontier = childIds;
}
return ids;
}
private buildTree(
rows: PrismaCategory[],
): PublicCategoryNodeDto[] {
const byId = new Map<bigint, PublicCategoryNodeDto>();
for (const row of rows) {
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
}
const roots: PublicCategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
if (row.parentCategoryId === null) {
roots.push(node);
} else {
const parent = byId.get(row.parentCategoryId);
if (parent) parent.children.push(node);
else roots.push(node);
}
}
return roots;
}
}