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