Files
inkreach-official-website/plans/feature/category-sort-feature.md
T

36 KiB
Raw Blame History

分类排序(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
  • 回滚保障(均已就绪,见文末「备份与回滚」):
    • DB 全库快照:backups/inkreach_snapshot_20260901_185233.dumppg_dump -Fc
    • 运行容器快照:镜像 inkreach-api-snapshot:20260901docker commit 自 deploy-v2-api-1,含当前代码与依赖)
    • 代码回滚 = 删 feature 分支

执行环境(宿主机无 node,统一用容器跑):

# 测试 / 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             缺失 → 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(现有字段,不变)

Task 1: Schema 迁移 — categories 加 sort_order + country_id

Files:

  • Modify: apps/api/prisma/schema.prismaCategory model 约 L157-174、Country model 约 L141-154

  • Create: apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql

  • Step 1.1: 先检查迁移状态(共享库防漂移)

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 modelsdsCategoryId 行后新增两行,并新增 relation:

  sortOrder        Int      @default(0) @map("sort_order")
  countryId        BigInt?  @map("country_id")
  country          Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)

Country modelfamilies ProductFamily[] 后新增:

  categories Category[]

Category model 底部 @@index([parentCategoryId]) 后新增:

  @@index([countryId])
  • Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)
mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_fields

创建 apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.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
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: 验证列存在
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 0country_id bigint)。

  • Step 1.6: Commit
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: 编写脚本
/**
 * 一次性回填:解析 排序表.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<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 + 顺序重写
  const countryIdByName = new Map<string, bigint>();
  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: 执行脚本
# 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 验证回填结果
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恤三级顺序:

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
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.tsgetGoods 约 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 追加:

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 descB 的 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: 跑测试确认失败
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 descB1 会排最前)。

  • Step 3.3: 实现

public.service.ts

  1. PUBLIC_GOOD_LIST_INCLUDE.originGood.select 增加 sdsCategoryId: true

  2. 新增类型与私有方法:

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;
}
  1. getGoodsDEFAULT 分支的 DB orderBy 改为 [{ id: 'asc' }](排序移内存),findMany 之后、分组之前插入:
if (!query.sort || query.sort === 'DEFAULT') {
  const meta = await this.loadTreeOrderMeta();
  rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
}

(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致。)

  • Step 3.4: 跑测试确认通过(含既有用例)
docker run --rm ... npx jest src/public/public.service.spec.ts

Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按新规则修正期望;fixture 多为同国家同分类,一般不受影响)。

  • Step 3.5: Commit
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.tsgetGoods 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: 写失败测试

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 过滤块:

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)) };
  }
}

新增方法:

/**
 * 新树过滤:节点自身或子孙存在 sds_category_id → 返回全部子孙(含自身)的
 * sds_category_id 集合(选二级 = 其下所有款);否则返回 null(老树回退)。
 * 根/二级自身的 sds id 不出现在 origin_goods 上,多收集无害。
 */
private async resolveSdsCategoryFilter(rootId: bigint): Promise<string[] | null> {
  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 测试确认通过
docker run --rm ... npx jest src/public

Expected: 全部 PASS(老树回退路径保证旧行为不变)。

  • Step 4.5: Commit
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.tsgetCategoriesTree 约 L85-127

语义:

  • 内容源:新树(根 = parentCategoryId=null AND sdsCategoryId!=null),老树不再返回

  • 排序:根按 country.sortOrdernull 最后)→ 根 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: 写失败测试

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 fixtureA1 两条、A2 一条)。若 describe 执行顺序影响 fixture 创建,将根/二级/叶子与国家的关联移入本 describe 自己的 beforeAll(按 stamp2 查询)。

  • Step 5.2: 跑测试确认失败
docker run --rm ... npx jest src/public/public.service.spec.ts -t 'new tree'

Expected: FAIL(现实现返回老树,RootA 不在结果中)。

  • Step 5.3: 实现

getCategoriesTree 整体替换:

async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
  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<Array<{ sds: string; cnt: bigint | number }>>`
    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 测试确认通过
docker run --rm ... npx jest src/public

Expected: 全部 PASS。既有 getCategoriesTree 用例(老树断言)改为新树断言或按新语义重写。

  • Step 5.5: Commit
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 全量测试
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 清单为空(或已逐条人工处理并记录)

  • (先完成 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 干净,全部提交

  • Step 6.3: 合并回 refactor/v2

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 容器使新逻辑生效(部署步骤,执行前向用户确认)
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api
  • Step 6.5: 按项目规则沉淀

  • 更新 docs/references/structs.mdcategories 新字段、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 同步覆盖名称的持久对抗(重跑回填脚本即可恢复)

备份与回滚(已就绪)

资产 位置 时间
DB 全库快照 backups/inkreach_snapshot_20260901_185233.dumppg_dump -Fc3.4M 2026-09-01
运行容器快照 docker 镜像 inkreach-api-snapshot:20260901711MBcommit 自 deploy-v2-api-1 2026-09-01
代码 分支 feature/category-tree-sortdevelop/refactor-v2 未动) 实时

DB 恢复(覆盖式,先停 api 容器避免写入竞争):

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 容器恢复(快照镜像另起实例做比对/应急):

docker run -d --name deploy-v2-api-snapshot --network deploy-v2_default inkreach-api-snapshot:20260901