import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PublicHomeGoodsQueryDto, PublicQueryGoodDto, PublicTagFilterDto, } 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 }] }, family: { select: { id: true, familyCode: true, familyName: true, sizeChart: true, packageSpecs: true, priceMatrix: true, }, }, }, }, mergedOriginGoods: { orderBy: { createdAt: 'asc' as const }, include: { originGood: { include: { 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(query.tags ?? []); 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: { OR: [ { originGood: { sdsGoodId: goodId, delisted: false } }, // Merged secondary sources also resolve to the same good. { mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } }, }, }, ], }, include: PUBLIC_GOOD_INCLUDE, orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], }); if (!good) { throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); } const dto = this.toPublicGoodDetail(good); dto.category.categoryIcon = await this.resolveCategoryIcon(good.category); return dto; } 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(), }; } /** Group distinct variant images by color so the frontend can switch media per color. * Only color-specific photos are included (main / result / detail images); * design-layer素材图 and the product-level blank garment photo are excluded * because they are not per-color gallery photos. */ private groupImagesByColor( variants: Array, ): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> { const groups = new Map(); for (const variant of variants) { const key = variant.colorId ?? `variant:${variant.sdsVariantId}`; let group = groups.get(key); if (!group) { group = { colorId: variant.colorId, colorName: variant.colorName, colorHex: variant.colorHex, images: [], }; groups.set(key, group); } const design = (variant.designData ?? {}) as { detailImgUrls?: Array<{ imageUrl?: unknown }>; prototypeResultGroups?: Array<{ resultImage?: unknown }>; }; const urls: unknown[] = [ variant.imageUrl, ...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage), ...(design.detailImgUrls ?? []).map((image) => image?.imageUrl), ]; for (const url of urls) { const value = typeof url === 'string' ? url.trim() : ''; if (value && !group.images.includes(value)) { group.images.push(value); } } } return [...groups.values()]; } /** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */ private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise { if (category.categoryIcon) return category.categoryIcon; let cursor = category.parentCategoryId; for (let depth = 0; cursor !== null && depth < 10; depth++) { const parent = await this.prisma.category.findUnique({ where: { id: cursor }, select: { categoryIcon: true, parentCategoryId: true }, }); if (!parent) break; if (parent.categoryIcon) return parent.categoryIcon; cursor = parent.parentCategoryId; } return null; } private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; // Merge primary and secondary origin good variants (dedup identical URLs). const allVariants = [ ...good.originGood.variants, ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), ]; 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, mediaByColor: this.groupImagesByColor(allVariants), options: (detail?.options as Record | null) ?? null, sizeChart: (detail?.sizeChart as Record | null) ?? null, packageSpecs: (detail?.packageSpecs as Record | null) ?? null, variants: allVariants.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, ...this.familyBlock(good), }; } /** * 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true * 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。 */ private familyBlock( good: PublicGoodRow, ): Pick | Record { if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') return {}; const family = good.originGood.family; if (!family || !family.priceMatrix) return {}; const matrix = family.priceMatrix as { sizes: Array<{ key: string; name: string | null }>; colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; crafts: string[]; logistics: string[]; rows: Array<{ price: string }>; }; const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n)); return { family: { familyId: family.id.toString(), familyCode: family.familyCode, familyName: family.familyName, sizes: matrix.sizes, colors: matrix.colors, crafts: matrix.crafts, logistics: matrix.logistics, sizeChart: (family.sizeChart as Record | null) ?? null, packageSpecs: (family.packageSpecs as Record | null) ?? null, priceMatrix: matrix as unknown as Record, minPrice: prices.length ? String(Math.min(...prices)) : null, }, }; } private async buildTagGroupFilters( selectedGroups: PublicTagFilterDto[], ): Promise { const selections = selectedGroups.flatMap((group) => { if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) { throw new BadRequestException( 'tags 每个元素必须包含合法的 tagGroupId 和 tagIds', ); } return group.tagIds.map((tagId) => { if (!/^\d+$/.test(tagId)) { throw new BadRequestException('tagIds 必须全部为数字字符串'); } return { tagGroupId: group.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; } }