feat(api): add mini program catalog endpoints and product details
This commit is contained in:
@@ -17,6 +17,9 @@ export class PublicCategoryNodeDto {
|
||||
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
||||
children!: PublicCategoryNodeDto[];
|
||||
|
||||
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
|
||||
productCount!: number;
|
||||
|
||||
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
@@ -26,6 +29,7 @@ export class PublicCategoryNodeDto {
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
productCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PublicGoodDto } from './public-good.dto';
|
||||
|
||||
export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
productCode!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
englishName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
productionCycleHours!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
minWeightG!: string | null;
|
||||
|
||||
@ApiProperty({ type: Object })
|
||||
details!: Record<string, string | null>;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
media!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
options!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
sizeChart!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
packageSpecs!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
variants!: Array<{
|
||||
id: string;
|
||||
sku: string;
|
||||
sizeId: string | null;
|
||||
sizeName: string | null;
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
imageUrl: string | null;
|
||||
price: string | null;
|
||||
originalPrice: string | null;
|
||||
weightG: string | null;
|
||||
boxLengthCm: string | null;
|
||||
boxWidthCm: string | null;
|
||||
boxHeightCm: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
detailSyncedAt!: string | null;
|
||||
}
|
||||
|
||||
export class PublicTagGroupFilterDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{
|
||||
id: string;
|
||||
tagName: string;
|
||||
tagColor: string | null;
|
||||
tagFontColor: string | null;
|
||||
sortOrder: number;
|
||||
productCount: number;
|
||||
}>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PublicGoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
goodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
@@ -33,4 +33,4 @@ export class PublicGoodDto {
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumberString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
const stringList = ({ value }: { value: unknown }): string[] | undefined => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values
|
||||
.flatMap((item) => String(item).split(','))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export class PublicQueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@@ -16,7 +28,7 @@ export class PublicQueryGoodDto {
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@ApiProperty({ required: false, default: 20, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@@ -24,25 +36,78 @@ export class PublicQueryGoodDto {
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
@IsNumberString()
|
||||
countryId?: string;
|
||||
|
||||
@ApiProperty({ required: false, type: String })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiProperty({ required: false, type: [String], description: '标签 ID;支持重复参数或逗号分隔' })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsNumberString({}, { each: true })
|
||||
tagIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], description: '印刷工艺标签 ID' })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsNumberString({}, { each: true })
|
||||
craftIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], description: '材质标签 ID' })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsNumberString({}, { each: true })
|
||||
materialIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, enum: ['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], isArray: true })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsIn(['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], { each: true })
|
||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>;
|
||||
|
||||
@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;
|
||||
minPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] })
|
||||
@IsOptional()
|
||||
@IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'])
|
||||
sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST';
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export class PublicCountryQueryDto {
|
||||
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
countryId?: string;
|
||||
}
|
||||
|
||||
export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto {
|
||||
@ApiProperty({ required: false, default: 10, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit: number = 10;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
@ApiTags('public')
|
||||
@Controller('public')
|
||||
@@ -17,9 +21,9 @@ export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'Public list of categories that have goods' })
|
||||
getCategories() {
|
||||
return this.service.getCategoriesTree();
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@@ -35,20 +39,30 @@ export class PublicController {
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: 'Public list of tag groups that have goods' })
|
||||
getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
return this.service.getTagGroups();
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: 'Public paginated goods with filters' })
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
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));
|
||||
@Get('goods/:goodId')
|
||||
@ApiOperation({ summary: '获取商品完整详情' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
||||
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||
return this.service.getGood(goodId);
|
||||
}
|
||||
|
||||
@Get('home-goods')
|
||||
@ApiOperation({ summary: '获取首页商品;可按国家返回' })
|
||||
@ApiOkResponse({ type: [PublicGoodDto] })
|
||||
getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
return this.service.getHomeGoods(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ describe('PublicService', () => {
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
@@ -98,6 +100,50 @@ describe('PublicService', () => {
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
const craftGroup = await prisma.tagGroup.create({
|
||||
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
|
||||
});
|
||||
const materialGroup = await prisma.tagGroup.create({
|
||||
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
|
||||
});
|
||||
filterGroupIds = [craftGroup.id, materialGroup.id];
|
||||
const craftA = await prisma.tag.create({
|
||||
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
|
||||
});
|
||||
const craftB = await prisma.tag.create({
|
||||
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
|
||||
});
|
||||
const cotton = await prisma.tag.create({
|
||||
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
|
||||
});
|
||||
filterTagIds = [craftA.id, craftB.id, cotton.id];
|
||||
await prisma.goodTag.createMany({
|
||||
data: [
|
||||
{ goodId: g1.id, tagId: craftA.id },
|
||||
{ goodId: g1.id, tagId: cotton.id },
|
||||
{ goodId: g2.id, tagId: craftB.id },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.originGoodDetail.create({
|
||||
data: {
|
||||
originGoodId,
|
||||
productCode: 'OZ10827003',
|
||||
productionProcess: '白墨烫画',
|
||||
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
|
||||
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
|
||||
},
|
||||
});
|
||||
await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId,
|
||||
sdsVariantId: `pub-variant-${stamp}`,
|
||||
sku: `OZ${stamp}`,
|
||||
sizeName: 'S',
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
@@ -134,6 +180,8 @@ describe('PublicService', () => {
|
||||
where: { countryId },
|
||||
});
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
@@ -172,8 +220,8 @@ describe('PublicService', () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId), // includes child
|
||||
countryId: countryId.toString(),
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
@@ -184,7 +232,7 @@ describe('PublicService', () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
@@ -193,31 +241,59 @@ describe('PublicService', () => {
|
||||
expect(priorities).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('uses OR within one tag group and AND across tag groups', async () => {
|
||||
const sameGroup = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||
});
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||
);
|
||||
|
||||
const acrossGroups = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
tagIds: filterTagIds.map(String),
|
||||
});
|
||||
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
|
||||
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
|
||||
});
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].id).not.toBe(goodIds[0].toString());
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(goodIds[0]);
|
||||
expect(detail.id).toBe(first.items[0].id);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
|
||||
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, 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 {
|
||||
PublicHomeGoodsQueryDto,
|
||||
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';
|
||||
import {
|
||||
PublicGoodDetailDto,
|
||||
PublicTagGroupFilterDto,
|
||||
} from './dto/public-good-detail.dto';
|
||||
|
||||
export interface PublicPaginatedGoods {
|
||||
items: PublicGoodDto[];
|
||||
@@ -20,17 +27,28 @@ const PUBLIC_GOOD_INCLUDE = {
|
||||
category: true,
|
||||
tag: { include: { tagGroup: true } },
|
||||
position: true,
|
||||
originGood: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
||||
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
|
||||
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||
const goodsWhere: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
const leafCategories = await this.prisma.category.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
where: { goods: { some: goodsWhere } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const ancestorIds = new Set<bigint>();
|
||||
@@ -46,22 +64,30 @@ export class PublicService {
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
}
|
||||
const ancestorRows = ancestorIds.size > 0
|
||||
const ancestorRows = ancestorIds.size
|
||||
? 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,
|
||||
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
|
||||
);
|
||||
allRows.sort((a, b) => Number(a.id - b.id));
|
||||
return this.buildTree(allRows);
|
||||
const directCounts = await this.prisma.good.groupBy({
|
||||
by: ['categoryId'],
|
||||
where: goodsWhere,
|
||||
_count: { _all: true },
|
||||
});
|
||||
return this.buildTree(
|
||||
allRows,
|
||||
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
|
||||
);
|
||||
}
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
const rows = await this.prisma.country.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
where: { goods: { some: { originGood: { delisted: false } } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicCountryDto.from);
|
||||
@@ -69,7 +95,7 @@ export class PublicService {
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: {} } },
|
||||
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
||||
orderBy: [
|
||||
{ tagGroup: { sortOrder: 'asc' } },
|
||||
{ sortOrder: 'asc' },
|
||||
@@ -80,99 +106,139 @@ export class PublicService {
|
||||
return rows.map(PublicTagDto.from);
|
||||
}
|
||||
|
||||
async getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||
const goodWhere: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
const rows = await this.prisma.tagGroup.findMany({
|
||||
where: { tags: { some: { goodTags: { some: {} } } } },
|
||||
where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: {
|
||||
tags: {
|
||||
where: { goodTags: { some: { good: goodWhere } } },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { goodTags: { where: { good: goodWhere } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map(PublicTagGroupDto.from);
|
||||
return rows.map((group) => ({
|
||||
...PublicTagGroupDto.from(group),
|
||||
tags: group.tags.map((tag) => ({
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
productCount: tag._count.goodTags,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
};
|
||||
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 } } }));
|
||||
}
|
||||
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
||||
if (query.countryId) where.countryId = BigInt(query.countryId);
|
||||
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
if (query.categoryId) {
|
||||
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||
}
|
||||
if (query.keyword) {
|
||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
|
||||
const tagIds = [
|
||||
...new Set([
|
||||
...(query.tagIds ?? []),
|
||||
...(query.craftIds ?? []),
|
||||
...(query.materialIds ?? []),
|
||||
]),
|
||||
];
|
||||
const tagFilters = await this.buildTagGroupFilters(tagIds, query.freeShipping);
|
||||
if (tagFilters.length) where.AND = tagFilters;
|
||||
|
||||
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
||||
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
|
||||
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
|
||||
throw new BadRequestException('minPrice 不能大于 maxPrice');
|
||||
}
|
||||
if (query.categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
if (minPrice !== null || maxPrice !== null) {
|
||||
where.originGood = {
|
||||
delisted: false,
|
||||
goodPrice: {
|
||||
...(minPrice !== null ? { gte: minPrice } : {}),
|
||||
...(maxPrice !== null ? { lte: maxPrice } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
|
||||
query.sort === 'PRICE_ASC'
|
||||
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
|
||||
: query.sort === 'PRICE_DESC'
|
||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||
: query.sort === 'NEWEST'
|
||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||
: [
|
||||
{ goodPriority: 'desc' },
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
|
||||
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' },
|
||||
],
|
||||
orderBy,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => this.toPublicGood(g)),
|
||||
items: rows.map((good) => this.toPublicGood(good)),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||
const good = await this.prisma.good.findFirst({
|
||||
where: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return this.toPublicGood(good);
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
}
|
||||
return this.toPublicGoodDetail(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: {
|
||||
sdsGoodId: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: { toString(): string } | 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,
|
||||
}
|
||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where: {
|
||||
positionId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ goodPriority: 'desc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
take: query.limit,
|
||||
});
|
||||
return rows.map((good) => this.toPublicGood(good));
|
||||
}
|
||||
|
||||
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||
group
|
||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||
: null;
|
||||
return {
|
||||
id: good.originGood.sdsGoodId,
|
||||
goodId: good.originGood.sdsGoodId,
|
||||
goodName: good.goodName,
|
||||
goodPriority: good.goodPriority,
|
||||
country: {
|
||||
@@ -194,63 +260,153 @@ export class PublicService {
|
||||
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),
|
||||
tags: good.goodTags.map(({ tag }) => ({
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
group: formatGroup(tag.tagGroup),
|
||||
})),
|
||||
position: good.position
|
||||
? {
|
||||
id: good.position.id.toString(),
|
||||
indexVal: good.position.indexVal,
|
||||
}
|
||||
? { 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(),
|
||||
image: good.goodImage ?? good.originGood.goodImage,
|
||||
price: good.originGood.goodPrice?.toString() ?? null,
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||
const base = this.toPublicGood(good);
|
||||
const detail = good.originGood.detail;
|
||||
return {
|
||||
...base,
|
||||
productCode: detail?.productCode ?? null,
|
||||
englishName: detail?.englishName ?? null,
|
||||
productionCycleHours: detail?.productionCycleHours ?? null,
|
||||
minWeightG: detail?.minWeightG?.toString() ?? null,
|
||||
details: {
|
||||
reminder: detail?.reminder ?? null,
|
||||
productionProcess: detail?.productionProcess ?? null,
|
||||
materialDescription: detail?.materialDescription ?? null,
|
||||
productPerformance: detail?.productPerformance ?? null,
|
||||
applicableScenarios: detail?.applicableScenarios ?? null,
|
||||
washingInstructions: detail?.washingInstructions ?? null,
|
||||
specialDescription: detail?.specialDescription ?? null,
|
||||
designExplanation: detail?.designExplanation ?? null,
|
||||
designArea: detail?.designArea ?? null,
|
||||
pictureRequest: detail?.pictureRequest ?? null,
|
||||
},
|
||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||
variants: good.originGood.variants.map((variant) => ({
|
||||
id: variant.sdsVariantId,
|
||||
sku: variant.sku,
|
||||
sizeId: variant.sizeId,
|
||||
sizeName: variant.sizeName,
|
||||
colorId: variant.colorId,
|
||||
colorName: variant.colorName,
|
||||
colorHex: variant.colorHex,
|
||||
imageUrl: variant.imageUrl,
|
||||
price: variant.price?.toString() ?? null,
|
||||
originalPrice: variant.originalPrice?.toString() ?? null,
|
||||
weightG: variant.weightG?.toString() ?? null,
|
||||
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
|
||||
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
|
||||
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
|
||||
enabled: variant.enabled,
|
||||
sortOrder: variant.sortOrder,
|
||||
})),
|
||||
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTagGroupFilters(
|
||||
selectedTagIds: string[],
|
||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>,
|
||||
): Promise<Prisma.GoodWhereInput[]> {
|
||||
const selected = selectedTagIds.length
|
||||
? await this.prisma.tag.findMany({
|
||||
where: { id: { in: selectedTagIds.map((id) => BigInt(id)) } },
|
||||
select: { id: true, tagGroupId: true },
|
||||
})
|
||||
: [];
|
||||
if (selected.length !== selectedTagIds.length) {
|
||||
throw new BadRequestException('包含不存在的标签 ID');
|
||||
}
|
||||
if (freeShipping?.length) {
|
||||
const names = freeShipping.map((value) =>
|
||||
value === 'FREE_SHIPPING' ? '包邮' : '不包邮',
|
||||
);
|
||||
const shippingTags = await this.prisma.tag.findMany({
|
||||
where: { tagName: { in: names }, tagGroup: { groupName: '物流渠道' } },
|
||||
select: { id: true, tagGroupId: true },
|
||||
});
|
||||
if (shippingTags.length !== new Set(names).size) {
|
||||
throw new BadRequestException('物流渠道标签配置不完整');
|
||||
}
|
||||
selected.push(...shippingTags);
|
||||
}
|
||||
const byGroup = new Map<string, bigint[]>();
|
||||
for (const tag of selected) {
|
||||
const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`;
|
||||
const ids = byGroup.get(key) ?? [];
|
||||
if (!ids.some((id) => id === tag.id)) ids.push(tag.id);
|
||||
byGroup.set(key, ids);
|
||||
}
|
||||
return [...byGroup.values()].map((ids) => ({
|
||||
goodTags: { some: { tagId: { in: ids } } },
|
||||
}));
|
||||
}
|
||||
|
||||
private parsePrice(value: string | undefined, field: string): number | null {
|
||||
if (value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
while (frontier.length) {
|
||||
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;
|
||||
if (!children.length) break;
|
||||
frontier = children.map((child) => child.id);
|
||||
ids.push(...frontier);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
directCounts: Map<bigint, number>,
|
||||
): PublicCategoryNodeDto[] {
|
||||
const byId = new Map<bigint, PublicCategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
|
||||
const node = PublicCategoryNodeDto.from(row, []);
|
||||
node.productCount = directCounts.get(row.id) ?? 0;
|
||||
byId.set(row.id, node);
|
||||
}
|
||||
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);
|
||||
}
|
||||
const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
const total = (node: PublicCategoryNodeDto): number => {
|
||||
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
|
||||
return node.productCount;
|
||||
};
|
||||
roots.forEach(total);
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user