418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
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[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
const PUBLIC_GOOD_INCLUDE = {
|
|
country: true,
|
|
category: true,
|
|
tag: { include: { tagGroup: true } },
|
|
position: 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(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: goodsWhere } },
|
|
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
|
|
? await this.prisma.category.findMany({
|
|
where: { id: { in: [...ancestorIds] } },
|
|
orderBy: { id: 'asc' },
|
|
})
|
|
: [];
|
|
const allRows = [...leafCategories, ...ancestorRows].filter(
|
|
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
|
|
);
|
|
allRows.sort((a, b) => Number(a.id - b.id));
|
|
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: { originGood: { delisted: false } } } },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
return rows.map(PublicCountryDto.from);
|
|
}
|
|
|
|
async getTags(): Promise<PublicTagDto[]> {
|
|
const rows = await this.prisma.tag.findMany({
|
|
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
|
orderBy: [
|
|
{ tagGroup: { sortOrder: 'asc' } },
|
|
{ sortOrder: 'asc' },
|
|
{ id: 'asc' },
|
|
],
|
|
include: { tagGroup: true },
|
|
});
|
|
return rows.map(PublicTagDto.from);
|
|
}
|
|
|
|
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: { 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((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) 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)) };
|
|
}
|
|
|
|
const tagFilters = await this.buildTagGroupFilters([
|
|
...new Set(query.tagIds ?? []),
|
|
]);
|
|
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 (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,
|
|
orderBy,
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
}),
|
|
]);
|
|
return {
|
|
items: rows.map((good) => this.toPublicGood(good)),
|
|
total,
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
};
|
|
}
|
|
|
|
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({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
|
}
|
|
return this.toPublicGoodDetail(good);
|
|
}
|
|
|
|
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 {
|
|
goodId: good.originGood.sdsGoodId,
|
|
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(({ 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 }
|
|
: null,
|
|
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(
|
|
selectedTagValues: string[],
|
|
): Promise<Prisma.GoodWhereInput[]> {
|
|
const selections = selectedTagValues.map((value) => {
|
|
const [tagGroupId, tagId] = value.split(':');
|
|
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
|
|
throw new BadRequestException(
|
|
'tagIds 每个元素必须为 tagGroupId:tagId',
|
|
);
|
|
}
|
|
return { tagGroupId, tagId };
|
|
});
|
|
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
|
|
const selected = uniqueTagIds.length
|
|
? await this.prisma.tag.findMany({
|
|
where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
|
|
select: { id: true, tagGroupId: true },
|
|
})
|
|
: [];
|
|
if (selected.length !== uniqueTagIds.length) {
|
|
throw new BadRequestException('包含不存在的标签 ID');
|
|
}
|
|
const actualGroups = new Map(
|
|
selected.map((tag) => [
|
|
tag.id.toString(),
|
|
tag.tagGroupId?.toString() ?? null,
|
|
]),
|
|
);
|
|
for (const selection of selections) {
|
|
if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
|
|
throw new BadRequestException(
|
|
`标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
|
|
);
|
|
}
|
|
}
|
|
const byGroup = new Map<string, bigint[]>();
|
|
for (const selection of selections) {
|
|
const key = selection.tagGroupId;
|
|
const ids = byGroup.get(key) ?? [];
|
|
const id = BigInt(selection.tagId);
|
|
if (!ids.includes(id)) ids.push(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) {
|
|
const children = await this.prisma.category.findMany({
|
|
where: { parentCategoryId: { in: frontier } },
|
|
select: { id: true },
|
|
});
|
|
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) {
|
|
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)!;
|
|
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;
|
|
}
|
|
}
|