merge: feature/category-tree-sort (public goods style-order sorting)
This commit is contained in:
+1
-1
@@ -10,4 +10,4 @@ deploy/data-dump.json
|
||||
uploads/
|
||||
.pnpm-store/
|
||||
data-cleaning/
|
||||
backups/.zcode/
|
||||
backups/
|
||||
|
||||
@@ -95,4 +95,8 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
|
||||
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
|
||||
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
|
||||
- **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
|
||||
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
|
||||
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
|
||||
- **pnpm 仓库容器化执行要挂仓库根**:pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。
|
||||
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。
|
||||
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
|
||||
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
|
||||
* + 沙特国家行 + 中东根更名
|
||||
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应)
|
||||
const ROOT_MAP: Record<string, string> = {
|
||||
'美国': '美国工厂直发',
|
||||
'英国': '英国本地直发',
|
||||
'日本': '日本本地工厂直发',
|
||||
'墨西哥': '墨西哥工厂本地直发',
|
||||
'巴西': '巴西本地工厂直发',
|
||||
'中东': '中东本地工厂直发', // 同时更名沙特
|
||||
'波兰': '欧洲波兰工厂直发',
|
||||
'西班牙': '欧洲西班牙工厂直发',
|
||||
'德国': '欧洲德国工厂本地直发',
|
||||
'意大利': '欧洲意大利工厂直发',
|
||||
'加拿大': '加拿大本地工厂直发',
|
||||
'澳大利亚': '澳大利亚本地工厂直发',
|
||||
'韩国': '韩国本地直发',
|
||||
'中国(国内工厂)': '国内工厂',
|
||||
};
|
||||
|
||||
const norm = (s: string) => s.trim().replace(/\s+/g, '');
|
||||
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
|
||||
|
||||
async function main() {
|
||||
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
|
||||
let country: string | null = null;
|
||||
let l2: string | null = null;
|
||||
const tree: Array<{ country: string; l2: string; l3: string }> = [];
|
||||
const countryOrder: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('# ')) {
|
||||
const name = t.slice(2).trim();
|
||||
if (name === '全部' || name.includes('工厂直发国家')) continue;
|
||||
country = name;
|
||||
if (!countryOrder.includes(name)) countryOrder.push(name);
|
||||
} else if (t.startsWith('## ') && country) {
|
||||
l2 = t.slice(3).trim();
|
||||
} else if (t.startsWith('### ') && country && l2) {
|
||||
tree.push({ country, l2, l3: t.slice(4).trim() });
|
||||
}
|
||||
}
|
||||
console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`);
|
||||
|
||||
const roots = await prisma.category.findMany({
|
||||
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
|
||||
include: { children: { include: { children: true } } },
|
||||
});
|
||||
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
|
||||
const unmatched: string[] = [];
|
||||
|
||||
// 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过)
|
||||
for (let i = 0; i < countryOrder.length; i++) {
|
||||
const name = countryOrder[i];
|
||||
const dbCountry = name === '中东' ? '沙特' : name;
|
||||
if (dbCountry === '中国(国内工厂)') continue;
|
||||
const sortOrder = i + 1;
|
||||
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
|
||||
if (existing) {
|
||||
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||
} else if (dbCountry === '沙特') {
|
||||
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
|
||||
console.log('created country: 沙特');
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 中东根 → 沙特
|
||||
const meRoot = rootByName.get('中东本地工厂直发');
|
||||
if (meRoot) {
|
||||
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
|
||||
rootByName.set('沙特本地工厂直发', meRoot);
|
||||
console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发');
|
||||
}
|
||||
|
||||
// 3) 二级/款 sort_order
|
||||
for (const countryName of countryOrder) {
|
||||
const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName];
|
||||
const root = rootByName.get(rootName);
|
||||
if (!root) {
|
||||
unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`);
|
||||
continue;
|
||||
}
|
||||
const l2s = root.children;
|
||||
const l2NamesInOrder: string[] = [];
|
||||
for (const row of tree) {
|
||||
if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
|
||||
}
|
||||
for (let i = 0; i < l2NamesInOrder.length; i++) {
|
||||
const target = l2NamesInOrder[i];
|
||||
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
|
||||
if (!mid) {
|
||||
unmatched.push(`L2 MISS: ${countryName} / ${target}`);
|
||||
continue;
|
||||
}
|
||||
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
|
||||
const leaves = mid.children;
|
||||
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
|
||||
for (let j = 0; j < l3Names.length; j++) {
|
||||
const want = norm(l3Names[j]);
|
||||
const code = norm(codeOf(l3Names[j]));
|
||||
const leaf =
|
||||
leaves.find((l) => norm(l.categoryName) === want) ??
|
||||
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
|
||||
if (!leaf) {
|
||||
unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`);
|
||||
continue;
|
||||
}
|
||||
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatched.length) {
|
||||
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('backfill done');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -161,6 +161,7 @@ model Category {
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 端展开聚合只回传
|
||||
|
||||
@@ -113,7 +113,7 @@ apps/api/
|
||||
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
|
||||
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
||||
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
||||
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`) | 公开 |
|
||||
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`)。**默认排序 = 款序树**:国家(`countries.sort_order`) → 款所属二级(`categories.sort_order`) → 款(`categories.sort_order`) → `good_priority`;商品经 `origin_goods.sds_category_id` 定位到款(新树三级节点=合并后的款,顺序值由 `排序表.md` 经回填脚本写入),同一款下多条 Good 聚在一起、款内按优先级分先后;`sort=PRICE_ASC/PRICE_DESC/NEWEST` 不受影响 | 公开 |
|
||||
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
|
||||
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
||||
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
# 分类排序(category-tree-sort)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `GET /public/goods` 默认排序变为「国家 → 分类(款所属二级)→ 款 → good_priority」,把排序表中每个"款"下的 1~N 个已配置商品排到该款的位置上。**分类树保持老树完全不动**,接口输入输出 DTO 结构完全不变。
|
||||
|
||||
**Architecture:** 领域模型:老树(男士服装→T恤)是正式分类体系,小程序导航与 `categoryId` 过滤全部照旧;新树(国家根→男士T恤→DG001…)的二级/三级节点**只作为"款排序元数据层"**——"款"是合并后的具体衣服(一个款经 family 合并映射 1~N 个源商品/已配置商品)。`categories` 新增 `sort_order` 承载款顺序;`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行),商品经 `origin_goods.sds_category_id` 找到所属款。数据一次性回填脚本解析 `排序表.md`。
|
||||
|
||||
**Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试)
|
||||
|
||||
**已确认的决策:**
|
||||
1. **分类树不动**:`/public/categories` 继续返回老树,老树分类是对的
|
||||
2. **款 = 排序单元**:排序表的三级(DG001…)是款,不是分类;一个款在"已配置"里有 1~多个商品(同款不同工艺/物流拆分的,经 family 合并),它们排在一起、占该款的位置(如 DG004 的商品整体排在 DG001 后面)
|
||||
3. **前端调用形态(真实 URL 已确认)**:`categoryId` 永远只传一个老树节点 id,可选伴随 `countryId`,DTO 不动:
|
||||
- 只选分类:`?categoryId=659`(女士服装,跨国家)→ ①国家→②款顺序→③priority("美国的女士T恤各款排完到下个国家")
|
||||
- 选到叶子:`?categoryId=666` → 同上
|
||||
- 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③
|
||||
- 什么都不选 → 全局 ①②③
|
||||
(全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支)
|
||||
4. 「中东」改为「沙特」写入 countries(需求方口径);新树根改名「沙特本地工厂直发」;防晒衣(good_id=137) 的款不在树中(sds_category_id=2393 无对应节点),排序在日本组内沉底,不修复
|
||||
|
||||
**硬性约束:**
|
||||
- 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑
|
||||
- 已有测试必须全部通过,不允许跳过
|
||||
- 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status`
|
||||
- 回滚保障(均已就绪,见文末「备份与回滚」):
|
||||
- DB 全库快照:`backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc)
|
||||
- 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖)
|
||||
- 代码回滚 = 删 feature 分支
|
||||
|
||||
**执行环境(宿主机无 node;已验证的正确方式——挂仓库根 + 复用运行镜像):**
|
||||
|
||||
```bash
|
||||
# ⚠️ 两个坑(实测踩过):
|
||||
# 1) pnpm 的 node_modules 是相对符号链接指向根 .pnpm store,
|
||||
# 只挂 apps/api:/app 会断链(Cannot find module)——必须挂整个仓库根
|
||||
# 2) node:20-alpine 缺 libssl1.1,Prisma engine 加载失败——
|
||||
# 直接用项目运行镜像 deploy-v2-api(libssl 已匹配)
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-v /opt/inkreach-v2:/repo -w /repo/apps/api deploy-v2-api npx <prisma|jest|tsc ...>
|
||||
# 只读 prisma 元数据(migrate status 等)也可轻量挂载:
|
||||
# -v /opt/inkreach-v2/apps/api/prisma:/app/prisma deploy-v2-api npx prisma migrate status
|
||||
```
|
||||
|
||||
**排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):**
|
||||
|
||||
```
|
||||
① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER
|
||||
② 款所属二级(如"男士T恤")的 sort_order 缺失 → MAX_SAFE_INTEGER
|
||||
③ 款自身的 sort_order 缺失 → MAX_SAFE_INTEGER
|
||||
④ good_priority desc, id asc(现有字段,不变)
|
||||
```
|
||||
(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树,
|
||||
它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索。
|
||||
同一款下 1~N 个商品(单面/双面印花等)共享 ②③,仅靠 ④ 分先后,自然聚在一起。)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema 迁移 — categories 加 sort_order
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174)
|
||||
- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`
|
||||
|
||||
- [ ] **Step 1.1: 先检查迁移状态(共享库防漂移)**
|
||||
|
||||
```bash
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma migrate status
|
||||
```
|
||||
Expected: `Database schema is up to date!`。若报 drift/已存在类错误,按 AGENTS.md 经验用 `migrate resolve` 处理,禁止 reset。
|
||||
|
||||
- [ ] **Step 1.2: 修改 schema.prisma**
|
||||
|
||||
Category model 的 `sdsCategoryId` 行后新增:
|
||||
|
||||
```prisma
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)**
|
||||
|
||||
```bash
|
||||
mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_order
|
||||
```
|
||||
|
||||
创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client**
|
||||
|
||||
```bash
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||
npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_order/migration.sql
|
||||
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||
npx prisma migrate resolve --applied 20260901000000_add_category_sort_order
|
||||
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma generate
|
||||
```
|
||||
Expected: 三条命令均成功。
|
||||
|
||||
- [ ] **Step 1.5: 验证列存在**
|
||||
|
||||
```bash
|
||||
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep sort_order
|
||||
```
|
||||
Expected: `sort_order | integer | | not null | 0`
|
||||
|
||||
- [ ] **Step 1.6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_order
|
||||
git commit -m "feat(api): add sort_order column to categories"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/prisma/backfill-category-sort-order.ts`
|
||||
|
||||
**行为(幂等,可重复执行):**
|
||||
1. 解析 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级(男士T恤等)、`### ` = 款
|
||||
2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」(sort_order=表内顺序 6)+ 根重命名「沙特本地工厂直发」
|
||||
3. 每个根内:二级按出现顺序写 `sort_order=1..n`;款在所属二级范围内写 `sort_order=1..n`
|
||||
4. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配**
|
||||
5. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1(人工对齐排序表/库名后重跑);`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填
|
||||
6. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国)
|
||||
|
||||
- [ ] **Step 2.1: 编写脚本**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
|
||||
* + 沙特国家行 + 中东根更名
|
||||
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 排序表国家名 → 新树根分类名
|
||||
const ROOT_MAP: Record<string, string> = {
|
||||
'美国': '美国工厂直发',
|
||||
'英国': '英国本地直发',
|
||||
'日本': '日本本地工厂直发',
|
||||
'墨西哥': '墨西哥工厂本地直发',
|
||||
'巴西': '巴西本地工厂直发',
|
||||
'中东': '中东本地工厂直发', // 同时更名沙特
|
||||
'波兰': '欧洲波兰工厂直发',
|
||||
'西班牙': '欧洲西班牙工厂直发',
|
||||
'德国': '欧洲德国工厂本地直发',
|
||||
'意大利': '欧洲意大利工厂直发',
|
||||
'加拿大': '加拿大本地工厂直发',
|
||||
'澳大利亚': '澳大利亚本地工厂直发',
|
||||
'韩国': '韩国本地直发',
|
||||
'中国(国内工厂)': '国内工厂',
|
||||
};
|
||||
|
||||
const norm = (s: string) => s.trim().replace(/\s+/g, '');
|
||||
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
|
||||
|
||||
async function main() {
|
||||
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
|
||||
let country: string | null = null;
|
||||
let l2: string | null = null;
|
||||
const tree: Array<{ country: string; l2: string; l3: string }> = [];
|
||||
const countryOrder: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('# ')) {
|
||||
const name = t.slice(2).trim();
|
||||
if (name === '全部' || name.includes('工厂直发国家')) continue;
|
||||
country = name;
|
||||
if (!countryOrder.includes(name)) countryOrder.push(name);
|
||||
} else if (t.startsWith('## ') && country) {
|
||||
l2 = t.slice(3).trim();
|
||||
} else if (t.startsWith('### ') && country && l2) {
|
||||
tree.push({ country, l2, l3: t.slice(4).trim() });
|
||||
}
|
||||
}
|
||||
|
||||
const roots = await prisma.category.findMany({
|
||||
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
|
||||
include: { children: { include: { children: true } } },
|
||||
});
|
||||
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
|
||||
const unmatched: string[] = [];
|
||||
|
||||
// 1) countries:沙特 upsert + 顺序重写
|
||||
for (let i = 0; i < countryOrder.length; i++) {
|
||||
const name = countryOrder[i];
|
||||
const dbCountry = name === '中东' ? '沙特' : name;
|
||||
const sortOrder = i + 1;
|
||||
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
|
||||
if (existing) {
|
||||
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||
} else if (dbCountry === '沙特') {
|
||||
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 中东根 → 沙特
|
||||
const meRoot = rootByName.get('中东本地工厂直发');
|
||||
if (meRoot) {
|
||||
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
|
||||
rootByName.set('沙特本地工厂直发', meRoot);
|
||||
}
|
||||
|
||||
// 3) 二级/款 sort_order
|
||||
for (const countryName of countryOrder) {
|
||||
const rootName = ROOT_MAP[countryName];
|
||||
const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName);
|
||||
if (!root) { unmatched.push(`ROOT MISS: ${countryName}`); continue; }
|
||||
const l2s = root.children;
|
||||
const l2NamesInOrder: string[] = [];
|
||||
for (const row of tree) if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
|
||||
for (let i = 0; i < l2NamesInOrder.length; i++) {
|
||||
const target = l2NamesInOrder[i];
|
||||
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
|
||||
if (!mid) { unmatched.push(`L2 MISS: ${countryName} / ${target}`); continue; }
|
||||
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
|
||||
const leaves = mid.children;
|
||||
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
|
||||
for (let j = 0; j < l3Names.length; j++) {
|
||||
const want = norm(l3Names[j]);
|
||||
const code = norm(codeOf(l3Names[j]));
|
||||
const leaf =
|
||||
leaves.find((l) => norm(l.categoryName) === want) ??
|
||||
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
|
||||
if (!leaf) { unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`); continue; }
|
||||
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatched.length) {
|
||||
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('backfill done');
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: 执行脚本**
|
||||
|
||||
```bash
|
||||
# ts-node 不在 apps/api 依赖中,用 tsc 编译后以 node 运行;
|
||||
# 仓库根只读挂载到 /repo 以读取排序表.md
|
||||
docker run --rm --network deploy-v2_default \
|
||||
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||
-e SORT_TABLE_PATH=/repo/排序表.md \
|
||||
-v /opt/inkreach-v2:/repo:ro -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||
sh -c "npx tsc prisma/backfill-category-sort-order.ts --module commonjs --target es2020 --esModuleInterop --skipLibCheck --outDir /tmp/bf && node /tmp/bf/backfill-category-sort-order.js"
|
||||
```
|
||||
Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 `DG170G170G女士无痕三角内裤`(库内为 `DG701 170G女士无痕三角内裤`,货号前缀 `DG170G170G` 不匹配)→ 将排序表.md 该行改为与库名一致后重跑;其余逐条人工核对。
|
||||
|
||||
- [ ] **Step 2.3: SQL 验证回填结果**
|
||||
|
||||
```bash
|
||||
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "
|
||||
select country_name, sort_order from countries order by sort_order;" -c "
|
||||
select root.category_name, mid.category_name, mid.sort_order, count(leaf.category_id) leaves
|
||||
from categories root
|
||||
join categories mid on mid.parent_category_id = root.category_id
|
||||
left join categories leaf on leaf.parent_category_id = mid.category_id
|
||||
where root.parent_category_id is null and root.sds_category_id is not null
|
||||
group by 1,2,3 order by 1, mid.sort_order;" | head -70
|
||||
```
|
||||
Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下款 sort_order 连续 1..n。
|
||||
|
||||
抽查美国/男士T恤款顺序:
|
||||
|
||||
```bash
|
||||
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc "
|
||||
select category_name || ' | ' || sort_order from categories
|
||||
where parent_category_id = (select category_id from categories where category_name='男士T恤' and parent_category_id=(select category_id from categories where category_name='美国工厂直发'))
|
||||
order by sort_order;" | head -8
|
||||
```
|
||||
Expected: 第一行 `DG001 180G纯棉T恤 (JSA002) | 1`,第二行 `DG004 230G水洗T恤(JSA003) | 2`。
|
||||
|
||||
- [ ] **Step 2.4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/prisma/backfill-category-sort-order.ts
|
||||
git commit -m "feat(api): backfill category/country sort order from reference table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: getGoods 默认排序(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/public/public.service.ts`(getGoods 约 L186-271、PUBLIC_GOOD_LIST_INCLUDE 约 L68-77)
|
||||
- Test: `apps/api/src/public/public.service.spec.ts`(追加 describe,遵循现有真实库集成测试风格,fixture 带 `stamp` 唯一化)
|
||||
|
||||
- [ ] **Step 3.1: 写失败测试**
|
||||
|
||||
在 `public.service.spec.ts` 追加:
|
||||
|
||||
```ts
|
||||
describe('getGoods tree-order sorting', () => {
|
||||
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1,2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
|
||||
// 期望默认顺序: A款1 -> A款2 -> B款1,同款内 priority desc;B 的 priority=99 也不能越级
|
||||
const stamp2 = `treeorder-${Date.now()}`;
|
||||
let orderedGoodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const cA = await prisma.country.create({ data: { countryName: `TA ${stamp2}`, sortOrder: 1 } });
|
||||
const cB = await prisma.country.create({ data: { countryName: `TB ${stamp2}`, sortOrder: 2 } });
|
||||
const midA = await prisma.category.create({ data: { categoryName: `MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 } });
|
||||
const leafA1 = await prisma.category.create({ data: { categoryName: `LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la1-${stamp2}`, sortOrder: 1 } });
|
||||
const leafA2 = await prisma.category.create({ data: { categoryName: `LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la2-${stamp2}`, sortOrder: 2 } });
|
||||
const midB = await prisma.category.create({ data: { categoryName: `MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 } });
|
||||
const leafB1 = await prisma.category.create({ data: { categoryName: `LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: `lb1-${stamp2}`, sortOrder: 2 } });
|
||||
|
||||
const mk = async (countryId: bigint, sdsCat: string, name: string, priority: number) => {
|
||||
const og = await prisma.originGood.create({ data: { sdsGoodId: `${name}-${stamp2}`, goodName: name, sdsCategoryId: sdsCat } });
|
||||
const fam = await prisma.productFamily.create({ data: { familyName: `f-${name}-${stamp2}`, primaryOriginGoodId: og.id } });
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
|
||||
return prisma.good.create({ data: { goodName: name, originGoodId: og.id, familyId: fam.id, countryId, categoryId: leafA1.id, goodPriority: priority } });
|
||||
};
|
||||
const a1Low = await mk(cA.id, `la1-${stamp2}`, 'A1Low', 1);
|
||||
const a1High = await mk(cA.id, `la1-${stamp2}`, 'A1High', 9);
|
||||
const a2 = await mk(cA.id, `la2-${stamp2}`, 'A2', 0);
|
||||
const b1 = await mk(cB.id, `lb1-${stamp2}`, 'B1', 99);
|
||||
orderedGoodIds = [a1High.id, a1Low.id, a2.id, b1.id];
|
||||
});
|
||||
|
||||
it('DEFAULT: country > mid > leaf > priority', async () => {
|
||||
const res = await service.getGoods({ page: 1, pageSize: 50 });
|
||||
const idx = res.items.map((i) => BigInt(i.goodId));
|
||||
const pos = orderedGoodIds.map((id) => idx.indexOf(id));
|
||||
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
|
||||
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
|
||||
expect(idx.indexOf(orderedGoodIds[0])).toBeLessThan(idx.indexOf(orderedGoodIds[1])); // 同款内 priority desc
|
||||
expect(idx.indexOf(orderedGoodIds[1])).toBeLessThan(idx.indexOf(orderedGoodIds[2])); // 款顺序
|
||||
expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/款顺序优先于 priority
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: 跑测试确认失败**
|
||||
|
||||
```bash
|
||||
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest src/public/public.service.spec.ts -t 'tree-order'
|
||||
```
|
||||
Expected: FAIL(现顺序按 goodPriority desc,B1 会排最前)。
|
||||
|
||||
- [ ] **Step 3.3: 实现**
|
||||
|
||||
`public.service.ts`:
|
||||
|
||||
1) `PUBLIC_GOOD_LIST_INCLUDE.originGood.select` 增加 `sdsCategoryId: true`
|
||||
|
||||
2) 新增类型与私有方法:
|
||||
|
||||
```ts
|
||||
interface TreeOrderMeta {
|
||||
countryOrder: Map<string, number>;
|
||||
leafOrder: Map<string, { c2: number; c3: number }>; // 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: bigint | number; c3: bigint | 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] => [
|
||||
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
|
||||
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c2 ?? MAX,
|
||||
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c3 ?? MAX,
|
||||
-g.goodPriority,
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
3) `getGoods`:DEFAULT 分支的 DB `orderBy` 改为 `[{ id: 'asc' }]`(排序移内存),`findMany` 之后、分组之前插入:
|
||||
|
||||
```ts
|
||||
if (!query.sort || query.sort === 'DEFAULT') {
|
||||
const meta = await this.loadTreeOrderMeta();
|
||||
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
|
||||
}
|
||||
```
|
||||
(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致;同一款下 1~N 个商品共享 ②③ 键,仅靠 ④ 分先后。)
|
||||
|
||||
- [ ] **Step 3.4: 跑测试确认通过(含既有用例)**
|
||||
|
||||
```bash
|
||||
docker run --rm ... npx jest src/public/public.service.spec.ts
|
||||
```
|
||||
Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按新规则修正期望;fixture 多为同国家同分类,一般不受影响)。
|
||||
|
||||
- [ ] **Step 3.5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts
|
||||
git commit -m "feat(api): order public goods by country > mid-category > style > priority"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 全量验证 + 交付
|
||||
|
||||
- [ ] **Step 4.1: api 全量测试**
|
||||
|
||||
```bash
|
||||
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest
|
||||
```
|
||||
Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变更波及的用例)。
|
||||
|
||||
- [ ] **Step 4.2: verification-before-completion 自检清单**
|
||||
|
||||
- [ ] `prisma migrate status` 干净(无未应用/未登记迁移)
|
||||
- [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录)
|
||||
- [ ] 冒烟(真实调用形态,先完成 Step 4.4 重建容器):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 输出与改造前完全一致(老树,结构内容均不变)
|
||||
- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / 分页结构)无增删
|
||||
- [ ] `git status` 干净,全部提交
|
||||
|
||||
- [ ] **Step 4.3: 合并回 refactor/v2**
|
||||
|
||||
```bash
|
||||
git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods style-order sorting)"
|
||||
git branch -d feature/category-tree-sort
|
||||
```
|
||||
(本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。)
|
||||
|
||||
- [ ] **Step 4.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)**
|
||||
|
||||
```bash
|
||||
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api
|
||||
```
|
||||
|
||||
- [ ] **Step 4.5: 按项目规则沉淀**
|
||||
|
||||
- 更新 `docs/references/structs.md`(categories.sort_order 新字段、public 排序语义、款/family 领域说明)
|
||||
- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则)
|
||||
- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、「绝对排序键 + 子集过滤」模式、排序表驱动回填的幂等脚本设计)
|
||||
|
||||
---
|
||||
|
||||
## 范围外(明确不做)
|
||||
|
||||
- **分类树接口与导航**(老树不动;新树仅作款排序元数据层)
|
||||
- `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调)
|
||||
- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中日本组内沉底)
|
||||
- admin 后台款顺序拖拽管理(本期用脚本回填;后续如需可视化调整再立项)
|
||||
- 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复)
|
||||
|
||||
---
|
||||
|
||||
## 备份与回滚(已就绪)
|
||||
|
||||
| 资产 | 位置 | 时间 |
|
||||
|---|---|---|
|
||||
| DB 全库快照 | `backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc,3.4M) | 2026-09-01 |
|
||||
| 运行容器快照 | docker 镜像 `inkreach-api-snapshot:20260901`(711MB,commit 自 deploy-v2-api-1) | 2026-09-01 |
|
||||
| 代码 | 分支 `feature/category-tree-sort`(refactor/v2 未动) | 实时 |
|
||||
|
||||
**DB 恢复(覆盖式,先停 api 容器避免写入竞争):**
|
||||
|
||||
```bash
|
||||
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml stop api
|
||||
docker exec -i deploy-v2-postgres-1 pg_restore -U inkreach -d inkreach --clean --if-exists \
|
||||
< /opt/inkreach-v2/backups/inkreach_snapshot_20260901_185233.dump
|
||||
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml start api
|
||||
```
|
||||
|
||||
**api 容器恢复(快照镜像另起实例做比对/应急):**
|
||||
|
||||
```bash
|
||||
docker run -d --name deploy-v2-api-snapshot --network deploy-v2_default inkreach-api-snapshot:20260901
|
||||
```
|
||||
@@ -14,7 +14,7 @@
|
||||
### DG100 180G女士彩棉T恤
|
||||
### DG101 180G纯棉女士露脐T恤
|
||||
### DG102 高弹力女款露脐
|
||||
### TDG110 180G大码彩棉女T恤
|
||||
### DG110 180G大码彩棉女T恤
|
||||
### DG120 牛奶丝T恤
|
||||
### DG150 180G纯棉女士T恤
|
||||
### DG502 180G女士圆领长袖T恤
|
||||
@@ -27,7 +27,7 @@
|
||||
### DG014 190G小童T恤
|
||||
### DG301 190G纯棉哈哈衣
|
||||
### DG503 190G小童长袖
|
||||
### T5000B 180G吉尔丹童装T恤
|
||||
### 5000B 180G吉尔丹童装T恤
|
||||
### JSA004 190G牛奶丝小童T恤
|
||||
## 男士短裤
|
||||
### DG201 300G脏洗短裤
|
||||
@@ -62,7 +62,7 @@
|
||||
## 男士长裤
|
||||
### DG210 280G高级抓绒慢跑卫裤
|
||||
## 内衣
|
||||
### DG170G170G女士无痕三角内裤
|
||||
### DG701 170G女士无痕三角内裤
|
||||
### JSA010 270G高弹力女士抹胸
|
||||
### JSE001 180G女士无痕内衣
|
||||
## 家居
|
||||
|
||||
Reference in New Issue
Block a user