merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干)

This commit is contained in:
yeuimu
2026-09-03 01:54:32 +08:00
22 changed files with 1996 additions and 444 deletions
+510
View File
@@ -0,0 +1,510 @@
# 分类排序(category-tree-sort)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `GET /public/goods` 默认排序变为「国家 → 分类(款所属二级)→ 款 → good_priority」,把排序表中每个"款"下的 1~N 个已配置商品排到该款的位置上。**分类树保持老树完全不动**,接口输入输出 DTO 结构完全不变。
**Architecture:** 领域模型:老树(男士服装→T恤)是正式分类体系,小程序导航与 `categoryId` 过滤全部照旧;新树(国家根→男士T恤→DG001…)的二级/三级节点**只作为"款排序元数据层"**——"款"是合并后的具体衣服(一个款经 family 合并映射 1~N 个源商品/已配置商品)。`categories` 新增 `sort_order` 承载款顺序;`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行),商品经 `origin_goods.sds_category_id` 找到所属款。数据一次性回填脚本解析 `排序表.md`
**Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试)
**已确认的决策:**
1. **分类树不动**`/public/categories` 继续返回老树,老树分类是对的
2. **款 = 排序单元**:排序表的三级(DG001…)是款,不是分类;一个款在"已配置"里有 1~多个商品(同款不同工艺/物流拆分的,经 family 合并),它们排在一起、占该款的位置(如 DG004 的商品整体排在 DG001 后面)
3. **前端调用形态(真实 URL 已确认)**`categoryId` 永远只传一个老树节点 id,可选伴随 `countryId`DTO 不动:
- 只选分类:`?categoryId=659`(女士服装,跨国家)→ ①国家→②款顺序→③priority("美国的女士T恤各款排完到下个国家")
- 选到叶子:`?categoryId=666` → 同上
- 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③
- 什么都不选 → 全局 ①②③
(全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支)
4. 「中东」改为「沙特」写入 countries(需求方口径);新树根改名「沙特本地工厂直发」;防晒衣(good_id=137) 的款不在树中(sds_category_id=2393 无对应节点),排序在日本组内沉底,不修复
**硬性约束:**
- 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑
- 已有测试必须全部通过,不允许跳过
- 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status`
- 回滚保障(均已就绪,见文末「备份与回滚」):
- DB 全库快照:`backups/inkreach_snapshot_20260901_185233.dump`pg_dump -Fc
- 运行容器快照:镜像 `inkreach-api-snapshot:20260901`docker commit 自 deploy-v2-api-1,含当前代码与依赖)
- 代码回滚 = 删 feature 分支
**执行环境(宿主机无 node;已验证的正确方式——挂仓库根 + 复用运行镜像):**
```bash
# ⚠️ 两个坑(实测踩过):
# 1) pnpm 的 node_modules 是相对符号链接指向根 .pnpm store
# 只挂 apps/api:/app 会断链(Cannot find module)——必须挂整个仓库根
# 2) node:20-alpine 缺 libssl1.1Prisma engine 加载失败——
# 直接用项目运行镜像 deploy-v2-apilibssl 已匹配)
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-v /opt/inkreach-v2:/repo -w /repo/apps/api deploy-v2-api npx <prisma|jest|tsc ...>
# 只读 prisma 元数据(migrate status 等)也可轻量挂载:
# -v /opt/inkreach-v2/apps/api/prisma:/app/prisma deploy-v2-api npx prisma migrate status
```
**排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):**
```
① countries.sort_order(经 goods.country_id 缺失 → MAX_SAFE_INTEGER
② 款所属二级(如"男士T恤")的 sort_order 缺失 → MAX_SAFE_INTEGER
③ 款自身的 sort_order 缺失 → MAX_SAFE_INTEGER
④ good_priority desc, id asc(现有字段,不变)
```
(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树,
它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索。
同一款下 1~N 个商品(单面/双面印花等)共享 ②③,仅靠 ④ 分先后,自然聚在一起。)
---
### Task 1: Schema 迁移 — categories 加 sort_order
**Files:**
- Modify: `apps/api/prisma/schema.prisma`Category model 约 L157-174
- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`
- [ ] **Step 1.1: 先检查迁移状态(共享库防漂移)**
```bash
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma migrate status
```
Expected: `Database schema is up to date!`。若报 drift/已存在类错误,按 AGENTS.md 经验用 `migrate resolve` 处理,禁止 reset。
- [ ] **Step 1.2: 修改 schema.prisma**
Category model 的 `sdsCategoryId` 行后新增:
```prisma
sortOrder Int @default(0) @map("sort_order")
```
- [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)**
```bash
mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_order
```
创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`
```sql
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
```
- [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client**
```bash
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_order/migration.sql
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
npx prisma migrate resolve --applied 20260901000000_add_category_sort_order
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma generate
```
Expected: 三条命令均成功。
- [ ] **Step 1.5: 验证列存在**
```bash
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep sort_order
```
Expected: `sort_order | integer | | not null | 0`
- [ ] **Step 1.6: Commit**
```bash
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_order
git commit -m "feat(api): add sort_order column to categories"
```
---
### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特
**Files:**
- Create: `apps/api/prisma/backfill-category-sort-order.ts`
**行为(幂等,可重复执行):**
1. 解析 `排序表.md``# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级(男士T恤等)、`### ` = 款
2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」(sort_order=表内顺序 6)+ 根重命名「沙特本地工厂直发」
3. 每个根内:二级按出现顺序写 `sort_order=1..n`;款在所属二级范围内写 `sort_order=1..n`
4. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配**
5. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1(人工对齐排序表/库名后重跑);`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填
6. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国)
- [ ] **Step 2.1: 编写脚本**
```ts
/**
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
* + 沙特国家行 + 中东根更名
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
*/
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
const prisma = new PrismaClient();
// 排序表国家名 → 新树根分类名
const ROOT_MAP: Record<string, string> = {
'美国': '美国工厂直发',
'英国': '英国本地直发',
'日本': '日本本地工厂直发',
'墨西哥': '墨西哥工厂本地直发',
'巴西': '巴西本地工厂直发',
'中东': '中东本地工厂直发', // 同时更名沙特
'波兰': '欧洲波兰工厂直发',
'西班牙': '欧洲西班牙工厂直发',
'德国': '欧洲德国工厂本地直发',
'意大利': '欧洲意大利工厂直发',
'加拿大': '加拿大本地工厂直发',
'澳大利亚': '澳大利亚本地工厂直发',
'韩国': '韩国本地直发',
'中国(国内工厂)': '国内工厂',
};
const norm = (s: string) => s.trim().replace(/\s+/g, '');
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
async function main() {
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
let country: string | null = null;
let l2: string | null = null;
const tree: Array<{ country: string; l2: string; l3: string }> = [];
const countryOrder: string[] = [];
for (const line of raw.split('\n')) {
const t = line.trim();
if (t.startsWith('# ')) {
const name = t.slice(2).trim();
if (name === '全部' || name.includes('工厂直发国家')) continue;
country = name;
if (!countryOrder.includes(name)) countryOrder.push(name);
} else if (t.startsWith('## ') && country) {
l2 = t.slice(3).trim();
} else if (t.startsWith('### ') && country && l2) {
tree.push({ country, l2, l3: t.slice(4).trim() });
}
}
const roots = await prisma.category.findMany({
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
include: { children: { include: { children: true } } },
});
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
const unmatched: string[] = [];
// 1) countries:沙特 upsert + 顺序重写
for (let i = 0; i < countryOrder.length; i++) {
const name = countryOrder[i];
const dbCountry = name === '中东' ? '沙特' : name;
const sortOrder = i + 1;
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
if (existing) {
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
} else if (dbCountry === '沙特') {
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
}
}
// 2) 中东根 → 沙特
const meRoot = rootByName.get('中东本地工厂直发');
if (meRoot) {
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
rootByName.set('沙特本地工厂直发', meRoot);
}
// 3) 二级/款 sort_order
for (const countryName of countryOrder) {
const rootName = ROOT_MAP[countryName];
const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName);
if (!root) { unmatched.push(`ROOT MISS: ${countryName}`); continue; }
const l2s = root.children;
const l2NamesInOrder: string[] = [];
for (const row of tree) if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
for (let i = 0; i < l2NamesInOrder.length; i++) {
const target = l2NamesInOrder[i];
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
if (!mid) { unmatched.push(`L2 MISS: ${countryName} / ${target}`); continue; }
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
const leaves = mid.children;
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
for (let j = 0; j < l3Names.length; j++) {
const want = norm(l3Names[j]);
const code = norm(codeOf(l3Names[j]));
const leaf =
leaves.find((l) => norm(l.categoryName) === want) ??
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
if (!leaf) { unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`); continue; }
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
}
}
}
if (unmatched.length) {
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
process.exit(1);
}
console.log('backfill done');
}
main().finally(() => prisma.$disconnect());
```
- [ ] **Step 2.2: 执行脚本**
```bash
# ts-node 不在 apps/api 依赖中,用 tsc 编译后以 node 运行;
# 仓库根只读挂载到 /repo 以读取排序表.md
docker run --rm --network deploy-v2_default \
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
-e SORT_TABLE_PATH=/repo/排序表.md \
-v /opt/inkreach-v2:/repo:ro -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
sh -c "npx tsc prisma/backfill-category-sort-order.ts --module commonjs --target es2020 --esModuleInterop --skipLibCheck --outDir /tmp/bf && node /tmp/bf/backfill-category-sort-order.js"
```
Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 `DG170G170G女士无痕三角内裤`(库内为 `DG701 170G女士无痕三角内裤`,货号前缀 `DG170G170G` 不匹配)→ 将排序表.md 该行改为与库名一致后重跑;其余逐条人工核对。
- [ ] **Step 2.3: SQL 验证回填结果**
```bash
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "
select country_name, sort_order from countries order by sort_order;" -c "
select root.category_name, mid.category_name, mid.sort_order, count(leaf.category_id) leaves
from categories root
join categories mid on mid.parent_category_id = root.category_id
left join categories leaf on leaf.parent_category_id = mid.category_id
where root.parent_category_id is null and root.sds_category_id is not null
group by 1,2,3 order by 1, mid.sort_order;" | head -70
```
Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下款 sort_order 连续 1..n。
抽查美国/男士T恤款顺序:
```bash
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc "
select category_name || ' | ' || sort_order from categories
where parent_category_id = (select category_id from categories where category_name='男士T恤' and parent_category_id=(select category_id from categories where category_name='美国工厂直发'))
order by sort_order;" | head -8
```
Expected: 第一行 `DG001 180G纯棉T恤 JSA002 | 1`,第二行 `DG004 230G水洗T恤(JSA003 | 2`
- [ ] **Step 2.4: Commit**
```bash
git add apps/api/prisma/backfill-category-sort-order.ts
git commit -m "feat(api): backfill category/country sort order from reference table"
```
---
### Task 3: getGoods 默认排序(TDD
**Files:**
- Modify: `apps/api/src/public/public.service.ts`getGoods 约 L186-271、PUBLIC_GOOD_LIST_INCLUDE 约 L68-77
- Test: `apps/api/src/public/public.service.spec.ts`(追加 describe,遵循现有真实库集成测试风格,fixture 带 `stamp` 唯一化)
- [ ] **Step 3.1: 写失败测试**
`public.service.spec.ts` 追加:
```ts
describe('getGoods tree-order sorting', () => {
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1,2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
// 期望默认顺序: A款1 -> A款2 -> B款1,同款内 priority 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 > mid > leaf > priority', async () => {
const res = await service.getGoods({ page: 1, pageSize: 50 });
const idx = res.items.map((i) => BigInt(i.goodId));
const pos = orderedGoodIds.map((id) => idx.indexOf(id));
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
expect(idx.indexOf(orderedGoodIds[0])).toBeLessThan(idx.indexOf(orderedGoodIds[1])); // 同款内 priority desc
expect(idx.indexOf(orderedGoodIds[1])).toBeLessThan(idx.indexOf(orderedGoodIds[2])); // 款顺序
expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/款顺序优先于 priority
});
});
```
- [ ] **Step 3.2: 跑测试确认失败**
```bash
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest src/public/public.service.spec.ts -t 'tree-order'
```
Expected: FAIL(现顺序按 goodPriority descB1 会排最前)。
- [ ] **Step 3.3: 实现**
`public.service.ts`
1) `PUBLIC_GOOD_LIST_INCLUDE.originGood.select` 增加 `sdsCategoryId: true`
2) 新增类型与私有方法:
```ts
interface TreeOrderMeta {
countryOrder: Map<string, number>;
leafOrder: Map<string, { c2: number; c3: number }>; // key: origin_goods.sds_category_id
}
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
const [countries, leaves] = await Promise.all([
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
this.prisma.$queryRaw<Array<{ sds_category_id: string; c2: bigint | number; c3: bigint | number }>>`
SELECT leaf.sds_category_id,
COALESCE(mid.sort_order, 2147483647) AS c2,
COALESCE(leaf.sort_order, 2147483647) AS c3
FROM categories leaf
JOIN categories mid ON mid.category_id = leaf.parent_category_id
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''`,
]);
return {
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
leafOrder: new Map(leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }])),
};
}
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
const MAX = Number.MAX_SAFE_INTEGER;
const key = (g: PublicGoodListRow): [number, number, number, number, number] => [
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c2 ?? MAX,
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c3 ?? MAX,
-g.goodPriority,
Number(g.id),
];
const ka = key(a);
const kb = key(b);
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
return 0;
}
```
3) `getGoods`DEFAULT 分支的 DB `orderBy` 改为 `[{ id: 'asc' }]`(排序移内存),`findMany` 之后、分组之前插入:
```ts
if (!query.sort || query.sort === 'DEFAULT') {
const meta = await this.loadTreeOrderMeta();
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
}
```
(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致;同一款下 1~N 个商品共享 ②③ 键,仅靠 ④ 分先后。)
- [ ] **Step 3.4: 跑测试确认通过(含既有用例)**
```bash
docker run --rm ... npx jest src/public/public.service.spec.ts
```
Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按新规则修正期望;fixture 多为同国家同分类,一般不受影响)。
- [ ] **Step 3.5: Commit**
```bash
git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts
git commit -m "feat(api): order public goods by country > mid-category > style > priority"
```
---
### Task 4: 全量验证 + 交付
- [ ] **Step 4.1: api 全量测试**
```bash
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest
```
Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变更波及的用例)。
- [ ] **Step 4.2: verification-before-completion 自检清单**
- [ ] `prisma migrate status` 干净(无未应用/未登记迁移)
- [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录)
- [ ] 冒烟(真实调用形态,先完成 Step 4.4 重建容器):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 输出与改造前完全一致(老树,结构内容均不变)
- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / 分页结构)无增删
- [ ] `git status` 干净,全部提交
- [ ] **Step 4.3: 合并回 refactor/v2**
```bash
git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods style-order sorting)"
git branch -d feature/category-tree-sort
```
(本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。)
- [ ] **Step 4.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)**
```bash
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api
```
- [ ] **Step 4.5: 按项目规则沉淀**
- 更新 `docs/references/structs.md`categories.sort_order 新字段、public 排序语义、款/family 领域说明)
- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则)
- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、「绝对排序键 + 子集过滤」模式、排序表驱动回填的幂等脚本设计)
---
## 范围外(明确不做)
- **分类树接口与导航**(老树不动;新树仅作款排序元数据层)
- `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调)
- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中日本组内沉底)
- admin 后台款顺序拖拽管理(本期用脚本回填;后续如需可视化调整再立项)
- 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复)
---
## 备份与回滚(已就绪)
| 资产 | 位置 | 时间 |
|---|---|---|
| DB 全库快照 | `backups/inkreach_snapshot_20260901_185233.dump`pg_dump -Fc3.4M | 2026-09-01 |
| 运行容器快照 | docker 镜像 `inkreach-api-snapshot:20260901`711MBcommit 自 deploy-v2-api-1 | 2026-09-01 |
| 代码 | 分支 `feature/category-tree-sort`refactor/v2 未动) | 实时 |
**DB 恢复(覆盖式,先停 api 容器避免写入竞争):**
```bash
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml stop api
docker exec -i deploy-v2-postgres-1 pg_restore -U inkreach -d inkreach --clean --if-exists \
< /opt/inkreach-v2/backups/inkreach_snapshot_20260901_185233.dump
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml start api
```
**api 容器恢复(快照镜像另起实例做比对/应急):**
```bash
docker run -d --name deploy-v2-api-snapshot --network deploy-v2_default inkreach-api-snapshot:20260901
```
@@ -0,0 +1,49 @@
# Feature:印刷工艺新增"热转印"选项
日期:2026-09-02
类型:新功能(feature
影响面:`apps/api/src/product-families/family-recompute.service.ts`(两处词表常量)+ 测试 + `tags` 表新增一行
## 背景
业务需要新增印刷工艺"热转印"。标签由 `tags` 表驱动(`GET /tags` 全量返回,管理端打标用),但价格矩阵的维度词表在后端硬编码,需同步放开准入。
## 现状(2026-09-02 分析确认)
- `tags` 表:印刷工艺组(tag_group_id=3)现有 烫画(1)/直喷(2)/不打印(3),有 `POST /tags` 接口(JwtAuthGuard,重名 409
- 矩阵维度词表 [DIM_VALUES.craft](file:///opt/inkreach-v2/apps/api/src/product-families/family-recompute.service.ts) = `['烫画','直喷','不打印']` 硬编码,`memberMatrixCombos` 按它过滤链接标签——词表外的标签会导致链接不进矩阵
- 展示序词表 `OPTION_DISPLAY_ORDER.craft` 同样硬编码,词表外沉底
- 名称自动派生规则(`auto-tag-rules.ts` / admin `origin-name.ts`)**本次不改**:SDS 链接不会自动派生热转印,只能人工打标
## 方案
1. `DIM_VALUES.craft` 追加 `'热转印'`(末尾)——矩阵认可该工艺标签,SDS/CUSTOM 链接人工打标后均可进矩阵
2. `OPTION_DISPLAY_ORDER.craft` 追加 `'热转印'`(末尾)——按钮组显示在 不打印 之后(业务确认排序表先不改,放最后)
3. `POST /tags { tagName: '热转印', tagGroupId: '3', sortOrder: 4 }` 创建标签行(管理端标签栏立即可见/可选)
4. **不给任何存量链接打标**——纯增量能力,存量族矩阵零变化
## 明确不做
- 不改自动派生规则(名称含"热转印"仍派生默认烫画,如需后续再议)
- 不给存量链接打标、不重算存量族
- 不动 自建商品 craftLabel 维度填错的历史数据
## 测试计划(TDD
测试文件:`family-recompute.service.spec.ts`memberMatrixCombos / derivePriceMatrix 纯函数)
1. `memberMatrixCombos`:链接标签 [单面印花, 热转印, 包邮] → 产出 `{ printCount:'单面印花', craft:'热转印', logistics:'包邮' }`(修复前被词表过滤 → combos 为空,RED
2. `derivePriceMatrix`crafts 输出 `烫画→直喷→不打印→热转印`(热转印作为词表内值参与排序,不再沉底)
3. 既有"未知自由文本沉底"用例(丝印/水洗)仍应沉底——热转印是词表内,丝印/水洗仍是词表外
4. 全量 jest + tsc 通过
## 线上验证
1. `GET /tags` 返回 热转印(tagGroupId=3, sortOrder=4
2. 抽查存量族 priceMatrix.crafts 与改动前一致(无新增值)
3. 无链接打标 → 公开详情 crafts 不含 热转印
## 回滚
- 代码:revert 两行常量(未打标状态零影响)
- 标签:无引用可直接删(`origin_good_tags` 无该行)
+57
View File
@@ -0,0 +1,57 @@
# Fix:价格矩阵选项组按词表序输出(印花/工艺/物流按钮顺序稳定)
日期:2026-09-02
类型:Bug 修复(fix
影响面:`apps/api/src/product-families/family-recompute.service.ts` 及其测试、一次性全库重算
## 背景与问题
详情接口 `family.priceMatrix``printCounts / crafts / logistics` 是物化进 JSONB 的字符串数组,H5 按数组原序渲染印花/工艺/物流按钮组(无前端排序)。数组顺序 = 重算时 Set 去重的"首次遇到序",且成员查询 `originGoods``orderBy`,导致:
1. 每个族的按钮顺序不一致(实测 goodId=1 为 `['双面印花','单面印花']``['不包邮','包邮']`,与词表相反);
2. 同一族两次重算顺序理论上可能漂移(Postgres 无顺序保证)。
对比:尺码/颜色按钮组有 `variants.sortOrder`,顺序稳定;本修复把印花/工艺/物流对齐到同样可预期。
## 目标顺序(业务确认)
| 维度 | 展示顺序 |
| --- | --- |
| printCount | 单面印花 → 双面印花 |
| craft | 烫画 → 直喷 → 不打印 |
| logistics | 不包邮 → 包邮 |
无论族内实际出现哪个子集,相对顺序恒定;词表外自由文本(如 CUSTOM 的"海运")沉到最后,相互间保持首次遇到序。
## 数据边界(2026-09-02 全库实测)
- crafts 中出现 `双面印花`×2、logistics 中出现 `海运`×2:来自 3 条 CUSTOM 链接 craftLabel/logisticsLabel 自由文本(含维度填错,属数据清理问题,本修复只保证其沉底,不改数据)。
## 方案
1. `derivePriceMatrix`:聚合(Set 去重)+ 人工覆盖 append 之后,对三个数组做稳定排序:
- 新增 `OPTION_DISPLAY_ORDER` 常量(printCount/craft/logistics 三组词表序,logistics 与 `DIM_VALUES` 顺序相反是业务要求;`DIM_VALUES` 组合生成语义不动);
- 排序键 = 词表下标,未知值 = `canon.length`(沉底),稳定排序保持未知值间首次遇到序。
2. `recomputeFamily` 成员查询加 `orderBy: { id: 'asc' }`,使"遇到序"确定(未知值之间顺序也确定)。
3. 只影响 `autoManaged` 族物化;非自动族人工字段照旧不碰(现有不变量)。
4. 一次性脚本:逐族调用 `recomputeFamily` 全库刷新存量(166 个族)。
## 非目标
- 不改 API 输出结构(仍是 `string[]`),前端零改动;
- 不清理 CUSTOM 维度填错数据(另行处理)。
## 测试计划(TDD
测试文件:`apps/api/src/product-families/family-recompute.service.spec.ts``derivePriceMatrix` 为纯函数,可直接单测)。
1. **打乱输入仍输出词表序**:成员顺序颠倒,crafts 输出仍 `烫画→直喷→不打印`(修复前为遇到序,RED)。
2. **子集保序**:只含 `直喷+烫画` → 输出 `烫画,直喷`;只含 `双面` → 单元素。
3. **未知值沉底**logistics 含 `海运``不包邮,包邮,海运`;未知值相互保持遇到序。
4. **覆盖 append 值参与同一排序**:override 新增取值后数组仍词表序。
5. **重算幂等(含顺序)**:同输入跑两遍输出 deep-equal。
6. 既有用例全绿;全量 jest + tsc 通过。
## 风险与回滚
- 纯物化顺序变化,无 schema/契约变更;回滚 = revert commit + 再跑一次全库重算(旧逻辑会把顺序"洗回"遇到序,仅顺序变化无数据损坏)。
@@ -0,0 +1,67 @@
# Fix:公开接口族代表行选取规则统一(列表/首页对齐详情)
日期:2026-09-02
类型:Bug 修复(fix
影响面:`apps/api/src/public/public.service.ts` 及其测试
## 背景与问题
公开接口中,族(productFamily)对外只暴露一条"代表行",`goodName`、主图、国家、分类、标签、`goodPriority``createdAt` 等字段全部来自代表行。但三个接口的代表行选取规则不一致:
| 接口 | 代表行选取规则 | 位置 |
| --- | --- | --- |
| 详情 `GET /public/goods/{goodId}` | `goodPriority desc → createdAt desc → id asc` | `getGoodByFamilyId` |
| 列表 `GET /public/goods` | `goods[0]`(随列表排序参数变化:DEFAULT 平局取 id 最小;价格排序取价格极值行;NEWEST 取最新行) | `getGoods` 分组处 |
| 首页 `GET /public/home-goods` | `goods[0]`(按 position 顺序的第一条) | `getHomeGoods` |
后果:同一族内多条 Good 名称不同时,列表返回的 `goodName` 与详情不一致;且列表换排序参数后名称还会变。
## 目标
- 列表与首页的族代表行选取规则与详情完全一致:`goodPriority desc → createdAt desc → id asc`
- 列表排序逻辑(DEFAULT 树序 / PRICE / NEWEST)与分组顺序完全不变。
- 公开 API 输入输出数据结构不变(硬性约束)。
## 非目标
- 不改动详情逻辑、族去重契约、树序排序实现。
- 不处理无族(自定义商品)——其分组内只有一条,不受影响。
## 方案(已确认:方案 A + 首页一起改)
`public.service.ts` 中新增私有方法,组内显式选取代表行,`getGoods``getHomeGoods` 分组处统一调用:
```ts
/** 族代表行:与详情 getGoodByFamilyId 的 orderBy 保持一致
* goodPriority desc → createdAt desc → id asc),保证列表/首页/详情字段一致 */
private pickFamilyRepresentative<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
goods: T[],
): T {
return [...goods].sort(
(a, b) =>
(b.goodPriority ?? 0) - (a.goodPriority ?? 0) ||
b.createdAt.getTime() - a.createdAt.getTime() ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
)[0];
}
```
- `getGoods``const rep = this.pickFamilyRepresentative(goods);`
- `getHomeGoods`:同上替换 `goods[0]`
- 组序不受影响:同族成员共享国家/款,最高 `goodPriority` 相同,仅平局细则变化,树序键 c1/c2/c3 与 max(priority) 均不变。
## 测试计划(TDD
测试文件:`apps/api/src/public/public.service.spec.ts`(沿用现有夹具风格)。
1. **列表 vs 详情一致性**:构造同族两条 Good`goodPriority` 相同、`createdAt` 不同、名称不同 →
`GET /public/goods` 列表项的 `goodName` === 详情接口返回的 `goodName`
2. **优先级优先**:族内两条 Good 优先级不同 → 列表代表行取优先级最高的那条(即使它 id 更大/创建更早)。
3. **价格排序下名称不漂移**`sort=PRICE_ASC` 时,价格较低但优先级低的成员不是代表行,列表 `goodName` 与 DEFAULT 排序一致。
4. **首页一致性**:位置配置同族多条 → 首页返回的 `goodName` 与详情一致。
5. 既有用例全部保持通过。
## 风险与回滚
- 纯内存选取逻辑变更,无 schema/数据变更,无部署数据风险。
- 回滚:还原 commit 即可。