From 88f60ba2e2936c485ead505573e49e2dd82465f8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Tue, 1 Sep 2026 19:26:50 +0800 Subject: [PATCH 1/8] docs: add category-sort implementation plan (single categoryId, dual-path filter) --- plans/feature/category-sort-feature.md | 727 +++++++++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 plans/feature/category-sort-feature.md diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md new file mode 100644 index 0000000..5548ab2 --- /dev/null +++ b/plans/feature/category-sort-feature.md @@ -0,0 +1,727 @@ +# 分类排序(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」,`GET /public/categories` 内容源切换为新树(国家根→二级→三级款),支持 1/2/3 级任意筛选组合,接口输入输出 DTO 结构完全不变。 + +**Architecture:** `categories` 新增 `sort_order`(二/三级顺序)与 `country_id`(树根关联国家,一级顺序复用 `countries.sort_order`)。`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行);`categoryId` 仍是单个 id(DTO 不动)+ 双通道过滤(新树走 `origin_goods.sds_category_id`,老树回退 `goods.category_id`)。数据一次性回填脚本解析 `排序表.md`。 + +**Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试) + +**已确认的决策:** +1. **前端调用形态(真实 URL 已确认)**:无论选中哪一层,`categoryId` 永远只传**一个**节点 id(DTO 完全不动,无逗号多 id),可选伴随 `countryId`。goods 全挂老树,老树节点天然跨国家: + - 只选老树节点:`?categoryId=659`(女士服装,跨国家)→ 按 ①国家→②新树二级→③新树款→④priority 排("美国的女士T恤全排完到下个国家") + - 选到老树叶子:`?categoryId=666`(女士/裤装,跨国家)→ 同上 + - 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③④ + - 只选国家:`?countryId=49` → 同上 + - 什么都不选 → 全局 ①②③④ + (以上全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支逻辑) +2. 款内顺序复用现有 `good_priority`(只需保证三级款之间顺序正确) +3. 「中东」改为「沙特」写入 countries;防晒衣(good_id=137) 不处理(排序沉底) +4. `/public/categories` 切换为新树内容源(结构不变);空国家/空二级(productCount=0)不返回,与旧行为、`getCountries` 口径一致。切换后前端将传新树 id(某国的二级/款),`categoryId` 过滤走双通道兼容两种 id + +**硬性约束:** +- 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑 +- 已有测试必须全部通过,不允许跳过 +- 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status` +- 回滚保障:`backups/inkreach_snapshot_20260901_185233.dump`(全库快照已做);代码回滚 = 删 feature 分支 + +**执行环境(宿主机无 node,统一用容器跑):** + +```bash +# 测试 / prisma 命令统一模板(挂载源码 + 加入 postgres 网络) +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 <命令> +``` + +**排序键定义(getGoods DEFAULT):** + +``` +① countries.sort_order(经 goods.country_id) 缺失 → 排最后 +② 叶子.parent_category_id 的 sort_order(二级) 缺失 → 排最后 +③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → 排最后 +④ good_priority desc, id asc(现有字段,不变) +``` + +--- + +### Task 1: Schema 迁移 — categories 加 sort_order + country_id + +**Files:** +- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174、Country model 约 L141-154) +- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/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` 行后新增两行,并新增 relation: + +```prisma + sortOrder Int @default(0) @map("sort_order") + countryId BigInt? @map("country_id") + country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction) +``` + +Country model:`families ProductFamily[]` 后新增: + +```prisma + categories Category[] +``` + +Category model 底部 `@@index([parentCategoryId])` 后新增: + +```prisma + @@index([countryId]) +``` + +- [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)** + +```bash +mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_fields +``` + +创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql`: + +```sql +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "categories" ADD COLUMN "country_id" BIGINT; + +-- ForeignKey +ALTER TABLE "categories" ADD CONSTRAINT "categories_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION; + +-- CreateIndex +CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); +``` + +- [ ] **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_fields/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_fields + +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 -E 'sort_order|country_id' +``` +Expected: 两列均存在(`sort_order integer not null default 0`、`country_id bigint`)。 + +- [ ] **Step 1.6: Commit** + +```bash +git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_fields +git commit -m "feat(api): add sort_order and country_id to categories" +``` + +--- + +### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特 + 根关联国家 + +**Files:** +- Create: `apps/api/prisma/backfill-category-sort-order.ts` + +**行为(幂等,可重复执行):** +1. 解析仓库根 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级、`### ` = 三级 +2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」+ 根重命名 `沙特本地工厂直发` +3. 根节点写入 `country_id`(关联 countries 行;`国内工厂` 无对应国家行 → 保持 null) +4. 每个根内:二级按出现顺序写 `sort_order=1..n`;三级在所属二级范围内写 `sort_order=1..n` +5. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配** +6. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1;`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填 +7. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国) + +- [ ] **Step 2.1: 编写脚本** + +```ts +/** + * 一次性回填:解析 排序表.md → categories.sort_order(二/三级)+ country_id(根) + * + countries.sort_order + 沙特国家行 + 中东根更名 + * 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复 + */ +import { PrismaClient } from '@prisma/client'; +import { readFileSync } from 'fs'; + +const prisma = new PrismaClient(); + +// 排序表国家名 → 新树根分类名 +const ROOT_MAP: Record = { + '美国': '美国工厂直发', + '英国': '英国本地直发', + '日本': '日本本地工厂直发', + '墨西哥': '墨西哥工厂本地直发', + '巴西': '巴西本地工厂直发', + '中东': '中东本地工厂直发', // 同时更名沙特 + '波兰': '欧洲波兰工厂直发', + '西班牙': '欧洲西班牙工厂直发', + '德国': '欧洲德国工厂本地直发', + '意大利': '欧洲意大利工厂直发', + '加拿大': '加拿大本地工厂直发', + '澳大利亚': '澳大利亚本地工厂直发', + '韩国': '韩国本地直发', + '中国(国内工厂)': '国内工厂', +}; + +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('/opt/inkreach-v2/排序表.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 + 顺序重写 + const countryIdByName = new Map(); + 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 } }); + countryIdByName.set(dbCountry, existing.id); + } else if (dbCountry === '沙特') { + const created = await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); + countryIdByName.set('沙特', created.id); + } + } + + // 2) 中东根 → 沙特 + const meRoot = rootByName.get('中东本地工厂直发'); + if (meRoot) { + await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } }); + rootByName.set('沙特本地工厂直发', meRoot); + } + + // 3) 根节点关联 country_id + for (const [tableName, rootName] of Object.entries(ROOT_MAP)) { + const root = rootByName.get(tableName === '中东' ? '沙特本地工厂直发' : rootName); + if (!root) { unmatched.push(`ROOT MISS: ${tableName}`); continue; } + const dbCountry = tableName === '中东' ? '沙特' : tableName === '中国(国内工厂)' ? null : tableName; + const cid = dbCountry ? (countryIdByName.get(dbCountry) ?? null) : null; + await prisma.category.update({ where: { id: root.id }, data: { countryId: cid } }); + } + + // 4) 二/三级 sort_order + for (const countryName of countryOrder) { + const rootName = ROOT_MAP[countryName]; + const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName); + if (!root) continue; // 已在 ROOT MISS 记录 + 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 +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 ts-node prisma/backfill-category-sort-order.ts +``` +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, root.country_id, 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,4 order by 1, mid.sort_order;" | head -70 +``` +Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下三级 sort_order 连续 1..n;每个新树根 country_id 非空(国内工厂除外)。 + +抽查美国/男士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 > l2 > l3 > 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; + leafOrder: Map; // 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>` + 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)); +} +``` +(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致。) + +- [ ] **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 > category > leaf > priority" +``` + +--- + +### Task 4: categoryId 新树双通道过滤(TDD) + +**Files:** +- Modify: `apps/api/src/public/public.service.ts`(getGoods categoryId 过滤块,约 L194-196) + +**语义(DTO 完全不动,`categoryId` 仍是单个 id):** +- 传入 id 属于新树(自身/子孙存在 sds_category_id)→ 收集该节点全部子孙叶子的 `sds_category_id` 集合,过滤改为 `where.originGood.sdsCategoryId in 集合`(选二级 = 该二级下所有款) +- 传入 id 属于老树(如 658/659/666)→ 回退现行逻辑 `where.categoryId in collectCategoryDescendants(...)`(老树节点天然跨国家,行为不变) +- 新树 originGood 条件与 minPrice/maxPrice 的 originGood 条件合并为同一对象,互不覆盖 + +**真实调用形态(验收基准):** +- `?categoryId=659`(老树一级,跨国家)→ 全部女士服装商品,按 ①国家→②新树二级→③新树款→④priority +- `?categoryId=666`(老树叶子,跨国家)→ 同上 +- `?countryId=49&categoryId=658` → 英国内按 ②③④ +- 切新树后前端会传新树 id(如某国"男士T恤"或某款)→ 走 sds 通道 + +- [ ] **Step 4.1: 写失败测试** + +```ts +describe('getGoods categoryId filter (dual-path)', () => { + it('new-tree mid id filters via originGood.sdsCategoryId', async () => { + const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); + const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: midA!.id.toString() }); + const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); + expect(names.length).toBe(3); // A1Low/A1High/A2 + expect(names.some((n) => n.startsWith('A2'))).toBe(true); + expect(names.every((n) => !n.startsWith('B1'))).toBe(true); // B1 不属于 MidA + }); + + it('new-tree leaf id filters to that leaf only', async () => { + const leafA2 = await prisma.category.findFirst({ where: { categoryName: `LeafA2 ${stamp2}` } }); + const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: leafA2!.id.toString() }); + const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); + expect(names.length).toBe(1); + expect(names[0].startsWith('A2')).toBe(true); + }); + + it('categoryId + minPrice merge originGood filters', async () => { + const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); + const res = await service.getGoods({ + page: 1, pageSize: 50, categoryId: midA!.id.toString(), minPrice: '999999', + }); + expect(res.items.filter((i) => i.goodName.includes(stamp2)).length).toBe(0); + }); +}); +``` +(老树 id 路径由既有用例覆盖:原始 fixture 的分类无 sds_category_id,走回退分支,行为不变。) + +- [ ] **Step 4.2: 跑测试确认失败** + +同 Task 3 命令,`-t 'categoryId'`。Expected: FAIL(新树 mid/leaf id 走旧路径查空)。 + +- [ ] **Step 4.3: 实现** + +`getGoods` 内替换原 categoryId 过滤块: + +```ts +if (query.categoryId) { + const sds = await this.resolveSdsCategoryFilter(BigInt(query.categoryId)); + if (sds) { + where.originGood = { + ...(where.originGood as Prisma.OriginGoodWhereInput | undefined), + sdsCategoryId: { in: sds }, + }; + } else { + where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; + } +} +``` + +新增方法: + +```ts +/** + * 新树过滤:节点自身或子孙存在 sds_category_id → 返回全部子孙(含自身)的 + * sds_category_id 集合(选二级 = 其下所有款);否则返回 null(老树回退)。 + * 根/二级自身的 sds id 不出现在 origin_goods 上,多收集无害。 + */ +private async resolveSdsCategoryFilter(rootId: bigint): Promise { + const ids: bigint[] = [rootId]; + let frontier: bigint[] = [rootId]; + while (frontier.length) { + const children = await this.prisma.category.findMany({ + where: { parentCategoryId: { in: frontier } }, + select: { id: true }, + }); + frontier = children.map((c) => c.id); + ids.push(...frontier); + } + const rows = await this.prisma.category.findMany({ + where: { id: { in: ids }, sdsCategoryId: { not: null } }, + select: { sdsCategoryId: true }, + }); + const sds = rows.map((r) => r.sdsCategoryId!).filter((s) => s !== ''); + return sds.length ? sds : null; +} +``` + +- [ ] **Step 4.4: 跑全部 public 测试确认通过** + +```bash +docker run --rm ... npx jest src/public +``` +Expected: 全部 PASS(老树回退路径保证旧行为不变)。 + +- [ ] **Step 4.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): support new-tree categoryId filter via origin sds category" +``` + +--- + +### Task 5: getCategoriesTree 切换新树(TDD) + +**Files:** +- Modify: `apps/api/src/public/public.service.ts`(getCategoriesTree 约 L85-127) + +**语义:** +- 内容源:新树(根 = `parentCategoryId=null AND sdsCategoryId!=null`),老树不再返回 +- 排序:根按 `country.sortOrder`(null 最后)→ 根 sortOrder;二级/三级按各自 `sort_order` +- `countryId` 过滤:根的 `country_id` 精确匹配(不再按 goods 归属) +- productCount:叶子 = 关联 origin_goods.sds_category_id 的在售族化 goods 数(`family_id IS NOT NULL AND delisted=false`);二级/根 = 后代求和(现有 `buildTree` 语义) +- productCount=0 的二级/根剪掉(空国家不出现,如沙特在配货前不展示) + +- [ ] **Step 5.1: 写失败测试** + +```ts +describe('getCategoriesTree (new tree)', () => { + it('returns new-tree roots ordered by country sort_order with counts', async () => { + const tree = await service.getCategoriesTree(); + const names = tree.map((n) => n.categoryName); + // 新树根在返回中,老树根不在 + expect(names).toContain(`RootA ${stamp2}`); + expect(names).not.toContain(`Pub Cat ${stamp}`); // 老树 fixture(无 sds_category_id) + const rootA = tree.find((n) => n.categoryName === `RootA ${stamp2}`)!; + expect(rootA.children.map((c) => c.categoryName)).toEqual([`MidA ${stamp2}`]); + expect(rootA.children[0].children.length).toBe(2); // LeafA1/LeafA2 + expect(rootA.children[0].children[0].productCount).toBe(2); // A1Low/A1High + expect(rootA.children[0].children[1].productCount).toBe(1); // A2 + expect(rootA.productCount).toBe(3); + // 国家顺序:RootA(sort=1) 在 RootB(sort=2) 前 + expect(names.indexOf(`RootA ${stamp2}`)).toBeLessThan(names.indexOf(`RootB ${stamp2}`)); + }); + + it('countryId filters to that country root', async () => { + const cA = await prisma.country.findFirst({ where: { countryName: `TA ${stamp2}` } }); + // Task 2 回填不会给测试 fixture 关联 country_id,此处手动关联 + const rootA = await prisma.category.findFirst({ where: { categoryName: `RootA ${stamp2}` } }); + await prisma.category.update({ where: { id: rootA!.id }, data: { countryId: cA!.id } }); + const tree = await service.getCategoriesTree(cA!.id.toString()); + expect(tree.map((n) => n.categoryName)).toEqual([`RootA ${stamp2}`]); + }); +}); +``` + +注意:Task 3 fixture 创建根时未设 countryId,本 describe 的 beforeAll 中补齐:RootA→cA、RootB→cB(同上 update 方式),并给 leafA1/leafA2 的 goods 数依赖 Task 3 fixture(A1 两条、A2 一条)。若 describe 执行顺序影响 fixture 创建,将根/二级/叶子与国家的关联移入本 describe 自己的 beforeAll(按 stamp2 查询)。 + +- [ ] **Step 5.2: 跑测试确认失败** + +```bash +docker run --rm ... npx jest src/public/public.service.spec.ts -t 'new tree' +``` +Expected: FAIL(现实现返回老树,`RootA` 不在结果中)。 + +- [ ] **Step 5.3: 实现** + +`getCategoriesTree` 整体替换: + +```ts +async getCategoriesTree(countryId?: string): Promise { + const countryFilter = countryId ? { countryId: BigInt(countryId) } : {}; + const roots = await this.prisma.category.findMany({ + where: { parentCategoryId: null, sdsCategoryId: { not: null }, ...countryFilter }, + orderBy: [{ country: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }], + include: { + children: { + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { children: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }, + }, + }, + }); + + const countRows = await this.prisma.$queryRaw>` + SELECT og.sds_category_id AS sds, COUNT(*) AS cnt + FROM goods g + JOIN origin_goods og ON og.origin_good_id = g.origin_good_id + WHERE g.family_id IS NOT NULL + AND og.delisted = false + AND og.sds_category_id IS NOT NULL + ${countryId ? Prisma.sql`AND g.country_id = ${BigInt(countryId)}` : Prisma.empty} + GROUP BY og.sds_category_id`; + const counts = new Map(countRows.map((r) => [r.sds, Number(r.cnt)])); + + const build = (row: (typeof roots)[number]): PublicCategoryNodeDto => { + const children = row.children.map(build).filter((c) => c.productCount > 0); + const own = row.sdsCategoryId ? (counts.get(row.sdsCategoryId) ?? 0) : 0; + const node = PublicCategoryNodeDto.from(row, children); + node.productCount = own + children.reduce((sum, c) => sum + c.productCount, 0); + return node; + }; + return roots.map(build).filter((r) => r.productCount > 0); +} +``` +(原 `buildTree` 若无其他调用方则删除;`Prisma.sql`/`Prisma.empty` 需 import `Prisma`,已有。) + +- [ ] **Step 5.4: 跑全部 public 测试确认通过** + +```bash +docker run --rm ... npx jest src/public +``` +Expected: 全部 PASS。既有 `getCategoriesTree` 用例(老树断言)改为新树断言或按新语义重写。 + +- [ ] **Step 5.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): serve public categories tree from sds country-category tree" +``` + +--- + +### Task 6: 全量验证 + 交付 + +- [ ] **Step 6.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 6.2: verification-before-completion 自检清单** + +- [ ] `prisma migrate status` 干净(无未应用/未登记迁移) +- [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) +- [ ] 冒烟(真实调用形态):`/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 / PublicCategoryNodeDto / 分页结构)无增删 +- [ ] `git status` 干净,全部提交 + +- [ ] **Step 6.3: 合并回 refactor/v2** + +```bash +git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods tree-order + new category tree)" +git branch -d feature/category-tree-sort +``` +(本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。) + +- [ ] **Step 6.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** + +```bash +cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api +``` + +- [ ] **Step 6.5: 按项目规则沉淀** + +- 更新 `docs/references/structs.md`(categories 新字段、public 排序语义) +- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则、categoryId 多 id 用法) +- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、树形排序的「绝对排序键 + 子集过滤」模式) + +--- + +## 范围外(明确不做) + +- `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调) +- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中沉底) +- admin 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) +- 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复) From 6973f863b5551608f4cf7045038183cd1a28fdc8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 09:27:08 +0800 Subject: [PATCH 2/8] chore: ignore local db snapshots (backups/) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1876ca3..57ba706 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ deploy/data-dump.json uploads/ .pnpm-store/ data-cleaning/ -backups/.zcode/ +backups/ From 3d431f8375e8f2512bba12538bb2ab129a0a0241 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 09:36:17 +0800 Subject: [PATCH 3/8] docs(plan): add backup/rollback section, fix backfill run channel and sink semantics --- plans/feature/category-sort-feature.md | 49 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 5548ab2..6d6f629 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -24,7 +24,10 @@ - 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑 - 已有测试必须全部通过,不允许跳过 - 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status` -- 回滚保障:`backups/inkreach_snapshot_20260901_185233.dump`(全库快照已做);代码回滚 = 删 feature 分支 +- 回滚保障(均已就绪,见文末「备份与回滚」): + - DB 全库快照:`backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc) + - 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖) + - 代码回滚 = 删 feature 分支 **执行环境(宿主机无 node,统一用容器跑):** @@ -38,9 +41,11 @@ docker run --rm --network deploy-v2_default \ **排序键定义(getGoods DEFAULT):** ``` -① countries.sort_order(经 goods.country_id) 缺失 → 排最后 -② 叶子.parent_category_id 的 sort_order(二级) 缺失 → 排最后 -③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → 排最后 +① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER +② 叶子.parent_category_id 的 sort_order(二级) 缺失 → MAX_SAFE_INTEGER +③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → MAX_SAFE_INTEGER +(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树, + 它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索) ④ good_priority desc, id asc(现有字段,不变) ``` @@ -187,7 +192,7 @@ 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('/opt/inkreach-v2/排序表.md', 'utf8'); + 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 }> = []; @@ -285,10 +290,13 @@ 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' \ - -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ - npx ts-node prisma/backfill-category-sort-order.ts + -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 该行改为与库名一致后重跑;其余逐条人工核对。 @@ -693,7 +701,7 @@ Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变 - [ ] `prisma migrate status` 干净(无未应用/未登记迁移) - [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) -- [ ] 冒烟(真实调用形态):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 返回新树且沙特(空)不出现 +- [ ] (先完成 Step 6.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 / PublicCategoryNodeDto / 分页结构)无增删 - [ ] `git status` 干净,全部提交 @@ -725,3 +733,28 @@ cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --bu - 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中沉底) - admin 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) - 中东根更名后 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`(develop/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 +``` From d01c1e1b974ade1c0d23958c6daf6c9d410e737e Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:00:29 +0800 Subject: [PATCH 4/8] docs(plan): keep legacy tree as nav, treat sds leaves as style-order metadata, drop tree-switch tasks --- plans/feature/category-sort-feature.md | 388 +++++-------------------- 1 file changed, 66 insertions(+), 322 deletions(-) diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 6d6f629..6a13d2d 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -2,23 +2,22 @@ > **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」,`GET /public/categories` 内容源切换为新树(国家根→二级→三级款),支持 1/2/3 级任意筛选组合,接口输入输出 DTO 结构完全不变。 +**Goal:** `GET /public/goods` 默认排序变为「国家 → 分类(款所属二级)→ 款 → good_priority」,把排序表中每个"款"下的 1~N 个已配置商品排到该款的位置上。**分类树保持老树完全不动**,接口输入输出 DTO 结构完全不变。 -**Architecture:** `categories` 新增 `sort_order`(二/三级顺序)与 `country_id`(树根关联国家,一级顺序复用 `countries.sort_order`)。`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行);`categoryId` 仍是单个 id(DTO 不动)+ 双通道过滤(新树走 `origin_goods.sds_category_id`,老树回退 `goods.category_id`)。数据一次性回填脚本解析 `排序表.md`。 +**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. **前端调用形态(真实 URL 已确认)**:无论选中哪一层,`categoryId` 永远只传**一个**节点 id(DTO 完全不动,无逗号多 id),可选伴随 `countryId`。goods 全挂老树,老树节点天然跨国家: - - 只选老树节点:`?categoryId=659`(女士服装,跨国家)→ 按 ①国家→②新树二级→③新树款→④priority 排("美国的女士T恤全排完到下个国家") - - 选到老树叶子:`?categoryId=666`(女士/裤装,跨国家)→ 同上 - - 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③④ - - 只选国家:`?countryId=49` → 同上 - - 什么都不选 → 全局 ①②③④ - (以上全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支逻辑) -2. 款内顺序复用现有 `good_priority`(只需保证三级款之间顺序正确) -3. 「中东」改为「沙特」写入 countries;防晒衣(good_id=137) 不处理(排序沉底) -4. `/public/categories` 切换为新树内容源(结构不变);空国家/空二级(productCount=0)不返回,与旧行为、`getCountries` 口径一致。切换后前端将传新树 id(某国的二级/款),`categoryId` 过滤走双通道兼容两种 id +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 字段**一个都不能变**,只改内部逻辑 @@ -38,24 +37,25 @@ docker run --rm --network deploy-v2_default \ -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx <命令> ``` -**排序键定义(getGoods DEFAULT):** +**排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):** ``` -① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER -② 叶子.parent_category_id 的 sort_order(二级) 缺失 → MAX_SAFE_INTEGER -③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → MAX_SAFE_INTEGER -(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树, - 它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索) +① 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 + country_id +### Task 1: Schema 迁移 — categories 加 sort_order **Files:** -- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174、Country model 约 L141-154) -- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql` +- 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: 先检查迁移状态(共享库防漂移)** @@ -68,44 +68,23 @@ Expected: `Database schema is up to date!`。若报 drift/已存在类错误, - [ ] **Step 1.2: 修改 schema.prisma** -Category model:`sdsCategoryId` 行后新增两行,并新增 relation: +Category model 的 `sdsCategoryId` 行后新增: ```prisma sortOrder Int @default(0) @map("sort_order") - countryId BigInt? @map("country_id") - country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction) -``` - -Country model:`families ProductFamily[]` 后新增: - -```prisma - categories Category[] -``` - -Category model 底部 `@@index([parentCategoryId])` 后新增: - -```prisma - @@index([countryId]) ``` - [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)** ```bash -mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_fields +mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_order ``` -创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql`: +创建 `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; -ALTER TABLE "categories" ADD COLUMN "country_id" BIGINT; - --- ForeignKey -ALTER TABLE "categories" ADD CONSTRAINT "categories_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION; - --- CreateIndex -CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); ``` - [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client** @@ -114,12 +93,12 @@ CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); 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_fields/migration.sql + 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_fields + 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' \ @@ -130,39 +109,38 @@ Expected: 三条命令均成功。 - [ ] **Step 1.5: 验证列存在** ```bash -docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep -E 'sort_order|country_id' +docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep sort_order ``` -Expected: 两列均存在(`sort_order integer not null default 0`、`country_id bigint`)。 +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_fields -git commit -m "feat(api): add sort_order and country_id to categories" +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 + 沙特 + 根关联国家 +### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特 **Files:** - Create: `apps/api/prisma/backfill-category-sort-order.ts` **行为(幂等,可重复执行):** -1. 解析仓库根 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级、`### ` = 三级 -2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」+ 根重命名 `沙特本地工厂直发` -3. 根节点写入 `country_id`(关联 countries 行;`国内工厂` 无对应国家行 → 保持 null) -4. 每个根内:二级按出现顺序写 `sort_order=1..n`;三级在所属二级范围内写 `sort_order=1..n` -5. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配** -6. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1;`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填 -7. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国) +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(二/三级)+ country_id(根) - * + countries.sort_order + 沙特国家行 + 中东根更名 + * 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order + * + 沙特国家行 + 中东根更名 * 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复 */ import { PrismaClient } from '@prisma/client'; @@ -219,7 +197,6 @@ async function main() { const unmatched: string[] = []; // 1) countries:沙特 upsert + 顺序重写 - const countryIdByName = new Map(); for (let i = 0; i < countryOrder.length; i++) { const name = countryOrder[i]; const dbCountry = name === '中东' ? '沙特' : name; @@ -227,10 +204,8 @@ async function main() { const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } }); if (existing) { await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } }); - countryIdByName.set(dbCountry, existing.id); } else if (dbCountry === '沙特') { - const created = await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); - countryIdByName.set('沙特', created.id); + await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); } } @@ -241,20 +216,11 @@ async function main() { rootByName.set('沙特本地工厂直发', meRoot); } - // 3) 根节点关联 country_id - for (const [tableName, rootName] of Object.entries(ROOT_MAP)) { - const root = rootByName.get(tableName === '中东' ? '沙特本地工厂直发' : rootName); - if (!root) { unmatched.push(`ROOT MISS: ${tableName}`); continue; } - const dbCountry = tableName === '中东' ? '沙特' : tableName === '中国(国内工厂)' ? null : tableName; - const cid = dbCountry ? (countryIdByName.get(dbCountry) ?? null) : null; - await prisma.category.update({ where: { id: root.id }, data: { countryId: cid } }); - } - - // 4) 二/三级 sort_order + // 3) 二级/款 sort_order for (const countryName of countryOrder) { const rootName = ROOT_MAP[countryName]; const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName); - if (!root) continue; // 已在 ROOT MISS 记录 + 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); @@ -305,16 +271,16 @@ Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 ```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, root.country_id, mid.category_name, mid.sort_order, count(leaf.category_id) leaves +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,4 order by 1, mid.sort_order;" | head -70 +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;每个新树根 country_id 非空(国内工厂除外)。 +Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下款 sort_order 连续 1..n。 -抽查美国/男士T恤三级顺序: +抽查美国/男士T恤款顺序: ```bash docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc " @@ -372,7 +338,7 @@ describe('getGoods tree-order sorting', () => { orderedGoodIds = [a1High.id, a1Low.id, a2.id, b1.id]; }); - it('DEFAULT: country > l2 > l3 > priority', async () => { + 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)); @@ -380,7 +346,7 @@ describe('getGoods tree-order sorting', () => { 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 + expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/款顺序优先于 priority }); }); ``` @@ -447,7 +413,7 @@ if (!query.sort || query.sort === 'DEFAULT') { rows.sort((a, b) => this.compareByTreeOrder(meta, a, b)); } ``` -(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致。) +(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致;同一款下 1~N 个商品共享 ②③ 键,仅靠 ④ 分先后。) - [ ] **Step 3.4: 跑测试确认通过(含既有用例)** @@ -460,278 +426,56 @@ Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按 ```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 > category > leaf > priority" +git commit -m "feat(api): order public goods by country > mid-category > style > priority" ``` --- -### Task 4: categoryId 新树双通道过滤(TDD) +### Task 4: 全量验证 + 交付 -**Files:** -- Modify: `apps/api/src/public/public.service.ts`(getGoods categoryId 过滤块,约 L194-196) - -**语义(DTO 完全不动,`categoryId` 仍是单个 id):** -- 传入 id 属于新树(自身/子孙存在 sds_category_id)→ 收集该节点全部子孙叶子的 `sds_category_id` 集合,过滤改为 `where.originGood.sdsCategoryId in 集合`(选二级 = 该二级下所有款) -- 传入 id 属于老树(如 658/659/666)→ 回退现行逻辑 `where.categoryId in collectCategoryDescendants(...)`(老树节点天然跨国家,行为不变) -- 新树 originGood 条件与 minPrice/maxPrice 的 originGood 条件合并为同一对象,互不覆盖 - -**真实调用形态(验收基准):** -- `?categoryId=659`(老树一级,跨国家)→ 全部女士服装商品,按 ①国家→②新树二级→③新树款→④priority -- `?categoryId=666`(老树叶子,跨国家)→ 同上 -- `?countryId=49&categoryId=658` → 英国内按 ②③④ -- 切新树后前端会传新树 id(如某国"男士T恤"或某款)→ 走 sds 通道 - -- [ ] **Step 4.1: 写失败测试** - -```ts -describe('getGoods categoryId filter (dual-path)', () => { - it('new-tree mid id filters via originGood.sdsCategoryId', async () => { - const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); - const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: midA!.id.toString() }); - const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); - expect(names.length).toBe(3); // A1Low/A1High/A2 - expect(names.some((n) => n.startsWith('A2'))).toBe(true); - expect(names.every((n) => !n.startsWith('B1'))).toBe(true); // B1 不属于 MidA - }); - - it('new-tree leaf id filters to that leaf only', async () => { - const leafA2 = await prisma.category.findFirst({ where: { categoryName: `LeafA2 ${stamp2}` } }); - const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: leafA2!.id.toString() }); - const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); - expect(names.length).toBe(1); - expect(names[0].startsWith('A2')).toBe(true); - }); - - it('categoryId + minPrice merge originGood filters', async () => { - const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); - const res = await service.getGoods({ - page: 1, pageSize: 50, categoryId: midA!.id.toString(), minPrice: '999999', - }); - expect(res.items.filter((i) => i.goodName.includes(stamp2)).length).toBe(0); - }); -}); -``` -(老树 id 路径由既有用例覆盖:原始 fixture 的分类无 sds_category_id,走回退分支,行为不变。) - -- [ ] **Step 4.2: 跑测试确认失败** - -同 Task 3 命令,`-t 'categoryId'`。Expected: FAIL(新树 mid/leaf id 走旧路径查空)。 - -- [ ] **Step 4.3: 实现** - -`getGoods` 内替换原 categoryId 过滤块: - -```ts -if (query.categoryId) { - const sds = await this.resolveSdsCategoryFilter(BigInt(query.categoryId)); - if (sds) { - where.originGood = { - ...(where.originGood as Prisma.OriginGoodWhereInput | undefined), - sdsCategoryId: { in: sds }, - }; - } else { - where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; - } -} -``` - -新增方法: - -```ts -/** - * 新树过滤:节点自身或子孙存在 sds_category_id → 返回全部子孙(含自身)的 - * sds_category_id 集合(选二级 = 其下所有款);否则返回 null(老树回退)。 - * 根/二级自身的 sds id 不出现在 origin_goods 上,多收集无害。 - */ -private async resolveSdsCategoryFilter(rootId: bigint): Promise { - const ids: bigint[] = [rootId]; - let frontier: bigint[] = [rootId]; - while (frontier.length) { - const children = await this.prisma.category.findMany({ - where: { parentCategoryId: { in: frontier } }, - select: { id: true }, - }); - frontier = children.map((c) => c.id); - ids.push(...frontier); - } - const rows = await this.prisma.category.findMany({ - where: { id: { in: ids }, sdsCategoryId: { not: null } }, - select: { sdsCategoryId: true }, - }); - const sds = rows.map((r) => r.sdsCategoryId!).filter((s) => s !== ''); - return sds.length ? sds : null; -} -``` - -- [ ] **Step 4.4: 跑全部 public 测试确认通过** - -```bash -docker run --rm ... npx jest src/public -``` -Expected: 全部 PASS(老树回退路径保证旧行为不变)。 - -- [ ] **Step 4.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): support new-tree categoryId filter via origin sds category" -``` - ---- - -### Task 5: getCategoriesTree 切换新树(TDD) - -**Files:** -- Modify: `apps/api/src/public/public.service.ts`(getCategoriesTree 约 L85-127) - -**语义:** -- 内容源:新树(根 = `parentCategoryId=null AND sdsCategoryId!=null`),老树不再返回 -- 排序:根按 `country.sortOrder`(null 最后)→ 根 sortOrder;二级/三级按各自 `sort_order` -- `countryId` 过滤:根的 `country_id` 精确匹配(不再按 goods 归属) -- productCount:叶子 = 关联 origin_goods.sds_category_id 的在售族化 goods 数(`family_id IS NOT NULL AND delisted=false`);二级/根 = 后代求和(现有 `buildTree` 语义) -- productCount=0 的二级/根剪掉(空国家不出现,如沙特在配货前不展示) - -- [ ] **Step 5.1: 写失败测试** - -```ts -describe('getCategoriesTree (new tree)', () => { - it('returns new-tree roots ordered by country sort_order with counts', async () => { - const tree = await service.getCategoriesTree(); - const names = tree.map((n) => n.categoryName); - // 新树根在返回中,老树根不在 - expect(names).toContain(`RootA ${stamp2}`); - expect(names).not.toContain(`Pub Cat ${stamp}`); // 老树 fixture(无 sds_category_id) - const rootA = tree.find((n) => n.categoryName === `RootA ${stamp2}`)!; - expect(rootA.children.map((c) => c.categoryName)).toEqual([`MidA ${stamp2}`]); - expect(rootA.children[0].children.length).toBe(2); // LeafA1/LeafA2 - expect(rootA.children[0].children[0].productCount).toBe(2); // A1Low/A1High - expect(rootA.children[0].children[1].productCount).toBe(1); // A2 - expect(rootA.productCount).toBe(3); - // 国家顺序:RootA(sort=1) 在 RootB(sort=2) 前 - expect(names.indexOf(`RootA ${stamp2}`)).toBeLessThan(names.indexOf(`RootB ${stamp2}`)); - }); - - it('countryId filters to that country root', async () => { - const cA = await prisma.country.findFirst({ where: { countryName: `TA ${stamp2}` } }); - // Task 2 回填不会给测试 fixture 关联 country_id,此处手动关联 - const rootA = await prisma.category.findFirst({ where: { categoryName: `RootA ${stamp2}` } }); - await prisma.category.update({ where: { id: rootA!.id }, data: { countryId: cA!.id } }); - const tree = await service.getCategoriesTree(cA!.id.toString()); - expect(tree.map((n) => n.categoryName)).toEqual([`RootA ${stamp2}`]); - }); -}); -``` - -注意:Task 3 fixture 创建根时未设 countryId,本 describe 的 beforeAll 中补齐:RootA→cA、RootB→cB(同上 update 方式),并给 leafA1/leafA2 的 goods 数依赖 Task 3 fixture(A1 两条、A2 一条)。若 describe 执行顺序影响 fixture 创建,将根/二级/叶子与国家的关联移入本 describe 自己的 beforeAll(按 stamp2 查询)。 - -- [ ] **Step 5.2: 跑测试确认失败** - -```bash -docker run --rm ... npx jest src/public/public.service.spec.ts -t 'new tree' -``` -Expected: FAIL(现实现返回老树,`RootA` 不在结果中)。 - -- [ ] **Step 5.3: 实现** - -`getCategoriesTree` 整体替换: - -```ts -async getCategoriesTree(countryId?: string): Promise { - const countryFilter = countryId ? { countryId: BigInt(countryId) } : {}; - const roots = await this.prisma.category.findMany({ - where: { parentCategoryId: null, sdsCategoryId: { not: null }, ...countryFilter }, - orderBy: [{ country: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }], - include: { - children: { - orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], - include: { children: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }, - }, - }, - }); - - const countRows = await this.prisma.$queryRaw>` - SELECT og.sds_category_id AS sds, COUNT(*) AS cnt - FROM goods g - JOIN origin_goods og ON og.origin_good_id = g.origin_good_id - WHERE g.family_id IS NOT NULL - AND og.delisted = false - AND og.sds_category_id IS NOT NULL - ${countryId ? Prisma.sql`AND g.country_id = ${BigInt(countryId)}` : Prisma.empty} - GROUP BY og.sds_category_id`; - const counts = new Map(countRows.map((r) => [r.sds, Number(r.cnt)])); - - const build = (row: (typeof roots)[number]): PublicCategoryNodeDto => { - const children = row.children.map(build).filter((c) => c.productCount > 0); - const own = row.sdsCategoryId ? (counts.get(row.sdsCategoryId) ?? 0) : 0; - const node = PublicCategoryNodeDto.from(row, children); - node.productCount = own + children.reduce((sum, c) => sum + c.productCount, 0); - return node; - }; - return roots.map(build).filter((r) => r.productCount > 0); -} -``` -(原 `buildTree` 若无其他调用方则删除;`Prisma.sql`/`Prisma.empty` 需 import `Prisma`,已有。) - -- [ ] **Step 5.4: 跑全部 public 测试确认通过** - -```bash -docker run --rm ... npx jest src/public -``` -Expected: 全部 PASS。既有 `getCategoriesTree` 用例(老树断言)改为新树断言或按新语义重写。 - -- [ ] **Step 5.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): serve public categories tree from sds country-category tree" -``` - ---- - -### Task 6: 全量验证 + 交付 - -- [ ] **Step 6.1: api 全量测试** +- [ ] **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 6.2: verification-before-completion 自检清单** +- [ ] **Step 4.2: verification-before-completion 自检清单** - [ ] `prisma migrate status` 干净(无未应用/未登记迁移) - [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) -- [ ] (先完成 Step 6.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 / PublicCategoryNodeDto / 分页结构)无增删 +- [ ] 冒烟(真实调用形态,先完成 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 6.3: 合并回 refactor/v2** +- [ ] **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 tree-order + new category tree)" +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 6.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** +- [ ] **Step 4.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** ```bash cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api ``` -- [ ] **Step 6.5: 按项目规则沉淀** +- [ ] **Step 4.5: 按项目规则沉淀** -- 更新 `docs/references/structs.md`(categories 新字段、public 排序语义) -- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则、categoryId 多 id 用法) -- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、树形排序的「绝对排序键 + 子集过滤」模式) +- 更新 `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 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) +- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中日本组内沉底) +- admin 后台款顺序拖拽管理(本期用脚本回填;后续如需可视化调整再立项) - 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复) --- @@ -742,7 +486,7 @@ cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --bu |---|---|---| | 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`(develop/refactor-v2 未动) | 实时 | +| 代码 | 分支 `feature/category-tree-sort`(refactor/v2 未动) | 实时 | **DB 恢复(覆盖式,先停 api 容器避免写入竞争):** From dc90712c4f35b2c5099a8ba57112faacf2fcf610 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:16:29 +0800 Subject: [PATCH 5/8] feat(api): add sort_order column to categories --- .../20260901000000_add_category_sort_order/migration.sql | 2 ++ apps/api/prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql diff --git a/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql b/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql new file mode 100644 index 0000000..67ec1e2 --- /dev/null +++ b/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2833af3..489ad57 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) From 3ccddc59824190cfc0bdb4b4b23ae72e3808f3bf Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:22:49 +0800 Subject: [PATCH 6/8] feat(api): backfill category/country sort order from reference table --- .../prisma/backfill-category-sort-order.ts | 133 ++++++++++++++++++ 排序表.md | 6 +- 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/backfill-category-sort-order.ts diff --git a/apps/api/prisma/backfill-category-sort-order.ts b/apps/api/prisma/backfill-category-sort-order.ts new file mode 100644 index 0000000..27ba60e --- /dev/null +++ b/apps/api/prisma/backfill-category-sort-order.ts @@ -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 = { + '美国': '美国工厂直发', + '英国': '英国本地直发', + '日本': '日本本地工厂直发', + '墨西哥': '墨西哥工厂本地直发', + '巴西': '巴西本地工厂直发', + '中东': '中东本地工厂直发', // 同时更名沙特 + '波兰': '欧洲波兰工厂直发', + '西班牙': '欧洲西班牙工厂直发', + '德国': '欧洲德国工厂本地直发', + '意大利': '欧洲意大利工厂直发', + '加拿大': '加拿大本地工厂直发', + '澳大利亚': '澳大利亚本地工厂直发', + '韩国': '韩国本地直发', + '中国(国内工厂)': '国内工厂', +}; + +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()); diff --git a/排序表.md b/排序表.md index 1674419..740b944 100644 --- a/排序表.md +++ b/排序表.md @@ -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女士无痕内衣 ## 家居 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 7/8] 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 端展开聚合只回传 From 76eb849b492eb7d74678f46d4585c322e39f5c79 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:33:54 +0800 Subject: [PATCH 8/8] docs: record style-order semantics, container-run pitfalls and lessons --- AGENTS.md | 6 +++++- docs/references/structs.md | 2 +- plans/feature/category-sort-feature.md | 12 +++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86cbe70..17417f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,4 +95,8 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied ` 把已存在的迁移标记为已应用,再 `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 逻辑可靠得多。 diff --git a/docs/references/structs.md b/docs/references/structs.md index a490f82..d58744c 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -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 | diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 6a13d2d..26f7ca0 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -28,13 +28,19 @@ - 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖) - 代码回滚 = 删 feature 分支 -**执行环境(宿主机无 node,统一用容器跑):** +**执行环境(宿主机无 node;已验证的正确方式——挂仓库根 + 复用运行镜像):** ```bash -# 测试 / prisma 命令统一模板(挂载源码 + 加入 postgres 网络) +# ⚠️ 两个坑(实测踩过): +# 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/apps/api:/app -w /app node:20-alpine npx <命令> + -v /opt/inkreach-v2:/repo -w /repo/apps/api deploy-v2-api npx +# 只读 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 定位到款):**