feat(product-family): replace legacy good merge with family mechanism in config view and public detail

This commit is contained in:
yeuimu
2026-08-28 14:14:58 +08:00
parent 14c53504f0
commit 4eadb67c36
11 changed files with 2894 additions and 2710 deletions
+46 -13
View File
@@ -92,26 +92,59 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
await prisma.$disconnect();
});
it('开关关闭:响应完全不含 family 键(与现状形状一致)', async () => {
it('默认(未设开关):输出族块', async () => {
delete process.env.PUBLIC_DETAIL_FROM_FAMILY
const detail = await service.getGood(sdsGoodId);
expect(detail.goodId).toBe(sdsGoodId);
expect(detail.family).toBeTruthy();
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
expect(detail.family!.minPrice).toBe('25');
// 旧字段保留(向后兼容)
expect(detail.variants.length).toBe(1);
});
it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
const detail = await service.getGood(sdsGoodId);
expect(detail.goodId).toBe(sdsGoodId);
expect('family' in detail).toBe(false);
});
it('开关开启:输出族块(物化矩阵 + 并集 + 起价)', async () => {
it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
const detail = await service.getGood(sdsGoodId);
expect(detail.family).not.toBeNull();
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
expect(detail.family!.familyId).toBe(familyId.toString());
expect(detail.family!.crafts).toEqual(['单面印花']);
expect(detail.family!.logistics).toEqual(['包邮']);
expect(detail.family!.priceMatrix).toBeTruthy();
expect(detail.family!.minPrice).toBe('25');
// 旧字段保留(向后兼容)
expect(detail.variants.length).toBe(1);
expect(detail.sizeChart).toBeDefined();
// 再加一个同族成员(不同仓库段)
const og2 = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-m-${stamp}`,
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花-二仓`,
craftLabel: '单面印花',
logisticsLabel: '包邮',
familyId,
},
});
createdOriginGoodIds.push(og2.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: og2.id,
sdsVariantId: 'pf-m-v1',
sku: `PF-${stamp}-M`,
sizeId: 'size_M',
sizeName: 'M',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(26),
},
});
// 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接)
const detail = await service.getGood(`pubfam-m-${stamp}`);
expect(detail.goodId).toBe(sdsGoodId);
const skus = detail.variants.map((v) => v.sku);
expect(skus).toContain(`PF-${stamp}-S`);
expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集
// 清理:把成员移出族避免影响其他用例
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } });
await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } });
});
it('无族商品:开关开启也不含 family 键', async () => {
+50 -8
View File
@@ -221,7 +221,9 @@ export class PublicService {
where: {
OR: [
{ originGood: { sdsGoodId: goodId, delisted: false } },
// Merged secondary sources also resolve to the same good.
// 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义)
{ family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } },
// 历史副源关联(good_origin_goods)只读保留,仍可命中
{
mergedOriginGoods: {
some: { originGood: { sdsGoodId: goodId, delisted: false } },
@@ -235,7 +237,25 @@ export class PublicService {
if (!good) {
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
}
const dto = this.toPublicGoodDetail(good);
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
let familyVariants: Array<{
originGoodId: bigint;
variant: PublicGoodRow['originGood']['variants'][number];
}> = [];
const familyId = good.originGood.family?.id;
if (familyId) {
const members = await this.prisma.originGood.findMany({
where: { familyId, delisted: false },
select: {
id: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
familyVariants = members
.filter((m) => m.id !== good.originGoodId)
.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })));
}
const dto = this.toPublicGoodDetail(good, familyVariants);
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
return dto;
}
@@ -362,13 +382,35 @@ export class PublicService {
return null;
}
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
private toPublicGoodDetail(
good: PublicGoodRow,
familyVariants: Array<{
originGoodId: bigint;
variant: PublicGoodRow['originGood']['variants'][number];
}> = [],
): PublicGoodDetailDto {
const base = this.toPublicGood(good);
const detail = good.originGood.detail;
// Merge primary and secondary origin good variants (dedup identical URLs).
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
// 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重。
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 allVariants = [
...good.originGood.variants,
...good.mergedOriginGoods.flatMap((m) => m.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),
),
];
return {
...base,
@@ -417,13 +459,13 @@ export class PublicService {
}
/**
* 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true
* 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询
* 族块(设计 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 !== 'true') return {};
if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {};
const family = good.originGood.family;
if (!family || !family.priceMatrix) return {};
const matrix = family.priceMatrix as {