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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user