feat(api): add mini program catalog endpoints and product details

This commit is contained in:
yeuimu
2026-08-21 02:11:20 +08:00
parent fcbf8bb494
commit 7d09077f1d
18 changed files with 1347 additions and 179 deletions
+256 -100
View File
@@ -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;
}
}