From 63820a3e6f3ff36fa1df3b43bbd8e83a21040d14 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 14:58:42 +0800 Subject: [PATCH] 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. --- apps/api/src/public/public.service.spec.ts | 114 ++++++++++++++++++ apps/api/src/public/public.service.ts | 38 ++++-- .../fix/public-goodname-representative-fix.md | 67 ++++++++++ 3 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 plans/fix/public-goodname-representative-fix.md diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index bfdd95b..67fa782 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -728,4 +728,118 @@ describe('PublicService', () => { expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); }); }); + + describe('family representative row consistency (列表/首页代表行对齐详情)', () => { + // 同族两条 Good:同 priority=10,Low 的 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); + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 31378f6..e90d26d 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -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(); - const items: PublicGoodDto[] = []; + // 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致 + const grouped = new Map(); 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( + 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) => diff --git a/plans/fix/public-goodname-representative-fix.md b/plans/fix/public-goodname-representative-fix.md new file mode 100644 index 0000000..e40909d --- /dev/null +++ b/plans/fix/public-goodname-representative-fix.md @@ -0,0 +1,67 @@ +# Fix:公开接口族代表行选取规则统一(列表/首页对齐详情) + +日期:2026-09-02 +类型:Bug 修复(fix) +影响面:`apps/api/src/public/public.service.ts` 及其测试 + +## 背景与问题 + +公开接口中,族(productFamily)对外只暴露一条"代表行",`goodName`、主图、国家、分类、标签、`goodPriority`、`createdAt` 等字段全部来自代表行。但三个接口的代表行选取规则不一致: + +| 接口 | 代表行选取规则 | 位置 | +| --- | --- | --- | +| 详情 `GET /public/goods/{goodId}` | `goodPriority desc → createdAt desc → id asc` | `getGoodByFamilyId` | +| 列表 `GET /public/goods` | `goods[0]`(随列表排序参数变化:DEFAULT 平局取 id 最小;价格排序取价格极值行;NEWEST 取最新行) | `getGoods` 分组处 | +| 首页 `GET /public/home-goods` | `goods[0]`(按 position 顺序的第一条) | `getHomeGoods` | + +后果:同一族内多条 Good 名称不同时,列表返回的 `goodName` 与详情不一致;且列表换排序参数后名称还会变。 + +## 目标 + +- 列表与首页的族代表行选取规则与详情完全一致:`goodPriority desc → createdAt desc → id asc`。 +- 列表排序逻辑(DEFAULT 树序 / PRICE / NEWEST)与分组顺序完全不变。 +- 公开 API 输入输出数据结构不变(硬性约束)。 + +## 非目标 + +- 不改动详情逻辑、族去重契约、树序排序实现。 +- 不处理无族(自定义商品)——其分组内只有一条,不受影响。 + +## 方案(已确认:方案 A + 首页一起改) + +在 `public.service.ts` 中新增私有方法,组内显式选取代表行,`getGoods` 与 `getHomeGoods` 分组处统一调用: + +```ts +/** 族代表行:与详情 getGoodByFamilyId 的 orderBy 保持一致 + * (goodPriority desc → createdAt desc → id asc),保证列表/首页/详情字段一致 */ +private pickFamilyRepresentative( + 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]; +} +``` + +- `getGoods`:`const rep = this.pickFamilyRepresentative(goods);` +- `getHomeGoods`:同上替换 `goods[0]`。 +- 组序不受影响:同族成员共享国家/款,最高 `goodPriority` 相同,仅平局细则变化,树序键 c1/c2/c3 与 max(priority) 均不变。 + +## 测试计划(TDD) + +测试文件:`apps/api/src/public/public.service.spec.ts`(沿用现有夹具风格)。 + +1. **列表 vs 详情一致性**:构造同族两条 Good,`goodPriority` 相同、`createdAt` 不同、名称不同 → + `GET /public/goods` 列表项的 `goodName` === 详情接口返回的 `goodName`。 +2. **优先级优先**:族内两条 Good 优先级不同 → 列表代表行取优先级最高的那条(即使它 id 更大/创建更早)。 +3. **价格排序下名称不漂移**:`sort=PRICE_ASC` 时,价格较低但优先级低的成员不是代表行,列表 `goodName` 与 DEFAULT 排序一致。 +4. **首页一致性**:位置配置同族多条 → 首页返回的 `goodName` 与详情一致。 +5. 既有用例全部保持通过。 + +## 风险与回滚 + +- 纯内存选取逻辑变更,无 schema/数据变更,无部署数据风险。 +- 回滚:还原 commit 即可。