feat(api): order public goods by country > mid-category > style > priority

This commit is contained in:
yeuimu
2026-09-02 10:27:23 +08:00
parent 3ccddc5982
commit bea0bf14d8
2 changed files with 170 additions and 7 deletions
+107
View File
@@ -621,4 +621,111 @@ describe('PublicService', () => {
}
});
});
describe('getGoods tree-order sorting (国家→二级→款→priority)', () => {
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
// 期望默认顺序: A款1(priority desc) -> A款2 -> B款1B 的 priority=99 也不能越级
const stamp2 = `${stamp}-treeorder`;
const sdsA1 = `la1-${stamp2}`;
const sdsA2 = `la2-${stamp2}`;
const sdsB1 = `lb1-${stamp2}`;
const trash = {
goodIds: [] as bigint[],
familyIds: [] as bigint[],
originGoodIds: [] as bigint[],
categoryIds: [] as bigint[],
countryIds: [] as bigint[],
};
let orderedFamilyIds: string[] = [];
beforeAll(async () => {
const cA = await prisma.country.create({
data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 },
});
const cB = await prisma.country.create({
data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 },
});
trash.countryIds = [cA.id, cB.id];
const midA = await prisma.category.create({
data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 },
});
const leafA1 = await prisma.category.create({
data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 },
});
const leafA2 = await prisma.category.create({
data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 },
});
const midB = await prisma.category.create({
data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 },
});
const leafB1 = await prisma.category.create({
data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 },
});
trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id];
const mk = async (
countryId: bigint,
sdsCategoryId: string,
name: string,
priority: number,
) => {
const og = await prisma.originGood.create({
data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId },
});
trash.originGoodIds.push(og.id);
const fam = await prisma.productFamily.create({
data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id },
});
trash.familyIds.push(fam.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
const good = await prisma.good.create({
data: {
goodName: `TO${stamp2}-${name}`,
originGoodId: og.id,
familyId: fam.id,
countryId,
categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id,
goodPriority: priority,
},
});
trash.goodIds.push(good.id);
return fam.id.toString();
};
const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1);
const a1High = await mk(cA.id, sdsA1, 'A1High', 9);
const a2 = await mk(cA.id, sdsA2, 'A2', 0);
const b1 = await mk(cB.id, sdsB1, 'B1', 99);
orderedFamilyIds = [a1High, a1Low, a2, b1];
});
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined);
for (const id of trash.categoryIds) {
await prisma.category.delete({ where: { id } }).catch(() => undefined);
}
await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined);
});
it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => {
const res = await service.getGoods({
page: 1,
pageSize: 100,
keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断
});
expect(res.total).toBe(4);
const idx = res.items.map((i) => i.goodId);
const pos = orderedFamilyIds.map((id) => idx.indexOf(id));
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
// 同款内 priority desc
expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1]));
// 款顺序:LeafA1 -> LeafA2
expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2]));
// 国家/款顺序优先于 priorityB1(99) 不能排到 A2(0) 前面
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
});
});
});
+63 -7
View File
@@ -71,13 +71,19 @@ const PUBLIC_GOOD_LIST_INCLUDE = {
tag: { include: { tagGroup: true } },
position: true,
originGood: {
select: { sdsGoodId: true, goodImage: true, goodPrice: true },
select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
},
goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
interface TreeOrderMeta {
countryOrder: Map<string, number>;
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
leafOrder: Map<string, { c2: number; c3: number }>;
}
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
@@ -220,12 +226,7 @@ export class PublicService {
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
: query.sort === 'NEWEST'
? [{ createdAt: 'desc' }, { id: 'asc' }]
: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
: [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
@@ -235,6 +236,15 @@ export class PublicService {
include: PUBLIC_GOOD_LIST_INCLUDE,
orderBy,
});
if (!query.sort || query.sort === 'DEFAULT') {
// 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。
// 款顺序存于新树 categories.sort_order(回填自排序表),商品经
// origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享
// 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到
// 所属国家分组末尾。
const meta = await this.loadTreeOrderMeta();
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
}
const familyMinPrices = await this.loadFamilyMinPrices();
const grouped = new Map<string, PublicGoodListRow[]>();
for (const good of rows) {
@@ -270,6 +280,52 @@ export class PublicService {
};
}
/**
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
*/
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
const [countries, leaves] = await Promise.all([
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
this.prisma.$queryRaw<
Array<{ sds_category_id: string; c2: number; c3: number }>
>`
SELECT leaf.sds_category_id,
COALESCE(mid.sort_order, 2147483647) AS c2,
COALESCE(leaf.sort_order, 2147483647) AS c3
FROM categories leaf
JOIN categories mid ON mid.category_id = leaf.parent_category_id
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''
`,
]);
return {
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
leafOrder: new Map(
leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]),
),
};
}
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
const MAX = Number.MAX_SAFE_INTEGER;
const key = (g: PublicGoodListRow): [number, number, number, number, number] => {
const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? '');
return [
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
leaf?.c2 ?? MAX,
leaf?.c3 ?? MAX,
-(g.goodPriority ?? 0),
Number(g.id),
];
};
const ka = key(a);
const kb = key(b);
for (let i = 0; i < ka.length; i++) {
if (ka[i] !== kb[i]) return ka[i] - kb[i];
}
return 0;
}
/**
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传