feat(product-family): replace legacy good merge with family mechanism in config view and public detail
This commit is contained in:
@@ -19,6 +19,6 @@ THROTTLE_LIMIT=120
|
||||
|
||||
PORT=3001
|
||||
|
||||
# Gray release: expose product-family block (union size chart + 5-dim price matrix)
|
||||
# in GET /public/goods/:goodId responses. Off = response shape identical to before.
|
||||
PUBLIC_DETAIL_FROM_FAMILY=false
|
||||
# Product-family public read path (family block + family variant union in
|
||||
# GET /public/goods/:goodId). Set to 'false' for emergency rollback to legacy behavior.
|
||||
PUBLIC_DETAIL_FROM_FAMILY=true
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface GoodRelations {
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
family?: { id: bigint; familyCode: string | null; familyName: string } | null;
|
||||
detail?: {
|
||||
productCode: string | null;
|
||||
syncedAt: Date;
|
||||
@@ -127,6 +128,7 @@ export class GoodDto {
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
productCode: string | null;
|
||||
family?: { familyId: string; familyCode: string | null; familyName: string } | null;
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
@@ -215,6 +217,13 @@ export class GoodDto {
|
||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||
productCode: rel.originGood.detail?.productCode ?? null,
|
||||
family: rel.originGood.family
|
||||
? {
|
||||
familyId: rel.originGood.family.id.toString(),
|
||||
familyCode: rel.originGood.family.familyCode,
|
||||
familyName: rel.originGood.family.familyName,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -61,4 +61,15 @@ export class UpdateGoodDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
type: Number,
|
||||
description: '所属产品族 id(null = 脱离族)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
familyId?: number | null;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const GOOD_INCLUDE = {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
_count: { select: { variants: true } },
|
||||
family: { select: { id: true, familyCode: true, familyName: true, stale: true } },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
@@ -334,6 +335,18 @@ export class GoodsService {
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.familyId !== undefined) {
|
||||
if (dto.familyId !== null) {
|
||||
const family = await this.prisma.productFamily.findUnique({
|
||||
where: { id: BigInt(dto.familyId) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
|
||||
data.family = { connect: { id: family.id } };
|
||||
} else {
|
||||
data.family = { disconnect: true };
|
||||
}
|
||||
}
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('ProductFamiliesService', () => {
|
||||
});
|
||||
|
||||
it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
|
||||
const code = `DG${stamp}T`;
|
||||
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
@@ -58,18 +59,18 @@ describe('ProductFamiliesService', () => {
|
||||
});
|
||||
const f1 = (await service.create({
|
||||
familyName: '测试T恤',
|
||||
familyCode: 'DGTEST',
|
||||
familyCode: code,
|
||||
originGoodIds: [a.id.toString()],
|
||||
primaryOriginGoodId: a.id.toString(),
|
||||
})) as any;
|
||||
createdFamilyIds.push(BigInt(f1.id));
|
||||
expect(f1.familyCode).toBe('DGTEST');
|
||||
expect(f1.familyCode).toBe(code);
|
||||
expect(f1._count.originGoods).toBe(1);
|
||||
expect((f1.priceMatrix as any).rows).toHaveLength(1);
|
||||
|
||||
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any;
|
||||
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: code })) as any;
|
||||
createdFamilyIds.push(BigInt(f2.id));
|
||||
expect(f2.familyCode).toBe('DGTEST-2');
|
||||
expect(f2.familyCode).toBe(`${code}-2`);
|
||||
});
|
||||
|
||||
it('detail:不存在 404', async () => {
|
||||
@@ -77,11 +78,12 @@ describe('ProductFamiliesService', () => {
|
||||
});
|
||||
|
||||
it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => {
|
||||
const code = `DG${stamp}T`;
|
||||
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
|
||||
expect(res.total).toBeGreaterThanOrEqual(2);
|
||||
expect(res.items.length).toBeGreaterThanOrEqual(2);
|
||||
expect(res.page).toBe(1);
|
||||
const byCode = (await service.list({ keyword: 'DGTEST-2' })) as any;
|
||||
const byCode = (await service.list({ keyword: `${code}-2` })) as any;
|
||||
expect(byCode.total).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user