merge: bugfix/public-goodname-representative (列表/首页族代表行对齐详情)
This commit is contained in:
@@ -728,4 +728,118 @@ describe('PublicService', () => {
|
|||||||
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export class PublicService {
|
|||||||
else grouped.set(key, [good]);
|
else grouped.set(key, [good]);
|
||||||
}
|
}
|
||||||
let items = [...grouped.values()].map((goods) => {
|
let items = [...grouped.values()].map((goods) => {
|
||||||
const rep = goods[0];
|
const rep = this.pickFamilyRepresentative(goods);
|
||||||
const dto = this.toPublicGood(rep);
|
const dto = this.toPublicGood(rep);
|
||||||
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||||
const familyMin = rep.familyId
|
const familyMin = rep.familyId
|
||||||
@@ -421,16 +421,20 @@ export class PublicService {
|
|||||||
take: query.limit,
|
take: query.limit,
|
||||||
});
|
});
|
||||||
const familyMinPrices = await this.loadFamilyMinPrices();
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
// 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
|
||||||
const seen = new Set<string>();
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
const items: PublicGoodDto[] = [];
|
|
||||||
for (const good of rows) {
|
for (const good of rows) {
|
||||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||||
if (seen.has(key)) continue;
|
const bucket = grouped.get(key);
|
||||||
seen.add(key);
|
if (bucket) bucket.push(good);
|
||||||
const dto = this.toPublicGood(good);
|
else grouped.set(key, [good]);
|
||||||
const familyMin = good.familyId
|
}
|
||||||
? familyMinPrices.get(good.familyId.toString())
|
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;
|
: undefined;
|
||||||
if (familyMin !== undefined) dto.price = familyMin;
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
items.push(dto);
|
items.push(dto);
|
||||||
@@ -438,6 +442,22 @@ export class PublicService {
|
|||||||
return items.slice(0, query.limit);
|
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,字段超集)都能传 */
|
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
||||||
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
||||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||||
|
|||||||
@@ -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 即可。
|
||||||
Reference in New Issue
Block a user