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
@@ -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<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];
}
```
- `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 即可。