import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PublicCacheService } from './public-cache.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: { 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 }>; /** * 列表/首页卡片的精简 include:重 JSON(变体 design_data、detail、merged 副源、 * 整份族矩阵)全部不进列表查询——全量商品拉这些字段实测要 ~2s,而列表 DTO * 一个都不用。详情接口仍走 PUBLIC_GOOD_INCLUDE。 */ const PUBLIC_GOOD_LIST_INCLUDE = { country: true, category: true, tag: { include: { tagGroup: true } }, position: true, originGood: { select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true }, }, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>; interface TreeOrderMeta { countryOrder: Map; /** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */ leafOrder: Map; } @Injectable() export class PublicService { constructor( private readonly prisma: PrismaService, private readonly cache: PublicCacheService, ) {} /** * 以下读端点统一走 PublicCacheService(性能整改 P0-1,TTL 只是内存回收 * 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。 * 依赖域声明: * - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据) * - goods:goods 行/关联展示数据(含 position) * - matrix:族 price_matrix 物化 JSON(族最低价聚合) * 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。 */ async getCategoriesTree(countryId?: string): Promise { return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () => this.loadCategoriesTree(countryId), ); } private async loadCategoriesTree(countryId?: string): Promise { const goodsWhere: Prisma.GoodWhereInput = { familyId: { not: null }, 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 { return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries()); } private async loadCountries(): Promise { const rows = await this.prisma.country.findMany({ where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return rows.map(PublicCountryDto.from); } async getTags(): Promise { return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags()); } private async loadTags(): Promise { const rows = await this.prisma.tag.findMany({ where: { goodTags: { some: { good: { familyId: { not: null }, originGood: { delisted: false } } }, }, }, orderBy: [ { tagGroup: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }, ], include: { tagGroup: true }, }); return rows.map(PublicTagDto.from); } async getTagGroups(countryId?: string): Promise { return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () => this.loadTagGroups(countryId), ); } private async loadTagGroups(countryId?: string): Promise { const goodWhere: Prisma.GoodWhereInput = { familyId: { not: null }, 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, })), })); } /** * 缓存键 = 筛选参数(不含 page/pageSize):缓存的是已排序已分组的全量 * items,翻页在缓存命中后内存切片——所有页码共享同一份物化结果。 */ private goodsListCacheKey(query: PublicQueryGoodDto): string { return [ 'goods-list', query.countryId ?? '', query.keyword ?? '', query.categoryId ?? '', query.minPrice ?? '', query.maxPrice ?? '', query.sort ?? 'DEFAULT', JSON.stringify(query.tags ?? []), ].join('|'); } async getGoods(query: PublicQueryGoodDto): Promise { // 缓存的是「已排序已分组的全量 items」;分页切片必须在缓存外按请求执行, // 否则后续页会拿到第一页的切片(缓存值会被多个页码共享) const materialized = await this.cache.wrap( this.goodsListCacheKey(query), ['goods', 'matrix', 'meta'], () => this.loadGoodsMaterialized(query), ); const start = (query.page - 1) * query.pageSize; return { items: materialized.items.slice(start, start + query.pageSize), total: materialized.total, page: query.page, pageSize: query.pageSize, }; } private async loadGoodsMaterialized(query: PublicQueryGoodDto): Promise<{ items: PublicGoodDto[]; total: number; }> { // 无族商品(自定义)不进公开列表:只认族 const where: Prisma.GoodWhereInput = { familyId: { not: null }, 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' }] : [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下) // 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID); // 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配 // 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。 const rows = await this.prisma.good.findMany({ where, include: PUBLIC_GOOD_LIST_INCLUDE, orderBy, }); if (!query.sort || query.sort === 'DEFAULT') { // 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。 // 款顺序存于新树 categories.sort_order(回填自排序表),商品经 // origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享 // 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到 // 所属国家分组末尾。 const meta = await this.loadTreeOrderMeta(); rows.sort((a, b) => this.compareByTreeOrder(meta, a, b)); } const familyMinPrices = await this.loadFamilyMinPrices(); const grouped = new Map(); for (const good of rows) { const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; const bucket = grouped.get(key); if (bucket) bucket.push(good); else grouped.set(key, [good]); } let items = [...grouped.values()].map((goods) => { const rep = this.pickFamilyRepresentative(goods); const dto = this.toPublicGood(rep); // 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价 const familyMin = rep.familyId ? familyMinPrices.get(rep.familyId.toString()) : undefined; if (familyMin !== undefined) dto.price = familyMin; return dto; }); if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') { // 分组后的最终价(族最低价)重排,null 价沉底 const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v)); items = items.sort((a, b) => query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price), ); } return { items, total: items.length }; } /** * 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order * (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。 */ private loadTreeOrderMeta(): Promise { return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta()); } private async queryTreeOrderMeta(): Promise { const [countries, leaves] = await Promise.all([ this.prisma.country.findMany({ select: { id: true, sortOrder: true } }), this.prisma.$queryRaw< Array<{ sds_category_id: string; c2: number; c3: number }> >` SELECT leaf.sds_category_id, COALESCE(mid.sort_order, 2147483647) AS c2, COALESCE(leaf.sort_order, 2147483647) AS c3 FROM categories leaf JOIN categories mid ON mid.category_id = leaf.parent_category_id WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> '' `, ]); return { countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])), leafOrder: new Map( leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]), ), }; } private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number { const MAX = Number.MAX_SAFE_INTEGER; const key = (g: PublicGoodListRow): [number, number, number, number, number] => { const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? ''); return [ meta.countryOrder.get(g.countryId.toString()) ?? MAX, leaf?.c2 ?? MAX, leaf?.c3 ?? MAX, -(g.goodPriority ?? 0), Number(g.id), ]; }; const ka = key(a); const kb = key(b); for (let i = 0; i < ka.length; i++) { if (ka[i] !== kb[i]) return ka[i] - kb[i]; } return 0; } /** * 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include * 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传 * 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。 */ private loadFamilyMinPrices(): Promise> { return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices()); } private async queryFamilyMinPrices(): Promise> { const rows = await this.prisma.$queryRaw< Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }> >` SELECT f.family_id, MIN((r->>'price')::numeric) AS min_price FROM product_families f CROSS JOIN LATERAL jsonb_array_elements(f.price_matrix->'rows') AS r WHERE (r->>'price') ~ '^-?[0-9]+(\.[0-9]+)?$' GROUP BY f.family_id `; const result = new Map(); for (const row of rows) { if (row.min_price !== null) { result.set(row.family_id.toString(), String(Number(row.min_price))); } } return result; } /** * 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。 * 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。 */ async getGood(goodId: string): Promise { const notFound = () => new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); if (!/^\d+$/.test(goodId)) throw notFound(); const detail = await this.getGoodByFamilyId(BigInt(goodId)); if (!detail) throw notFound(); return detail; } /** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */ private async getGoodByFamilyId(familyId: bigint): Promise { return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () => this.loadGoodByFamilyId(familyId), ); } private async loadGoodByFamilyId(familyId: bigint): Promise { const [family, goods] = await Promise.all([ this.prisma.productFamily.findUnique({ where: { id: familyId } }), this.prisma.good.findMany({ where: { familyId, originGood: { delisted: false } }, include: PUBLIC_GOOD_INCLUDE, orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }], }), ]); // 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 走回退路径 if (!family || goods.length === 0) return null; const rep = goods[0]; const members = await this.prisma.originGood.findMany({ where: { familyId, delisted: false }, orderBy: { id: 'asc' }, select: { id: true, detail: true, variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }); const familyVariants = members.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })), ); const familyDetails = members .filter((m) => m.id !== rep.originGoodId) .map((m) => m.detail); const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails); // 尺码表/包装规格以族物化并集为准(款级公共数据),空并集回退主链接合并结果 if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') { dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart; dto.packageSpecs = (family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs; } dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category); return dto; } async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise { return this.cache.wrap( `home:${query.countryId ?? 'all'}:${query.limit}`, ['goods', 'matrix', 'meta'], () => this.loadHomeGoods(query), ); } private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise { const rows = await this.prisma.good.findMany({ where: { positionId: { not: null }, familyId: { not: null }, originGood: { delisted: false }, ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}), }, include: PUBLIC_GOOD_LIST_INCLUDE, orderBy: [ { position: { indexVal: 'asc' } }, { goodPriority: 'desc' }, { id: 'asc' }, ], take: query.limit, }); const familyMinPrices = await this.loadFamilyMinPrices(); // 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致 const grouped = new Map(); for (const good of rows) { const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; const bucket = grouped.get(key); if (bucket) bucket.push(good); else grouped.set(key, [good]); } const items: PublicGoodDto[] = []; for (const goods of grouped.values()) { const rep = this.pickFamilyRepresentative(goods); const dto = this.toPublicGood(rep); const familyMin = rep.familyId ? familyMinPrices.get(rep.familyId.toString()) : undefined; if (familyMin !== undefined) dto.price = familyMin; items.push(dto); } return items.slice(0, query.limit); } /** * 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致 * (goodPriority desc → createdAt desc → id asc), * 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。 */ private pickFamilyRepresentative( goods: T[], ): T { return [...goods].sort( (a, b) => (b.goodPriority ?? 0) - (a.goodPriority ?? 0) || b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), )[0]; } /** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */ private toPublicGood(good: PublicGoodListRow): 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(对外契约);无族(自定义商品)→ sdsGoodId goodId: good.familyId ? good.familyId.toString() : 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, familyVariants: Array<{ originGoodId: bigint; variant: PublicGoodRow['originGood']['variants'][number]; }> = [], /** 族成员 detail(款级公共规格并集来源之一;族视角详情传入,链接视角为空) */ familyDetails: Array = [], ): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; // Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options // the primary does not have. const secondaryDetails = [ ...familyDetails, ...good.mergedOriginGoods.map((m) => m.originGood.detail), ].filter((d): d is NonNullable => Boolean(d)); // 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期); // 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重, // 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。 const seen = new Set(['']); const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => { const key = `${originGoodId}:${variant.sdsVariantId}`; if (seen.has(key)) return null; seen.add(key); return variant; }; good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v)); const unionVariants = [ ...good.originGood.variants, ...familyVariants .map(({ originGoodId, variant }) => dedupe(originGoodId, variant)) .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null), ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants .map((variant) => dedupe(m.originGoodId, variant)) .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null), ), ]; const allVariants = this.dedupeVariants(unionVariants); 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: this.mergeMedia(detail, secondaryDetails), mediaByColor: this.groupImagesByColor(allVariants), options: this.mergeOptions(detail, secondaryDetails), sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'), packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'), 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 * 显式设为 'false' 时关闭(应急回退开关)。数据全部来自 ProductFamily 的物化 JSON。 */ private familyBlock( good: PublicGoodRow, ): Pick | Record { if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') 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 }>; printCounts: string[]; 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, printCounts: matrix.printCounts ?? [], 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, }, }; } /** Norm key for variant/dedupe matching: case-insensitive, trimmed. */ private static normName(value: string | null | undefined): string { return (value ?? '').trim().toLowerCase(); } /** Dedupe variants by color+size (case-insensitive); first (primary) wins. */ private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) { const seen = new Set(); const result: PublicGoodRow['originGood']['variants'] = []; for (const variant of variants) { const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`; if (seen.has(key)) continue; seen.add(key); result.push(variant); } return result; } /** * Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary * and secondary details: primary rows win, rows for sizes the primary * lacks are appended from secondaries in order. */ private mergeRowsByKey( primary: PublicGoodRow['originGood']['detail'], secondaries: NonNullable[], field: 'sizeChart' | 'packageSpecs', ): Record | null { const primaryRows = (primary?.[field] as { rows?: Array> } | null)?.rows; if (!Array.isArray(primaryRows) && secondaries.length === 0) return null; const rows: Array> = Array.isArray(primaryRows) ? [...primaryRows] : []; const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? '')))); for (const sec of secondaries) { const secRows = (sec[field] as { rows?: Array> } | null)?.rows; if (!Array.isArray(secRows)) continue; for (const row of secRows) { const key = PublicService.normName(String(row?.sizeName ?? '')); if (seen.has(key)) continue; seen.add(key); rows.push(row); } } if (rows.length === 0) return null; return { ...(primary?.[field] as Record | null ?? {}), rows }; } /** * Merge options across details: sizes/colors are unioned by name, * primary entries win and secondary-only ones are appended. */ private mergeOptions( primary: PublicGoodRow['originGood']['detail'], secondaries: NonNullable[], ): Record | null { const primaryOptions = primary?.options as | { sizes?: Array>; colors?: Array> } | null; if (!primaryOptions && secondaries.length === 0) return null; const merged: Record = { ...(primaryOptions ?? {}) }; for (const listKey of ['sizes', 'colors'] as const) { const base = Array.isArray(primaryOptions?.[listKey]) ? [...(primaryOptions![listKey] as Array>)] : null; if (!base && secondaries.length === 0) continue; const rows = base ?? []; const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? '')))); for (const sec of secondaries) { const secOptions = sec.options as | { sizes?: Array>; colors?: Array> } | null; const secRows = secOptions?.[listKey]; if (!Array.isArray(secRows)) continue; for (const row of secRows) { const key = PublicService.normName(String(row?.name ?? '')); if (seen.has(key)) continue; seen.add(key); rows.push(row); } } merged[listKey] = rows; } return merged; } /** * Merge the gallery `media` across details: primary images first, * secondary-only image URLs appended (URL-deduped). `primaryImageUrl` * stays the primary's. Image entries keep their original shape * (`{id,url,sortOrder}` objects or plain strings). */ private mergeMedia( primary: PublicGoodRow['originGood']['detail'], secondaries: NonNullable[], ): Record | null { const imageUrl = (img: unknown): string | null => { if (typeof img === 'string') return img; if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') { return (img as { url: string }).url; } return null; }; const primaryMedia = (primary?.media as Record | null) ?? null; const primaryImages = Array.isArray(primaryMedia?.images) ? (primaryMedia!.images as unknown[]) : null; if (!primaryImages && secondaries.length === 0) return null; const images: unknown[] = primaryImages ? [...primaryImages] : []; const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u))); for (const sec of secondaries) { const secMedia = sec.media as { images?: unknown } | null; if (!Array.isArray(secMedia?.images)) continue; for (const img of secMedia!.images as unknown[]) { const url = imageUrl(img); if (!url || seen.has(url)) continue; seen.add(url); images.push(img); } } if (images.length === 0) return null; return { ...(primaryMedia ?? {}), images }; } 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; } }