From bea0bf14d8db3fa8b6de87ae777c52063b0ed6ca Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:27:23 +0800 Subject: [PATCH] feat(api): order public goods by country > mid-category > style > priority --- apps/api/src/public/public.service.spec.ts | 107 +++++++++++++++++++++ apps/api/src/public/public.service.ts | 70 ++++++++++++-- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index b6be652..bfdd95b 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -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款1;B 的 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])); + // 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面 + expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 12883a1..31378f6 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -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; + /** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */ + leafOrder: Map; +} + @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(); 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 { + 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 端展开聚合只回传