feat(api): public good detail family block behind gray-release flag

This commit is contained in:
yeuimu
2026-08-28 13:40:19 +08:00
parent b4b9fbbe60
commit 6418aa7302
9 changed files with 324 additions and 6 deletions
+4
View File
@@ -18,3 +18,7 @@ CORS_ORIGINS=http://localhost:5173
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
+8
View File
@@ -122,11 +122,17 @@ export class GoodsService {
);
await this.ensureMergedOriginGoods(mergedIds);
const result = await this.prisma.$transaction(async (tx) => {
// Good 的族是派生数据:主链接所属族
const primary = await tx.originGood.findUnique({
where: { id: BigInt(dto.originGoodId) },
select: { familyId: true },
});
const created = await tx.good.create({
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
familyId: primary?.familyId ?? null,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
@@ -217,6 +223,7 @@ export class GoodsService {
const good = await tx.good.create({
data: {
originGoodId: originGood.id,
familyId: family?.id ?? null,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId:
@@ -445,6 +452,7 @@ export class GoodsService {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage,
originGoodId: og.id,
familyId: og.familyId,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
@@ -221,6 +221,10 @@ export class ProductFamiliesService {
where: { id: { in: removeIds }, familyId: id },
data: { familyId: null },
});
await this.prisma.good.updateMany({
where: { originGoodId: { in: removeIds }, familyId: id },
data: { familyId: null },
});
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
if (remaining > 0) {
const stillPrimary = await this.prisma.originGood.count({
@@ -248,10 +252,12 @@ export class ProductFamiliesService {
}
if (dto.addOriginGoodIds?.length) {
await this.attachMembers(
id,
dto.addOriginGoodIds.map((v) => BigInt(v)),
);
const addIds = dto.addOriginGoodIds.map((v) => BigInt(v));
await this.attachMembers(id, addIds);
await this.prisma.good.updateMany({
where: { originGoodId: { in: addIds } },
data: { familyId: id },
});
}
await this.recompute.recomputeFamily(id);
@@ -59,6 +59,27 @@ export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true })
detailSyncedAt!: string | null;
/** 灰度字段:PUBLIC_DETAIL_FROM_FAMILY=true 且主链接有所属族时输出,否则完全不含该键 */
@ApiProperty({
required: false,
nullable: true,
type: Object,
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)',
})
family?: {
familyId: string;
familyCode: string | null;
familyName: string;
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
crafts: string[];
logistics: string[];
sizeChart: Record<string, unknown> | null;
packageSpecs: Record<string, unknown> | null;
priceMatrix: Record<string, unknown>;
minPrice: string | null;
} | null;
}
export class PublicTagGroupFilterDto {
@@ -0,0 +1,205 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
/**
* 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、
* 族块字段内容、Good.familyId 派生与成员移动联动。
*/
describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
let service: PublicService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const createdGoodIds: bigint[] = [];
const createdCountryIds: bigint[] = [];
const createdCategoryIds: bigint[] = [];
const prevFlag = process.env.PUBLIC_DETAIL_FROM_FAMILY;
let sdsGoodId = '';
let familyId = 0n;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PublicService, PrismaService],
}).compile();
service = moduleRef.get(PublicService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `公开族国家-${stamp}` },
});
createdCountryIds.push(country.id);
const category = await prisma.category.create({
data: { categoryName: `公开族分类-${stamp}` },
});
createdCategoryIds.push(category.id);
// 族 + 主链接(带变体)+ Good
const og = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-${stamp}`,
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花`,
goodPrice: new Prisma.Decimal(25),
craftLabel: '单面印花',
logisticsLabel: '包邮',
},
});
sdsGoodId = og.sdsGoodId;
createdOriginGoodIds.push(og.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: og.id,
sdsVariantId: 'pf-v1',
sku: `PF-${stamp}-S`,
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const family = await prisma.productFamily.create({
data: { familyName: `公开族-${stamp}`, familyCode: `PF${stamp}`, primaryOriginGoodId: og.id },
});
familyId = family.id;
createdFamilyIds.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
const good = await prisma.good.create({
data: {
originGoodId: og.id,
familyId: family.id,
countryId: country.id,
categoryId: category.id,
goodName: `公开族商品-${stamp}`,
},
});
createdGoodIds.push(good.id);
});
afterAll(async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = prevFlag;
await prisma.good.deleteMany({ where: { id: { in: createdGoodIds } } });
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.country.deleteMany({ where: { id: { in: createdCountryIds } } });
await prisma.category.deleteMany({ where: { id: { in: createdCategoryIds } } });
await prisma.$disconnect();
});
it('开关关闭:响应完全不含 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 () => {
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();
});
it('无族商品:开关开启也不含 family 键', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
const og2 = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-2-${stamp}`,
goodName: `无族链接-${stamp}`,
goodPrice: new Prisma.Decimal(10),
},
});
createdOriginGoodIds.push(og2.id);
const good2 = await prisma.good.create({
data: {
originGoodId: og2.id,
countryId: createdCountryIds[0],
categoryId: createdCategoryIds[0],
goodName: `无族商品-${stamp}`,
},
});
createdGoodIds.push(good2.id);
const detail = await service.getGood(`pubfam-2-${stamp}`);
expect('family' in detail).toBe(false);
});
});
describe('Good familyId derivation', () => {
let prisma: PrismaService;
let familiesService: import('../product-families/product-families.service').ProductFamiliesService;
const stamp = Date.now();
const ids = { originGood: [] as bigint[], family: [] as bigint[], good: [] as bigint[], country: [] as bigint[], category: [] as bigint[] };
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaService],
}).compile();
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
prisma,
new FamilyRecomputeService(prisma),
);
});
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
await prisma.$disconnect();
});
it('updateMembers 移除/添加成员时,Good.familyId 联动', async () => {
const country = await prisma.country.create({ data: { countryName: `联动国家-${stamp}` } });
ids.country.push(country.id);
const category = await prisma.category.create({ data: { categoryName: `联动分类-${stamp}` } });
ids.category.push(category.id);
const og = await prisma.originGood.create({
data: { sdsGoodId: `derive-${stamp}`, goodName: `联动链接-${stamp}` },
});
ids.originGood.push(og.id);
const family = await prisma.productFamily.create({
data: { familyName: `联动族-${stamp}`, primaryOriginGoodId: og.id },
});
ids.family.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
const good = await prisma.good.create({
data: {
originGoodId: og.id,
familyId: family.id,
countryId: country.id,
categoryId: category.id,
goodName: `联动商品-${stamp}`,
},
});
ids.good.push(good.id);
// 移出族 → Good.familyId 置空
await familiesService.updateMembers(family.id, { removeOriginGoodIds: [og.id.toString()] });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
).toBeNull();
// 重新挂回 → Good.familyId 回填
await familiesService.updateMembers(family.id, { addOriginGoodIds: [og.id.toString()] });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
).toBe(family.id);
});
});
+46
View File
@@ -32,6 +32,16 @@ const PUBLIC_GOOD_INCLUDE = {
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: {
@@ -402,6 +412,42 @@ export class PublicService {
sortOrder: variant.sortOrder,
})),
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
...this.familyBlock(good),
};
}
/**
* 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true
* 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。
*/
private familyBlock(
good: PublicGoodRow,
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') 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 }>;
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,
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,
},
};
}
+5
View File
@@ -715,6 +715,11 @@ export class SyncService {
where: { id: originGoodId },
data: { familyId },
});
// Good 的族随主链接联动
await this.prisma.good.updateMany({
where: { originGoodId },
data: { familyId },
});
this.familyRecompute.enqueue(familyId);
} else {
await this.prisma.productFamily.update({
+23
View File
@@ -101,8 +101,31 @@ pnpm --filter @inkreach/api backfill:product-families
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
```
**公开读路径灰度(三期已上线,默认关闭)**:环境变量 `PUBLIC_DETAIL_FROM_FAMILY=true` 时,
`GET /public/goods/:goodId` 在既有响应上**增量**输出 `family` 块:
```jsonc
{
"family": {
"familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤",
"sizes": [...], "colors": [...], "crafts": ["单面印花", ...], "logistics": ["包邮", ...],
"sizeChart": { /* */ }, "packageSpecs": { /* */ },
"priceMatrix": { /* rows {sizeId, colorId, craft, logistics, price, manual, sources} */ },
"minPrice": "29.5"
}
}
```
实测(DG015 族):4 工艺 × 2 物流 × 8 尺码 × 12 颜色 = 653 格矩阵,族起价 ¥29.5
(主源单链接价为 ¥68.74——族视角展示了光板/单面的更低档价格)。前端本地按五维联动
`priceMatrix` 即可实时算价,无需新增查价端点(设计 D4)。
**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON,
且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。
**后台操作入口(二期已上线)**:商品中心新增「产品族」标签页(`/goods?tab=families`):
- 列表:关键词/分页、成员数、人工改价数、自动托管/锁定状态、`stale` 待处理标记;
- 「自动成族」:先预览候选分组(SDS 分类聚合),确认后应用并逐族重算;
- 详情抽屉:canonical 字段编辑(名称/编码/主图/主链接)、`autoManaged` 开关、手动重算、
+2 -2
View File
@@ -89,7 +89,7 @@ apps/api/
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
| `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority``familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) |
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
| `User` | 后台用户(bcrypt 哈希) |
| `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` |
@@ -114,7 +114,7 @@ apps/api/
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize``tagIds` 为 AND 关系) | 公开 |
| `/public/goods/:id` `GET` | 商品详情 | 公开 |
| `/public/goods/:id` `GET` | 商品详情`PUBLIC_DETAIL_FROM_FAMILY=true` 且主链接有族时额外输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),开关关闭时响应形状与现状一致 | 公开 |
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |