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 { 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(); 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 { const rows = await this.prisma.country.findMany({ where: { goods: { some: { originGood: { delisted: false } } } }, orderBy: { id: 'asc' }, }); return rows.map(PublicCountryDto.from); } async getTags(): Promise { 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 { 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 { 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 { 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 { 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 | null) ?? null, options: (detail?.options as Record | null) ?? null, sizeChart: (detail?.sizeChart as Record | null) ?? null, packageSpecs: (detail?.packageSpecs as Record | 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 { 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(); 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 { 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, ): PublicCategoryNodeDto[] { const byId = new Map(); 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; } }