Files
inkreach-official-website/apps/api/src/public/public.service.ts
T
yeuimu ab79325ab0 perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并
- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间
  bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED
  /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块
- PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行,
  修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/
  族最低价聚合各按依赖域缓存
- 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/
  positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶——
  事务提交成功后失效对应域,product-families 经 recompute 天然覆盖
- 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
2026-09-03 03:40:20 +08:00

999 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>;
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
leafOrder: Map<string, { c2: number; c3: number }>;
}
@Injectable()
export class PublicService {
constructor(
private readonly prisma: PrismaService,
private readonly cache: PublicCacheService,
) {}
/**
* 以下读端点统一走 PublicCacheService(性能整改 P0-1TTL 只是内存回收
* 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。
* 依赖域声明:
* - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据)
* - goodsgoods 行/关联展示数据(含 position)
* - matrix:族 price_matrix 物化 JSON(族最低价聚合)
* 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。
*/
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadCategoriesTree(countryId),
);
}
private async loadCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
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<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[]> {
return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries());
}
private async loadCountries(): Promise<PublicCountryDto[]> {
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<PublicTagDto[]> {
return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags());
}
private async loadTags(): Promise<PublicTagDto[]> {
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<PublicTagGroupFilterDto[]> {
return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadTagGroups(countryId),
);
}
private async loadTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
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<PublicPaginatedGoods> {
// 缓存的是「已排序已分组的全量 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<string, PublicGoodListRow[]>();
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<TreeOrderMeta> {
return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta());
}
private async queryTreeOrderMeta(): Promise<TreeOrderMeta> {
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<Map<string, string>> {
return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices());
}
private async queryFamilyMinPrices(): Promise<Map<string, string>> {
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<string, string>();
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<PublicGoodDetailDto> {
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<PublicGoodDetailDto | null> {
return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () =>
this.loadGoodByFamilyId(familyId),
);
}
private async loadGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
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<PublicGoodDto[]> {
return this.cache.wrap(
`home:${query.countryId ?? 'all'}:${query.limit}`,
['goods', 'matrix', 'meta'],
() => this.loadHomeGoods(query),
);
}
private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
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<string, PublicGoodListRow[]>();
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<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
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<PublicGoodRow['originGood']['variants'][number]>,
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
const groups = new Map<string, {
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>();
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<string | null> {
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<PublicGoodRow['originGood']['detail']> = [],
): 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<typeof d> => Boolean(d));
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
// 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重,
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
const seen = new Set<string>(['']);
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<PublicGoodDetailDto, 'family'> | Record<string, never> {
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<string, unknown> | null) ?? null,
packageSpecs: (family.packageSpecs as Record<string, unknown> | null) ?? null,
priceMatrix: matrix as unknown as Record<string, unknown>,
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<string>();
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<PublicGoodRow['originGood']['detail']>[],
field: 'sizeChart' | 'packageSpecs',
): Record<string, unknown> | null {
const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
const rows: Array<Record<string, unknown>> = 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<Record<string, unknown>> } | 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<string, unknown> | 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<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const primaryOptions = primary?.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
if (!primaryOptions && secondaries.length === 0) return null;
const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
for (const listKey of ['sizes', 'colors'] as const) {
const base = Array.isArray(primaryOptions?.[listKey])
? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
: 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<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| 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<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | 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<string, unknown> | 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<Prisma.GoodWhereInput[]> {
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<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;
}
}