- 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 例(读命中/写后立即可见 端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
999 lines
39 KiB
TypeScript
999 lines
39 KiB
TypeScript
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-1,TTL 只是内存回收
|
||
* 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。
|
||
* 依赖域声明:
|
||
* - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据)
|
||
* - goods:goods 行/关联展示数据(含 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;
|
||
}
|
||
}
|