feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix

公开契约族化(前端只需知道款号/族):
- GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后);
  无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计)
- GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集,
  尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404
- priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级
  标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签
- 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复)
- family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化
- admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配,
  改价 payload 带 printCount)
- 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
This commit is contained in:
yeuimu
2026-08-28 18:43:30 +08:00
parent e5ec022834
commit 8a052773cd
19 changed files with 649 additions and 235 deletions
+129 -58
View File
@@ -66,6 +66,7 @@ export class PublicService {
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
@@ -109,7 +110,7 @@ export class PublicService {
async getCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: { originGood: { delisted: false } } } },
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
orderBy: { id: 'asc' },
});
return rows.map(PublicCountryDto.from);
@@ -117,7 +118,11 @@ export class PublicService {
async getTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
where: {
goodTags: {
some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
},
},
orderBy: [
{ tagGroup: { sortOrder: 'asc' } },
{ sortOrder: 'asc' },
@@ -130,6 +135,7 @@ export class PublicService {
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
@@ -160,7 +166,11 @@ export class PublicService {
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
// 无族商品(自定义)不进公开列表:只认族
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) {
@@ -199,65 +209,107 @@ export class PublicService {
{ id: 'asc' },
];
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: PUBLIC_GOOD_INCLUDE,
orderBy,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
const rows = await this.prisma.good.findMany({
where,
include: PUBLIC_GOOD_INCLUDE,
orderBy,
});
const grouped = new Map<string, PublicGoodRow[]>();
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 = goods[0];
const dto = this.toPublicGood(rep);
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
const familyMin = this.familyMinPrice(rep);
if (rep.familyId && familyMin !== null) 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),
);
}
const total = items.length;
const start = (query.page - 1) * query.pageSize;
return {
items: rows.map((good) => this.toPublicGood(good)),
items: items.slice(start, start + query.pageSize),
total,
page: query.page,
pageSize: query.pageSize,
};
}
/** 族物化矩阵的最低价;无族/无矩阵返回 null */
private familyMinPrice(good: PublicGoodRow): string | null {
const matrix = good.originGood.family?.priceMatrix as
| { rows?: Array<{ price: string }> }
| null
| undefined;
const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
return prices.length ? String(Math.min(...prices)) : null;
}
/**
* 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。
* 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。
*/
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const good = await this.prisma.good.findFirst({
where: {
OR: [
{ originGood: { sdsGoodId: goodId, delisted: false } },
// 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义)
{ family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } },
// 历史副源关联(good_origin_goods)只读保留,仍可命中
{
mergedOriginGoods: {
some: { originGood: { sdsGoodId: goodId, delisted: false } },
},
},
],
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> {
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 }] },
},
include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
});
if (!good) {
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
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;
}
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
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);
dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
return dto;
}
@@ -265,6 +317,7 @@ export class PublicService {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },
familyId: { not: null },
originGood: { delisted: false },
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
},
@@ -276,7 +329,19 @@ export class PublicService {
],
take: query.limit,
});
return rows.map((good) => this.toPublicGood(good));
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
const seen = new Set<string>();
const items: PublicGoodDto[] = [];
for (const good of rows) {
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
if (seen.has(key)) continue;
seen.add(key);
const dto = this.toPublicGood(good);
const familyMin = this.familyMinPrice(good);
if (good.familyId && familyMin !== null) dto.price = familyMin;
items.push(dto);
}
return items.slice(0, query.limit);
}
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
@@ -285,7 +350,8 @@ export class PublicService {
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
: null;
return {
goodId: good.originGood.sdsGoodId,
// 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId
goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
goodName: good.goodName,
goodPriority: good.goodPriority,
country: {
@@ -389,14 +455,17 @@ export class PublicService {
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 = good.mergedOriginGoods
.map((m) => m.originGood.detail)
.filter((d): d is NonNullable<typeof d> => Boolean(d));
// 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 去重(先到先得),避免站点出现重复的尺码×颜色行。
@@ -479,6 +548,7 @@ export class PublicService {
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 }>;
@@ -491,6 +561,7 @@ export class PublicService {
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,