fix(public): unify family representative selection with detail endpoint

List/home endpoints picked the family representative row via goods[0],
which drifted with the list sort parameter (cheapest under PRICE_ASC,
lowest id under DEFAULT ties), causing goodName and other
representative-derived fields to differ from the detail endpoint.
Extract pickFamilyRepresentative (goodPriority desc -> createdAt desc
-> id asc, same as getGoodByFamilyId) and use it in getGoods and
getHomeGoods grouping. Group ordering and API contracts unchanged.
This commit is contained in:
yeuimu
2026-09-02 14:58:42 +08:00
parent b32a5575df
commit 63820a3e6f
3 changed files with 210 additions and 9 deletions
+114
View File
@@ -728,4 +728,118 @@ describe('PublicService', () => {
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
});
});
describe('family representative row consistency (列表/首页代表行对齐详情)', () => {
// 同族两条 Good:同 priority=10Low 的 id 更小/createdAt 更早/价格更低/位置更好,
// High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc
// → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。
const stamp3 = `${stamp}-rep`;
let repFamilyId: bigint;
let trash = {
goodIds: [] as bigint[],
positionIds: [] as bigint[],
originGoodIds: [] as bigint[],
};
const repLowName = `Rep Low ${stamp3}`;
const repHighName = `Rep High ${stamp3}`;
beforeAll(async () => {
const posLow = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
const posHigh = await prisma.position.create({
data: { indexVal: 5, countryId, categoryId },
});
trash.positionIds = [posLow.id, posHigh.id];
const ogLow = await prisma.originGood.create({
data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 },
});
const ogHigh = await prisma.originGood.create({
data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 },
});
trash.originGoodIds = [ogLow.id, ogHigh.id];
const family = await prisma.productFamily.create({
data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id },
});
repFamilyId = family.id;
await prisma.originGood.updateMany({
where: { id: { in: [ogLow.id, ogHigh.id] } },
data: { familyId: family.id },
});
const gLow = await prisma.good.create({
data: {
goodName: repLowName,
originGoodId: ogLow.id,
familyId: family.id,
countryId,
categoryId,
goodPriority: 10,
positionId: posLow.id,
createdAt: new Date(stamp),
},
});
const gHigh = await prisma.good.create({
data: {
goodName: repHighName,
originGoodId: ogHigh.id,
familyId: family.id,
countryId,
categoryId,
goodPriority: 10,
positionId: posHigh.id,
createdAt: new Date(stamp + 60_000),
},
});
trash.goodIds = [gLow.id, gHigh.id];
});
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
await prisma.position
.deleteMany({ where: { id: { in: trash.positionIds } } })
.catch(() => undefined);
await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined);
await prisma.originGood
.deleteMany({ where: { id: { in: trash.originGoodIds } } })
.catch(() => undefined);
});
it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => {
const detail = await service.getGood(repFamilyId.toString());
expect(detail.goodName).toBe(repHighName);
const list = await service.getGoods({
page: 1,
pageSize: 50,
keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High
});
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => {
const detail = await service.getGood(repFamilyId.toString());
const list = await service.getGoods({
page: 1,
pageSize: 50,
keyword: 'Rep ',
sort: 'PRICE_ASC',
});
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => {
const detail = await service.getGood(repFamilyId.toString());
const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() });
const ours = home.filter((h) => h.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
});
});
+29 -9
View File
@@ -254,7 +254,7 @@ export class PublicService {
else grouped.set(key, [good]);
}
let items = [...grouped.values()].map((goods) => {
const rep = goods[0];
const rep = this.pickFamilyRepresentative(goods);
const dto = this.toPublicGood(rep);
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
const familyMin = rep.familyId
@@ -421,16 +421,20 @@ export class PublicService {
take: query.limit,
});
const familyMinPrices = await this.loadFamilyMinPrices();
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
const seen = new Set<string>();
const items: PublicGoodDto[] = [];
// 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
const grouped = new Map<string, PublicGoodListRow[]>();
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 = good.familyId
? familyMinPrices.get(good.familyId.toString())
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);
@@ -438,6 +442,22 @@ export class PublicService {
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) =>