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.
68 lines
3.3 KiB
Markdown
68 lines
3.3 KiB
Markdown
# 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 即可。
|