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
+113 -104
View File
@@ -1,104 +1,113 @@
# AGENT.md - AI Agent 开发规范 # AGENT.md - AI Agent 开发规范
## 开发流程 ## 开发流程
### 1. 梳理需求 ### 1. 梳理需求
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下: 使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
| 需求类型 | 计划目录 | 命名规则 | 示例 | | 需求类型 | 计划目录 | 命名规则 | 示例 |
| -------- | ----------------- | --------------------- | ----------------------- | | -------- | ----------------- | --------------------- | ----------------------- |
| 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` | | 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` |
| 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` | | 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` |
| 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` | | 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` |
使用 writing-plans 技能制定实施计划 使用 writing-plans 技能制定实施计划
**开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。 **开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。
可参考的开发文档位于 `docs/dev/*` 可参考的开发文档位于 `docs/dev/*`
### 2. 建立开发分支 ### 2. 建立开发分支
确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。 确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。
### 3. 按照需求开发 ### 3. 按照需求开发
按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。 按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。
**重要原则:所有测试必须通过** **重要原则:所有测试必须通过**
- 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试 - 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试
- 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能 - 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能
- 只有在所有测试都通过的情况下,才能认为开发完成 - 只有在所有测试都通过的情况下,才能认为开发完成
所有任务完成后请遵循 verification-before-completion 技能完成验证 所有任务完成后请遵循 verification-before-completion 技能完成验证
### 4. 合并到开发分支 ### 4. 合并到开发分支
所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。 所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。
### 5. 沉淀开发经验 ### 5. 沉淀开发经验
在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。 在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。
### 6. 更新项目结构文档 ### 6. 更新项目结构文档
每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。 每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。
### 7. 更新使用文档 ### 7. 更新使用文档
使用文档分为: 使用文档分为:
1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。 1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。
2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。 2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。
3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。 3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。
**快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。 **快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。
**详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。 **详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。
**AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。 **AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。
## 开发规范 ## 开发规范
### 代码优化 ### 代码优化
遵循 improve-codebase-architecture 遵循 improve-codebase-architecture
### 类型规范 ### 类型规范
参考技能 typescript-advanced-types 参考技能 typescript-advanced-types
### 测试规范 ### 测试规范
参考技能 javascript-testing-patterns 参考技能 javascript-testing-patterns
### 测试 ### 测试
每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts` 每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts`
## 数据库设计 ## 数据库设计
PostgreSQL 最佳设计实践: postgresql-table-design 技能 PostgreSQL 最佳设计实践: postgresql-table-design 技能
PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能 PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能
Prisma Postgres 最佳实践: prisma-postgres 技能 Prisma Postgres 最佳实践: prisma-postgres 技能
### 后端规范 ### 后端规范
参考 nestjs-best-practices 技能 参考 nestjs-best-practices 技能
> 你可以自己增加规范 > 你可以自己增加规范
### 经验沉淀(跨项目通用) ### 经验沉淀(跨项目通用)
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库 - **入口 nginx 的真实挂载源在 v1 仓库**:线上 official.inkreach.cc 的 nginx 容器由 `/opt/inkreach/deploy` 创建,挂载的是那边的 `nginx/admin.conf``certbot/acme` 证书;v2 仓库 `deploy/nginx/admin.conf` 只是同步副本。改线上路由必须改 v1 的文件,再在该目录 `docker compose up -d --force-recreate admin`。v1/v2 目录同名 → compose 项目名相同 → pgdata/uploads 数据卷共享,重建不丢数据
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试 - **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件
- **跑全量 jest 前先停 dev server**`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑 - **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库
- **中文断言勿手写字面量排序**JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或对两侧统一 `.sort()` 后再比较 - **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态 - **跑全量 jest 前先停 dev server**`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库 - **中文断言勿手写字面量排序**JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180 SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec - **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 最稳
- **共享开发库不可达时用一次性 docker postgres 跑集成测试**`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine``DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿 - **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名
- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply - **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序
- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复 - **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。
- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180 SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。
- **共享开发库不可达时用一次性 docker postgres 跑集成测试**`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine``DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。
- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。
- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
+3 -1
View File
@@ -24,9 +24,11 @@
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.ts", "import:product-detail": "ts-node prisma/import-product-detail.ts",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
"fix:category-names": "ts-node prisma/fix-category-parens.ts", "fix:category-names": "ts-node prisma/fix-category-parens.ts",
"organize": "ts-node prisma/backfill-product-families.ts" "organize": "ts-node prisma/backfill-product-families.ts",
"recompute:families": "ts-node prisma/recompute-all-families.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
@@ -0,0 +1,133 @@
/**
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
* + 沙特国家行 + 中东根更名
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
*/
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
const prisma = new PrismaClient();
// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应)
const ROOT_MAP: Record<string, string> = {
'美国': '美国工厂直发',
'英国': '英国本地直发',
'日本': '日本本地工厂直发',
'墨西哥': '墨西哥工厂本地直发',
'巴西': '巴西本地工厂直发',
'中东': '中东本地工厂直发', // 同时更名沙特
'波兰': '欧洲波兰工厂直发',
'西班牙': '欧洲西班牙工厂直发',
'德国': '欧洲德国工厂本地直发',
'意大利': '欧洲意大利工厂直发',
'加拿大': '加拿大本地工厂直发',
'澳大利亚': '澳大利亚本地工厂直发',
'韩国': '韩国本地直发',
'中国(国内工厂)': '国内工厂',
};
const norm = (s: string) => s.trim().replace(/\s+/g, '');
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
async function main() {
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
let country: string | null = null;
let l2: string | null = null;
const tree: Array<{ country: string; l2: string; l3: string }> = [];
const countryOrder: string[] = [];
for (const line of raw.split('\n')) {
const t = line.trim();
if (t.startsWith('# ')) {
const name = t.slice(2).trim();
if (name === '全部' || name.includes('工厂直发国家')) continue;
country = name;
if (!countryOrder.includes(name)) countryOrder.push(name);
} else if (t.startsWith('## ') && country) {
l2 = t.slice(3).trim();
} else if (t.startsWith('### ') && country && l2) {
tree.push({ country, l2, l3: t.slice(4).trim() });
}
}
console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`);
const roots = await prisma.category.findMany({
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
include: { children: { include: { children: true } } },
});
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
const unmatched: string[] = [];
// 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过)
for (let i = 0; i < countryOrder.length; i++) {
const name = countryOrder[i];
const dbCountry = name === '中东' ? '沙特' : name;
if (dbCountry === '中国(国内工厂)') continue;
const sortOrder = i + 1;
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
if (existing) {
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
} else if (dbCountry === '沙特') {
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
console.log('created country: 沙特');
}
}
// 2) 中东根 → 沙特
const meRoot = rootByName.get('中东本地工厂直发');
if (meRoot) {
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
rootByName.set('沙特本地工厂直发', meRoot);
console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发');
}
// 3) 二级/款 sort_order
for (const countryName of countryOrder) {
const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName];
const root = rootByName.get(rootName);
if (!root) {
unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`);
continue;
}
const l2s = root.children;
const l2NamesInOrder: string[] = [];
for (const row of tree) {
if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
}
for (let i = 0; i < l2NamesInOrder.length; i++) {
const target = l2NamesInOrder[i];
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
if (!mid) {
unmatched.push(`L2 MISS: ${countryName} / ${target}`);
continue;
}
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
const leaves = mid.children;
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
for (let j = 0; j < l3Names.length; j++) {
const want = norm(l3Names[j]);
const code = norm(codeOf(l3Names[j]));
const leaf =
leaves.find((l) => norm(l.categoryName) === want) ??
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
if (!leaf) {
unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`);
continue;
}
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
}
}
}
if (unmatched.length) {
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
process.exit(1);
}
console.log('backfill done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
+40
View File
@@ -0,0 +1,40 @@
/**
* 全量族重算(一次性维护脚本):
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
*
* 运行:pnpm --filter @inkreach/api recompute:families
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma);
const families = await prisma.productFamily.findMany({
select: { id: true },
orderBy: { id: 'asc' },
});
let ok = 0;
let failed = 0;
for (const { id } of families) {
try {
await recompute.recomputeFamily(id);
ok++;
} catch (error) {
failed++;
console.error(`family ${id} recompute failed: ${String(error)}`);
}
}
console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`);
await prisma.onModuleDestroy();
if (failed > 0) process.exit(1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+1
View File
@@ -161,6 +161,7 @@ model Category {
categoryName String @map("category_name") categoryName String @map("category_name")
categoryIcon String? @map("category_icon") categoryIcon String? @map("category_icon")
sdsCategoryId String? @unique @map("sds_category_id") sdsCategoryId String? @unique @map("sds_category_id")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,6 +1,10 @@
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { FamilyRecomputeService } from './family-recompute.service'; import {
FamilyRecomputeService,
derivePriceMatrix,
memberMatrixCombos,
} from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service'; import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service'; import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -400,3 +404,130 @@ describe('FamilyRecomputeService', () => {
spy.mockRestore(); spy.mockRestore();
}); });
}); });
/**
* derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。
* 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
* 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。
*/
describe('derivePriceMatrix 选项组排序', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('成员顺序无关:三个维度恒为词表序', () => {
// 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前)
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '直喷']);
expect(m.logistics).toEqual(['不包邮', '包邮']);
});
it('子集保序:只出现部分取值时相对顺序不变', () => {
const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []);
expect(m.printCounts).toEqual(['双面印花']);
expect(m.crafts).toEqual(['直喷']);
expect(m.logistics).toEqual(['不包邮']);
});
it('未知自由文本沉底且相互保持首次遇到序', () => {
const members = [
// CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义)
mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '丝印', '水洗']);
expect(m.logistics).toEqual(['包邮', '海运', '空运']);
});
it('人工覆盖新增取值参与同一排序', () => {
const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })];
const overrides = [
{
sizeId: 'size_M',
colorId: 'color_red',
printCount: '四面印花',
craft: '丝印',
logistics: '海运',
price: '40',
},
] as any;
const m = derivePriceMatrix(members, overrides);
expect(m.printCounts).toEqual(['单面印花', '四面印花']);
expect(m.crafts).toEqual(['烫画', '丝印']);
expect(m.logistics).toEqual(['包邮', '海运']);
});
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const first = derivePriceMatrix(members, []);
const second = derivePriceMatrix(members, []);
const reversed = derivePriceMatrix([...members].reverse(), []);
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
});
});
/**
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
*/
describe('热转印工艺', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
});
it('derivePriceMatrixcrafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
});
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['热转印', '丝印']);
});
});
@@ -66,11 +66,31 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */ /** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
const DIM_VALUES = { const DIM_VALUES = {
printCount: ['单面印花', '双面印花'], printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印'], craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['包邮', '不包邮'], logistics: ['包邮', '不包邮'],
} as const satisfies Record<string, readonly string[]>; } as const satisfies Record<string, readonly string[]>;
type DimKey = keyof typeof DIM_VALUES; type DimKey = keyof typeof DIM_VALUES;
/**
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
*/
const OPTION_DISPLAY_ORDER = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['不包邮', '包邮'],
} as const;
function optionRank(value: string, canon: readonly string[]): number {
const i = canon.indexOf(value);
return i === -1 ? canon.length : i;
}
function sortOptions(values: string[], canon: readonly string[]): string[] {
return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon));
}
export interface MatrixCombo { export interface MatrixCombo {
printCount: string; printCount: string;
craft: string; craft: string;
@@ -270,9 +290,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
return { return {
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })), sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })), colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
printCounts, printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
crafts, crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
logistics: logisticsOptions, logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
rows, rows,
}; };
} }
@@ -330,6 +350,8 @@ export class FamilyRecomputeService {
include: { include: {
originGoods: { originGoods: {
where: { delisted: false }, where: { delisted: false },
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
orderBy: { id: 'asc' },
include: { include: {
detail: true, detail: true,
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } }, variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
+221
View File
@@ -650,4 +650,225 @@ 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款1B 的 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]));
// 国家/款顺序优先于 priorityB1(99) 不能排到 A2(0) 前面
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
});
});
describe('family representative row consistency (列表/首页代表行对齐详情)', () => {
// 同族两条 Good:同 priority=10Low 的 id 更小/createdAt 更早/价格更低/位置更好,
// High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc
// → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。
const stamp3 = `${stamp}-rep`;
let repFamilyId: bigint;
let trash = {
goodIds: [] as bigint[],
positionIds: [] as bigint[],
originGoodIds: [] as bigint[],
};
const repLowName = `Rep Low ${stamp3}`;
const repHighName = `Rep High ${stamp3}`;
beforeAll(async () => {
const posLow = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
const posHigh = await prisma.position.create({
data: { indexVal: 5, countryId, categoryId },
});
trash.positionIds = [posLow.id, posHigh.id];
const ogLow = await prisma.originGood.create({
data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 },
});
const ogHigh = await prisma.originGood.create({
data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 },
});
trash.originGoodIds = [ogLow.id, ogHigh.id];
const family = await prisma.productFamily.create({
data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id },
});
repFamilyId = family.id;
await prisma.originGood.updateMany({
where: { id: { in: [ogLow.id, ogHigh.id] } },
data: { familyId: family.id },
});
const gLow = await prisma.good.create({
data: {
goodName: repLowName,
originGoodId: ogLow.id,
familyId: family.id,
countryId,
categoryId,
goodPriority: 10,
positionId: posLow.id,
createdAt: new Date(stamp),
},
});
const gHigh = await prisma.good.create({
data: {
goodName: repHighName,
originGoodId: ogHigh.id,
familyId: family.id,
countryId,
categoryId,
goodPriority: 10,
positionId: posHigh.id,
createdAt: new Date(stamp + 60_000),
},
});
trash.goodIds = [gLow.id, gHigh.id];
});
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
await prisma.position
.deleteMany({ where: { id: { in: trash.positionIds } } })
.catch(() => undefined);
await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined);
await prisma.originGood
.deleteMany({ where: { id: { in: trash.originGoodIds } } })
.catch(() => undefined);
});
it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => {
const detail = await service.getGood(repFamilyId.toString());
expect(detail.goodName).toBe(repHighName);
const list = await service.getGoods({
page: 1,
pageSize: 50,
keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High
});
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => {
const detail = await service.getGood(repFamilyId.toString());
const list = await service.getGoods({
page: 1,
pageSize: 50,
keyword: 'Rep ',
sort: 'PRICE_ASC',
});
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => {
const detail = await service.getGood(repFamilyId.toString());
const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() });
const ours = home.filter((h) => h.goodId === repFamilyId.toString());
expect(ours).toHaveLength(1);
expect(ours[0].goodName).toBe(detail.goodName);
});
});
}); });
+92 -16
View File
@@ -71,13 +71,19 @@ const PUBLIC_GOOD_LIST_INCLUDE = {
tag: { include: { tagGroup: true } }, tag: { include: { tagGroup: true } },
position: true, position: true,
originGood: { originGood: {
select: { sdsGoodId: true, goodImage: true, goodPrice: true }, select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
}, },
goodTags: { include: { tag: { include: { tagGroup: true } } } }, goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude; } satisfies Prisma.GoodInclude;
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>; type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
interface TreeOrderMeta {
countryOrder: Map<string, number>;
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
leafOrder: Map<string, { c2: number; c3: number }>;
}
@Injectable() @Injectable()
export class PublicService { export class PublicService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -220,12 +226,7 @@ export class PublicService {
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }] ? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
: query.sort === 'NEWEST' : query.sort === 'NEWEST'
? [{ createdAt: 'desc' }, { id: 'asc' }] ? [{ createdAt: 'desc' }, { id: 'asc' }]
: [ : [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID); // 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配 // 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
@@ -235,6 +236,15 @@ export class PublicService {
include: PUBLIC_GOOD_LIST_INCLUDE, include: PUBLIC_GOOD_LIST_INCLUDE,
orderBy, 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 familyMinPrices = await this.loadFamilyMinPrices();
const grouped = new Map<string, PublicGoodListRow[]>(); const grouped = new Map<string, PublicGoodListRow[]>();
for (const good of rows) { for (const good of rows) {
@@ -244,7 +254,7 @@ export class PublicService {
else grouped.set(key, [good]); else grouped.set(key, [good]);
} }
let items = [...grouped.values()].map((goods) => { let items = [...grouped.values()].map((goods) => {
const rep = goods[0]; const rep = this.pickFamilyRepresentative(goods);
const dto = this.toPublicGood(rep); const dto = this.toPublicGood(rep);
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价 // 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
const familyMin = rep.familyId const familyMin = rep.familyId
@@ -270,6 +280,52 @@ export class PublicService {
}; };
} }
/**
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
*/
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
const [countries, leaves] = await Promise.all([
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
this.prisma.$queryRaw<
Array<{ sds_category_id: string; c2: number; c3: number }>
>`
SELECT leaf.sds_category_id,
COALESCE(mid.sort_order, 2147483647) AS c2,
COALESCE(leaf.sort_order, 2147483647) AS c3
FROM categories leaf
JOIN categories mid ON mid.category_id = leaf.parent_category_id
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''
`,
]);
return {
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
leafOrder: new Map(
leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]),
),
};
}
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
const MAX = Number.MAX_SAFE_INTEGER;
const key = (g: PublicGoodListRow): [number, number, number, number, number] => {
const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? '');
return [
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
leaf?.c2 ?? MAX,
leaf?.c3 ?? MAX,
-(g.goodPriority ?? 0),
Number(g.id),
];
};
const ka = key(a);
const kb = key(b);
for (let i = 0; i < ka.length; i++) {
if (ka[i] !== kb[i]) return ka[i] - kb[i];
}
return 0;
}
/** /**
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include * 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传 * 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
@@ -365,16 +421,20 @@ export class PublicService {
take: query.limit, take: query.limit,
}); });
const familyMinPrices = await this.loadFamilyMinPrices(); const familyMinPrices = await this.loadFamilyMinPrices();
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit // 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
const seen = new Set<string>(); const grouped = new Map<string, PublicGoodListRow[]>();
const items: PublicGoodDto[] = [];
for (const good of rows) { for (const good of rows) {
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
if (seen.has(key)) continue; const bucket = grouped.get(key);
seen.add(key); if (bucket) bucket.push(good);
const dto = this.toPublicGood(good); else grouped.set(key, [good]);
const familyMin = good.familyId }
? familyMinPrices.get(good.familyId.toString()) const items: PublicGoodDto[] = [];
for (const goods of grouped.values()) {
const rep = this.pickFamilyRepresentative(goods);
const dto = this.toPublicGood(rep);
const familyMin = rep.familyId
? familyMinPrices.get(rep.familyId.toString())
: undefined; : undefined;
if (familyMin !== undefined) dto.price = familyMin; if (familyMin !== undefined) dto.price = familyMin;
items.push(dto); items.push(dto);
@@ -382,6 +442,22 @@ export class PublicService {
return items.slice(0, query.limit); return items.slice(0, query.limit);
} }
/**
* 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致
* goodPriority desc → createdAt desc → id asc),
* 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。
*/
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];
}
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */ /** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
private toPublicGood(good: PublicGoodListRow): PublicGoodDto { private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) => const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
+1 -1
View File
@@ -1,2 +1,2 @@
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)')) <!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/v2/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/v2/h5/static/js/chunk-vendors.6234e216.js></script><script src=/v2/h5/static/js/index.e79d1a25.js></script></body></html> document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/v2/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/v2/h5/static/js/chunk-vendors.6234e216.js></script><script src=/v2/h5/static/js/index.f402cc83.js></script></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+312 -312
View File
@@ -1,312 +1,312 @@
# InkReach Product Center — Monorepo Structure # InkReach Product Center — Monorepo Structure
> pnpm workspaces + Turborepo 统一管理三个子项目与共享包。所有子项目位于 `apps/`,共享代码位于 `packages/`。 > pnpm workspaces + Turborepo 统一管理三个子项目与共享包。所有子项目位于 `apps/`,共享代码位于 `packages/`。
## 根目录 ## 根目录
``` ```
inkreach-official/ inkreach-official/
├── apps/ ├── apps/
│ ├── api/ # NestJS 后端 (package: @inkreach/api) │ ├── api/ # NestJS 后端 (package: @inkreach/api)
│ ├── admin/ # Vue 3 后台 (package: @inkreach/admin) │ ├── admin/ # Vue 3 后台 (package: @inkreach/admin)
│ └── website/ # Nuxt 4 官网 (package: @inkreach/website) │ └── website/ # Nuxt 4 官网 (package: @inkreach/website)
├── packages/ ├── packages/
│ ├── tsconfig/ # 共享 TypeScript 配置 (base / api / vue) │ ├── tsconfig/ # 共享 TypeScript 配置 (base / api / vue)
│ └── shared-types/ # 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) │ └── shared-types/ # 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口)
├── .agents/ # AI Agent 共享技能 ├── .agents/ # AI Agent 共享技能
├── docs/ # 跨子项目文档 ├── docs/ # 跨子项目文档
│ ├── dev/ # 设计文档(数据库表设计、PRD、原型图) │ ├── dev/ # 设计文档(数据库表设计、PRD、原型图)
│ ├── references/ │ ├── references/
│ │ └── structs.md # 本文件 │ │ └── structs.md # 本文件
│ └── superpowers/specs/ # superpowers 规格说明 │ └── superpowers/specs/ # superpowers 规格说明
├── plans/ # 跨子项目计划 ├── plans/ # 跨子项目计划
├── package.json # 根 workspace 配置(scripts + devDependencies ├── package.json # 根 workspace 配置(scripts + devDependencies
├── pnpm-workspace.yaml # pnpm workspace 定义(apps/*, packages/* ├── pnpm-workspace.yaml # pnpm workspace 定义(apps/*, packages/*
├── turbo.json # Turborepo 任务流水线 ├── turbo.json # Turborepo 任务流水线
├── .prettierrc # 统一代码格式化 ├── .prettierrc # 统一代码格式化
├── .gitignore # 全局忽略规则 ├── .gitignore # 全局忽略规则
├── .env # 根级环境变量(DATABASE_URL 等) ├── .env # 根级环境变量(DATABASE_URL 等)
├── AGENTS.md # 根级 AI Agent 开发规范 ├── AGENTS.md # 根级 AI Agent 开发规范
└── README.md └── README.md
``` ```
## 端口与子项目 ## 端口与子项目
| 子项目 | 包名 | 端口 | 启动命令 | 说明 | | 子项目 | 包名 | 端口 | 启动命令 | 说明 |
|--------|------|------|----------|------| |--------|------|------|----------|------|
| `apps/api` | `@inkreach/api` | 3001 | `pnpm --filter @inkreach/api start:dev` | NestJS + Prisma + PostgreSQL 后端 APISwagger 文档 `/api/docs` | | `apps/api` | `@inkreach/api` | 3001 | `pnpm --filter @inkreach/api start:dev` | NestJS + Prisma + PostgreSQL 后端 APISwagger 文档 `/api/docs` |
| `apps/admin` | `@inkreach/admin` | 5173 | `pnpm --filter @inkreach/admin dev` | Vue 3 + Element Plus 后台管理;通过 Vite proxy `/api → :3001` | | `apps/admin` | `@inkreach/admin` | 5173 | `pnpm --filter @inkreach/admin dev` | Vue 3 + Element Plus 后台管理;通过 Vite proxy `/api → :3001` |
| `apps/website` | `@inkreach/website` | 3000 | `pnpm --filter @inkreach/website dev` | Nuxt 4 官网;通过 Nitro `server/api/backend/*` 代理 `:3001` | | `apps/website` | `@inkreach/website` | 3000 | `pnpm --filter @inkreach/website dev` | Nuxt 4 官网;通过 Nitro `server/api/backend/*` 代理 `:3001` |
启动顺序:先启动 `apps/api`,再启动另两个。 启动顺序:先启动 `apps/api`,再启动另两个。
## 子项目 1apps/api(后端,@inkreach/api ## 子项目 1apps/api(后端,@inkreach/api
``` ```
apps/api/ apps/api/
├── prisma/ ├── prisma/
│ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog │ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog
│ ├── migrations/ # Prisma migrate 历史 │ ├── migrations/ # Prisma migrate 历史
│ ├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) │ ├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等)
│ ├── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) │ ├── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认)
│ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认) │ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认)
├── src/ ├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块 │ ├── app.module.ts # 根模块,聚合所有业务模块
│ ├── prisma/ # PrismaService 封装 │ ├── prisma/ # PrismaService 封装
│ ├── auth/ # JWT 认证:register / login / JwtStrategy / JwtAuthGuard │ ├── auth/ # JWT 认证:register / login / JwtStrategy / JwtAuthGuard
│ ├── countries/ # 国家 CRUD(受 JWT 保护) │ ├── countries/ # 国家 CRUD(受 JWT 保护)
│ ├── categories/ # 品类 CRUD(受 JWT 保护,自引用树) │ ├── categories/ # 品类 CRUD(受 JWT 保护,自引用树)
│ ├── tags/ # 标签 CRUD(受 JWT 保护) │ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管) │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名) │ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名)
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述) │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述)
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 │ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
│ └── common/ # 全局装饰器 / 过滤器 / 拦截器 │ └── common/ # 全局装饰器 / 过滤器 / 拦截器
│ ├── decorators/current-user.decorator.ts │ ├── decorators/current-user.decorator.ts
│ ├── filters/http-exception.filter.ts │ ├── filters/http-exception.filter.ts
│ └── interceptors/transform.interceptor.ts │ └── interceptors/transform.interceptor.ts
├── test/ # e2e 测试 ├── test/ # e2e 测试
├── dist/ # 构建产物 ├── dist/ # 构建产物
├── nest-cli.json ├── nest-cli.json
├── tsconfig.json / tsconfig.build.json ├── tsconfig.json / tsconfig.build.json
├── jest.config.js ├── jest.config.js
└── package.json └── package.json
``` ```
### 数据模型(Prisma ### 数据模型(Prisma
| 模型 | 说明 | | 模型 | 说明 |
|------|------| |------|------|
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 | | `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 |
| `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON | | `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON |
| `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 | | `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 |
| `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) | | `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) |
| `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 | | `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 |
| `Country` | 国家,关联 goods / positions | | `Country` | 国家,关联 goods / positions |
| `Category` | 自引用树形品类,可选 `sds_category_id` | | `Category` | 自引用树形品类,可选 `sds_category_id` |
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` | | `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
| `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 | | `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods | | `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority``familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) | | `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority``familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) |
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 | | `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
| `User` | 后台用户(bcrypt 哈希) | | `User` | 后台用户(bcrypt 哈希) |
| `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` | | `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` |
### 关键设计 ### 关键设计
- **所有主键为 `BigInt`**,路由解析后 `BigInt(id)` 处理,序列化时通过 `BigInt.prototype.toJSON` 转为字符串。 - **所有主键为 `BigInt`**,路由解析后 `BigInt(id)` 处理,序列化时通过 `BigInt.prototype.toJSON` 转为字符串。
- **全局 `TransformInterceptor`**:把响应包装为 `{ data: T, success: true }`,前端读取 `response.data` - **全局 `TransformInterceptor`**:把响应包装为 `{ data: T, success: true }`,前端读取 `response.data`
- **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted` - **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted`
- **全局 `HttpExceptionFilter`**:统一错误响应形态。 - **全局 `HttpExceptionFilter`**:统一错误响应形态。
- **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。 - **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。 - **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。
### API 路由 ### API 路由
| 前缀 | 说明 | 鉴权 | | 前缀 | 说明 | 鉴权 |
|------|------|------| |------|------|------|
| `/auth/register` `POST` | 注册后台用户 | 公开 | | `/auth/register` `POST` | 注册后台用户 | 公开 |
| `/auth/login` `POST` | 登录获取 JWT | 公开 | | `/auth/login` `POST` | 登录获取 JWT | 公开 |
| `/public/categories` `GET` | 公开品类树(仅含已挂商品的品类) | 公开 | | `/public/categories` `GET` | 公开品类树(仅含已挂商品的品类) | 公开 |
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 | | `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 | | `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 | | `/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` 应急回退 | 公开 | | `/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 | | `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT | | `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT | | `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT | | `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT |
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT | | `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT | | `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
| `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族(并入已有同款族优先)→ 碎片族合并 → 全量重算;返回 `familiesMerged/fragmentsConsolidated/fragmentsManualReview`CLI 等价 `pnpm --filter @inkreach/api organize` | JWT | | `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族(并入已有同款族优先)→ 碎片族合并 → 全量重算;返回 `familiesMerged/fragmentsConsolidated/fragmentsManualReview`CLI 等价 `pnpm --filter @inkreach/api organize` | JWT |
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接,无分类回退族语义名称键(`familyNameKey`,物流/工艺不分族);已有同款 autoManaged 族则**并入**(不建 -2 碎片族),同款仅人工锁定族则跳过;`{apply:false}` 预览含 `action: create/merge/skip``{apply:true}` 落库并重算(幂等) | JWT | | `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接,无分类回退族语义名称键(`familyNameKey`,物流/工艺不分族);已有同款 autoManaged 族则**并入**(不建 -2 碎片族),同款仅人工锁定族则跳过;`{apply:false}` 预览含 `action: create/merge/skip``{apply:true}` 落库并重算(幂等) | JWT |
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT | | `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT | | `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT | | `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | JWT | | `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | JWT |
| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;格子五键 `sizeId+colorId+printCount+craft+logistics` 必须存在于族矩阵选项 | JWT | | `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;格子五键 `sizeId+colorId+printCount+craft+logistics` 必须存在于族矩阵选项 | JWT |
| `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT | | `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT |
| `/sync/categories` `POST` | 手动触发分类同步 | JWT | | `/sync/categories` `POST` | 手动触发分类同步 | JWT |
| `/sync/products` `POST` | 手动触发商品同步 | JWT | | `/sync/products` `POST` | 手动触发商品同步 | JWT |
| `/sync/status` `GET` | 最近同步日志(`?limit=20` | JWT | | `/sync/status` `GET` | 最近同步日志(`?limit=20` | JWT |
| `/api/docs` | Swagger UI | 公开 | | `/api/docs` | Swagger UI | 公开 |
## 子项目 2apps/admin(后台,@inkreach/admin ## 子项目 2apps/admin(后台,@inkreach/admin
``` ```
apps/admin/ apps/admin/
├── public/ # 静态资源 ├── public/ # 静态资源
├── src/ ├── src/
│ ├── main.ts # 入口:Pinia + Vue Router + ElementPlus │ ├── main.ts # 入口:Pinia + Vue Router + ElementPlus
│ ├── App.vue │ ├── App.vue
│ ├── style.css # 全局样式(含品牌色变量) │ ├── style.css # 全局样式(含品牌色变量)
│ ├── api/ # 按业务模块拆分的 API 客户端 │ ├── api/ # 按业务模块拆分的 API 客户端
│ │ ├── request.ts # axios 实例 + JWT 拦截 + 全局错误处理 │ │ ├── request.ts # axios 实例 + JWT 拦截 + 全局错误处理
│ │ ├── auth.ts # /auth/login, /auth/me, /auth/logout │ │ ├── auth.ts # /auth/login, /auth/me, /auth/logout
│ │ ├── goods.ts # 商品 CRUD + 批量 │ │ ├── goods.ts # 商品 CRUD + 批量
│ │ ├── categories.ts # 品类 CRUD │ │ ├── categories.ts # 品类 CRUD
│ │ ├── countries.ts # 国家 CRUD │ │ ├── countries.ts # 国家 CRUD
│ │ ├── tags.ts # 标签 CRUD(含批量排序) │ │ ├── tags.ts # 标签 CRUD(含批量排序)
│ │ ├── tag-groups.ts # 标签分组 CRUD │ │ ├── tag-groups.ts # 标签分组 CRUD
│ │ ├── positions.ts # 坑位 CRUD │ │ ├── positions.ts # 坑位 CRUD
│ │ ├── origin-goods.ts # 原始商品快照 │ │ ├── origin-goods.ts # 原始商品快照
│ │ ├── product-families.ts # 产品族 CRUD / auto-group / 成员 / 覆盖 / 重算 │ │ ├── product-families.ts # 产品族 CRUD / auto-group / 成员 / 覆盖 / 重算
│ │ └── sync.ts # 同步触发 + 日志 │ │ └── sync.ts # 同步触发 + 日志
│ ├── layouts/DefaultLayout.vue # 侧边栏 + 顶部条 + 用户菜单 │ ├── layouts/DefaultLayout.vue # 侧边栏 + 顶部条 + 用户菜单
│ ├── router/index.ts # 路由 + 登录守卫 │ ├── router/index.ts # 路由 + 登录守卫
│ ├── stores/ │ ├── stores/
│ │ ├── auth.ts # 登录态 + token + user(持久化到 localStorage │ │ ├── auth.ts # 登录态 + token + user(持久化到 localStorage
│ │ └── app.ts # 侧边栏折叠 │ │ └── app.ts # 侧边栏折叠
│ ├── types/index.ts # 共享类型 │ ├── types/index.ts # 共享类型
│ ├── views/ │ ├── views/
│ │ ├── login/LoginView.vue # 登录 │ │ ├── login/LoginView.vue # 登录
│ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表) │ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表)
│ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、 │ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、
│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover │ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover
│ │ ├── categories/CategoriesView.vue │ │ ├── categories/CategoriesView.vue
│ │ ├── countries/CountriesView.vue │ │ ├── countries/CountriesView.vue
│ │ ├── tags/TagsView.vue │ │ ├── tags/TagsView.vue
│ │ ├── positions/PositionsView.vue │ │ ├── positions/PositionsView.vue
│ │ └── sync/SyncView.vue │ │ └── sync/SyncView.vue
│ ├── auto-imports.d.ts # 自动生成的自动导入类型 │ ├── auto-imports.d.ts # 自动生成的自动导入类型
│ └── components.d.ts # 自动生成的组件类型 │ └── components.d.ts # 自动生成的组件类型
├── index.html ├── index.html
├── vite.config.ts # Vite + ElementPlus 自动导入 + /api → :3001 代理 ├── vite.config.ts # Vite + ElementPlus 自动导入 + /api → :3001 代理
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json ├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
└── package.json └── package.json
``` ```
### 路由 ### 路由
| 路径 | 视图 | 鉴权 | | 路径 | 视图 | 鉴权 |
|------|------|------| |------|------|------|
| `/login` | LoginView | 公开 | | `/login` | LoginView | 公开 |
| `/goods` | GoodsView(默认页) | JWT | | `/goods` | GoodsView(默认页) | JWT |
| `/categories` | CategoriesView | JWT | | `/categories` | CategoriesView | JWT |
| `/countries` | CountriesView | JWT | | `/countries` | CountriesView | JWT |
| `/tags` | TagsView | JWT | | `/tags` | TagsView | JWT |
| `/positions` | PositionsView | JWT | | `/positions` | PositionsView | JWT |
| `/sync` | SyncView | JWT | | `/sync` | SyncView | JWT |
| `/:pathMatch(.*)*` | 重定向到 `/goods` | — | | `/:pathMatch(.*)*` | 重定向到 `/goods` | — |
### 关键设计 ### 关键设计
- **Vite 代理**`/api/*` 代理到 `http://localhost:3001`rewrite 去掉 `/api` 前缀。 - **Vite 代理**`/api/*` 代理到 `http://localhost:3001`rewrite 去掉 `/api` 前缀。
- **axios 拦截器**:请求注入 `Authorization: Bearer <token>`;响应直接返回 `response.data`401 自动登出跳转。 - **axios 拦截器**:请求注入 `Authorization: Bearer <token>`;响应直接返回 `response.data`401 自动登出跳转。
- **ElementPlus 自动导入**:通过 `unplugin-auto-import` + `unplugin-vue-components` + `ElementPlusResolver` - **ElementPlus 自动导入**:通过 `unplugin-auto-import` + `unplugin-vue-components` + `ElementPlusResolver`
- **路由守卫**:未登录访问受保护路由跳 `/login`;已登录访问 `/login``/` - **路由守卫**:未登录访问受保护路由跳 `/login`;已登录访问 `/login``/`
- **Pinia 持久化**`auth` store 主动读写 `localStorage``token` + `user`)。 - **Pinia 持久化**`auth` store 主动读写 `localStorage``token` + `user`)。
## 子项目 3apps/website(官网,@inkreach/website ## 子项目 3apps/website(官网,@inkreach/website
``` ```
apps/website/ apps/website/
├── app/ ├── app/
│ ├── app.vue # 根容器:<NuxtPage /> │ ├── app.vue # 根容器:<NuxtPage />
│ ├── pages/ │ ├── pages/
│ │ ├── index.vue # 首页 │ │ ├── index.vue # 首页
│ │ └── product-center.vue # 产品中心(侧边栏 + 国家/筛选 + 网格 + 分页) │ │ └── product-center.vue # 产品中心(侧边栏 + 国家/筛选 + 网格 + 分页)
│ ├── assets/css/tailwind.css # Tailwind v4 主题(@theme 定义颜色与动画) │ ├── assets/css/tailwind.css # Tailwind v4 主题(@theme 定义颜色与动画)
│ ├── composables/ │ ├── composables/
│ │ ├── useNavData.ts # 导航数据(选品推荐/解决方案) │ │ ├── useNavData.ts # 导航数据(选品推荐/解决方案)
│ │ ├── usePodProducts.ts # 首页 POD 产品(SDS │ │ ├── usePodProducts.ts # 首页 POD 产品(SDS
│ │ └── useProductCenter.ts # 产品中心数据(调用 /api/backend/* │ │ └── useProductCenter.ts # 产品中心数据(调用 /api/backend/*
│ └── components/ │ └── components/
│ ├── AppHeader.vue / AppFooter.vue │ ├── AppHeader.vue / AppFooter.vue
│ ├── nav/ # 导航下拉面板 │ ├── nav/ # 导航下拉面板
│ │ ├── NavMegaMenu.vue │ │ ├── NavMegaMenu.vue
│ │ └── NavColumnMenu.vue │ │ └── NavColumnMenu.vue
│ ├── product/ # 产品中心专用组件 │ ├── product/ # 产品中心专用组件
│ │ ├── ProductSidebar.vue # 树形品类侧边栏 │ │ ├── ProductSidebar.vue # 树形品类侧边栏
│ │ ├── ProductCountryFilter.vue # 国家 pill 筛选 │ │ ├── ProductCountryFilter.vue # 国家 pill 筛选
│ │ ├── ProductFilterBar.vue # 搜索输入 + 按钮 │ │ ├── ProductFilterBar.vue # 搜索输入 + 按钮
│ │ ├── ProductCard.vue # 商品卡片 │ │ ├── ProductCard.vue # 商品卡片
│ │ ├── ProductCardSkeleton.vue # 骨架占位 │ │ ├── ProductCardSkeleton.vue # 骨架占位
│ │ ├── ProductGrid.vue # 网格容器 │ │ ├── ProductGrid.vue # 网格容器
│ │ └── ProductPagination.vue # 分页器 │ │ └── ProductPagination.vue # 分页器
│ ├── HeroBanner.vue │ ├── HeroBanner.vue
│ ├── TrustSection.vue │ ├── TrustSection.vue
│ ├── StepProcess.vue │ ├── StepProcess.vue
│ ├── PodProducts.vue │ ├── PodProducts.vue
│ ├── FeatureCards.vue │ ├── FeatureCards.vue
│ ├── WhyInkReach.vue │ ├── WhyInkReach.vue
│ ├── CompanyProfile.vue │ ├── CompanyProfile.vue
│ ├── CustomerCases.vue │ ├── CustomerCases.vue
│ └── CtaBanner.vue │ └── CtaBanner.vue
├── server/ # Nitro 后端 ├── server/ # Nitro 后端
│ ├── api/ │ ├── api/
│ │ ├── pod/ # 原有 SDS POD API 代理 │ │ ├── pod/ # 原有 SDS POD API 代理
│ │ └── backend/ # NestJS 后端代理(同源 + Nitro 缓存 60s │ │ └── backend/ # NestJS 后端代理(同源 + Nitro 缓存 60s
│ │ ├── categories.get.ts # → GET :3001/public/categories │ │ ├── categories.get.ts # → GET :3001/public/categories
│ │ ├── countries.get.ts # → GET :3001/public/countries │ │ ├── countries.get.ts # → GET :3001/public/countries
│ │ ├── tags.get.ts # → GET :3001/public/tags │ │ ├── tags.get.ts # → GET :3001/public/tags
│ │ ├── tag-groups.get.ts # → GET :3001/public/tag-groups │ │ ├── tag-groups.get.ts # → GET :3001/public/tag-groups
│ │ ├── goods.get.ts # → GET :3001/public/goods │ │ ├── goods.get.ts # → GET :3001/public/goods
│ │ └── goods/[id].get.ts # → GET :3001/public/goods/:id │ │ └── goods/[id].get.ts # → GET :3001/public/goods/:id
│ └── utils/pod-api.ts │ └── utils/pod-api.ts
├── public/ # 静态资源 ├── public/ # 静态资源
├── plans/feature/ # 历史功能计划 ├── plans/feature/ # 历史功能计划
├── docs/ ├── docs/
│ ├── references/structs.md # 子项目级结构文档 │ ├── references/structs.md # 子项目级结构文档
│ └── superpowers/specs/ │ └── superpowers/specs/
├── nuxt.config.ts # runtimeConfig.public.backendUrl ├── nuxt.config.ts # runtimeConfig.public.backendUrl
├── .env # NUXT_PUBLIC_BACKEND_URL=http://localhost:3001 ├── .env # NUXT_PUBLIC_BACKEND_URL=http://localhost:3001
├── AGENTS.md # 子项目 AI Agent 规范 ├── AGENTS.md # 子项目 AI Agent 规范
├── README.md ├── README.md
└── package.json └── package.json
``` ```
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`。 > 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`。
## 数据库 ## 数据库
- `apps/api/public/product-center/`:产品中心 Figma 国家旗帜与一级品类图标,由 `/assets/product-center/*` 对外提供。 - `apps/api/public/product-center/`:产品中心 Figma 国家旗帜与一级品类图标,由 `/assets/product-center/*` 对外提供。
- `apps/api/prisma/configure-product-center-icons.ts`:按名称幂等写入 `countryIcon/categoryIcon`,不创建业务记录。 - `apps/api/prisma/configure-product-center-icons.ts`:按名称幂等写入 `countryIcon/categoryIcon`,不创建业务记录。
## 产品中心设计还原(2026-07-16 ## 产品中心设计还原(2026-07-16
- `apps/website/app/components/product/ProductCenterHeader.vue`Figma 产品中心专用 80px 导航栏。 - `apps/website/app/components/product/ProductCenterHeader.vue`Figma 产品中心专用 80px 导航栏。
- `apps/website/app/components/product/ProductSidebar.vue`:240px 分类树及移动端抽屉内容。 - `apps/website/app/components/product/ProductSidebar.vue`:240px 分类树及移动端抽屉内容。
- `apps/website/app/components/product/ProductCountryFilter.vue`:国家胶囊筛选。 - `apps/website/app/components/product/ProductCountryFilter.vue`:国家胶囊筛选。
- `apps/website/app/components/product/ProductTagFilter.vue`:物流与工艺分组筛选。 - `apps/website/app/components/product/ProductTagFilter.vue`:物流与工艺分组筛选。
- `apps/website/app/components/product/ProductCard.vue`270 x 382 桌面商品卡片。 - `apps/website/app/components/product/ProductCard.vue`270 x 382 桌面商品卡片。
- `apps/website/app/components/product/ProductPagination.vue`:总数、页码、每页数量和跳转。 - `apps/website/app/components/product/ProductPagination.vue`:总数、页码、每页数量和跳转。
- `apps/website/test/useProductCenter.test.ts`:默认筛选名称映射测试。 - `apps/website/test/useProductCenter.test.ts`:默认筛选名称映射测试。
- `docs/references/product-center.md`:产品中心使用与验证说明。 - `docs/references/product-center.md`:产品中心使用与验证说明。
- `skills/inkreach-official-website/SKILL.md`Agent 使用说明。 - `skills/inkreach-official-website/SKILL.md`Agent 使用说明。
## 官网首页设计还原(2026-07-16 ## 官网首页设计还原(2026-07-16
- `apps/website/app/pages/index.vue`:官网首页组合入口。 - `apps/website/app/pages/index.vue`:官网首页组合入口。
- `apps/website/app/components/HeroBanner.vue``AppFooter.vue`:首页分区组件。 - `apps/website/app/components/HeroBanner.vue``AppFooter.vue`:首页分区组件。
- `apps/website/public/case-*.png`Figma 用户案例商品图。 - `apps/website/public/case-*.png`Figma 用户案例商品图。
- `docs/references/homepage.md`:首页结构、尺寸基准和验证说明。 - `docs/references/homepage.md`:首页结构、尺寸基准和验证说明。
- `apps/website/test/HomepageNavigationCarousel.test.ts`:首页当前页 CTA、自动轮播、主视觉图片和临时导航隐藏的行为测试。 - `apps/website/test/HomepageNavigationCarousel.test.ts`:首页当前页 CTA、自动轮播、主视觉图片和临时导航隐藏的行为测试。
## 数据库 ## 数据库
- `apps/api/src/public/public.service.ts`:官网公共商品序列化边界;响应中的商品 `id` 使用 `originGood.sdsGoodId`,不暴露本地 `goods.good_id` - `apps/api/src/public/public.service.ts`:官网公共商品序列化边界;响应中的商品 `id` 使用 `originGood.sdsGoodId`,不暴露本地 `goods.good_id`
- PostgreSQL 14+Prisma 5.x - PostgreSQL 14+Prisma 5.x
- 连接配置在根 `.env``DATABASE_URL` - 连接配置在根 `.env``DATABASE_URL`
- 所有 `TIMESTAMPTZ` 列:`@db.Timestamptz(6)` - 所有 `TIMESTAMPTZ` 列:`@db.Timestamptz(6)`
- 所有主键:`BigInt @default(autoincrement())` - 所有主键:`BigInt @default(autoincrement())`
- 表名与列名通过 `@map` / `@@map` 映射为 `snake_case` - 表名与列名通过 `@map` / `@@map` 映射为 `snake_case`
- 迁移位于 `apps/api/prisma/migrations/` - 迁移位于 `apps/api/prisma/migrations/`
## 环境变量总览 ## 环境变量总览
| 变量 | 位置 | 用途 | 默认 | | 变量 | 位置 | 用途 | 默认 |
|------|------|------|------| |------|------|------|------|
| `DATABASE_URL` | 根 `.env` | PostgreSQL 连接串 | — | | `DATABASE_URL` | 根 `.env` | PostgreSQL 连接串 | — |
| `JWT_SECRET` | `apps/api` | JWT 签名密钥 | — | | `JWT_SECRET` | `apps/api` | JWT 签名密钥 | — |
| `PORT` | `apps/api` | 后端端口 | `3001` | | `PORT` | `apps/api` | 后端端口 | `3001` |
| `SDS_API_*` | `apps/api` | 同步上游 SDS 接口凭据 | — | | `SDS_API_*` | `apps/api` | 同步上游 SDS 接口凭据 | — |
| `VITE_API_BASE` | `apps/admin` | axios baseURL | `/api` | | `VITE_API_BASE` | `apps/admin` | axios baseURL | `/api` |
| `NUXT_PUBLIC_BACKEND_URL` | `apps/website` | NestJS 后端地址 | `http://localhost:3001` | | `NUXT_PUBLIC_BACKEND_URL` | `apps/website` | NestJS 后端地址 | `http://localhost:3001` |
+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 即可。
+233
View File
@@ -0,0 +1,233 @@
全部
# 工厂直发国家/地区列表
# 美国
## 男士T恤
### DG001 180G纯棉T恤 JSA002
### DG004 230G水洗T恤(JSA003
### DG013 230G脏洗T恤
### JSA006 全棉重磅T恤
### JSS001 美国足球球衣T恤
### DG015 180G纯棉T恤
### C1717 207G重磅纯棉T恤
## 女士T恤
### DG100 180G女士彩棉T恤
### DG101 180G纯棉女士露脐T恤
### DG102 高弹力女款露脐
### DG110 180G大码彩棉女T恤
### DG120 牛奶丝T恤
### DG150 180G纯棉女士T恤
### DG502 180G女士圆领长袖T恤
### DG601 牛奶丝裙子
### JSA008 180G女士短装T恤
### JSA011 冰丝螺纹V领女士T恤
### JSE008 180g女士水洗露脐装
## 童装T恤
### DG012 180G青少年纯棉T恤
### DG014 190G小童T恤
### DG301 190G纯棉哈哈衣
### DG503 190G小童长袖
### 5000B 180G吉尔丹童装T恤
### JSA004 190G牛奶丝小童T恤
## 男士短裤
### DG201 300G脏洗短裤
### DG202 260G水洗短裤(JSD007
### DG206 240G涤纶休闲短裤
### JSD001 180G高弹纤维男士短裤
## 女士短裤
### DG205 牛奶丝短裤
### JSD002 180G高弹纤维女士短裤
### JSD003 女士短款瑜伽运动裤
### JSD004 女士高弹中长款瑜伽运动裤
### JSD005 女士高弹瑜伽长裤
## 童装短裤
### DG204 190G小童短裤
## 男士背心
### VS001 180G男款背心(JSA005
### VS003 210G水洗背心 JSA007
## 女士背心
### VS002 180G女款彩棉背心
### VS004 V领彩棉背心
### JSA009 180G女士镂空背心
## 男士卫衣
### DF009 270G连帽卫衣
### DF010 400G水洗做旧连帽卫衣
### JSB001 280G薄绒男士带帽卫衣
### JSB002 380G薄绒男士迷彩卫衣
### JSB003 380G男士薄绒圆领卫衣
## 女士卫衣
### DG501 250G女士圆领卫衣
## 童装卫衣
### DG504 280G小童圆领卫衣
## 男士长裤
### DG210 280G高级抓绒慢跑卫裤
## 内衣
### DG701 170G女士无痕三角内裤
### JSA010 270G高弹力女士抹胸
### JSE001 180G女士无痕内衣
## 家居
### USIU002 280G法兰绒毛毯
### USIU005 法兰绒地垫/科技布地垫
### USIU012 天鹅绒地毯
## 配饰
### YSM01 7X7全棉磨毛帽子
### YSM02 全棉斜纹拼色帽子(黑+卡其)
# 英国
## 男士T恤
### GBTM001 180g纯棉T恤
### GBTM010 成人POLO衫
### GBTM011长袖T
### GBTM013 230G水洗T恤
## 女士T恤
### GBTF004 180g女装纯棉T恤
## 男士卫衣
### GBHM002 280g圆领卫衣
### GBHM003 280g连帽卫衣
## 童装T恤
### GBTK005 180g童装纯棉T恤
### GBTK008 190g童装POLO短袖
### GBTK012 婴儿长袖哈哈衣
## 童装卫衣
### GBHK006 280g童装圆领卫衣
### GBHK007 280g童装连帽卫衣
## 家居配饰
### GBIU014 280g法兰绒毛毯
### GBIU015 超细纤维沙滩巾
### GBIU016 条形桌垫地垫
## 印花烫贴
### GBIU009 定制立体印花烫画贴
## 桌面用品
### GBIU017 鼠标垫
# 日本
## 男士T恤
### JPTM001 200g纯棉T恤
### JPTM004 250g纯棉渐变T恤
### JPTM006 200g纯棉长袖
### JPTM007 230g水洗T恤
## 童装T恤
### JPTK015 200g童装纯棉T恤
## 男士背心
### JPTM008 纯棉背心
### JPTM002 230g水洗背心
## 男士卫衣
### JPHM009 280g连帽卫衣
### JPHM010 280g圆领卫衣
### JPPX012 280g连帽卫衣+卫裤套装
### JPPX013 圆领卫衣+卫裤套装
## 下装(短裤 & 长裤)
### JPSM018 240G华棉短裤
### JPPM011 男士/女士320克华棉卫裤
## 家居配饰
### JPIU020 沙滩巾
### JPIU021 280G法兰绒毛毯
### JPIU014 帆布袋
# 墨西哥
## 男士T恤
### METB001 180g纯棉男士T恤
### PET001 男士涤纶T恤
### MESXT001 230g水洗T恤
## 女士T恤
### METP001 180g纯棉女装T恤
## 童装T恤
### METN001 180g纯棉童装T恤
## 男士卫衣
### HM01 280g连帽卫衣
## 男士背心
### TBB001 篮球圆领背心
## 下装(短裤 & 长裤)
### SFB 运动短裤
# 巴西
## 男士T恤
### BRTM001 180g纯棉T恤
### BRTM002 水洗T恤
## 女士T恤
### BRTF004 女款紧身露背T恤
### BRTF007 女士短款露脐T恤
# 中东
## 男士T恤
### SATU001 180g男装纯棉T恤
## 女士T恤
### SATU001 180g女装纯棉T恤
## 男士卫衣
### SAHM003 270g连帽卫衣
## 童装T恤
### SATK004 180g童装纯棉T恤
# 波兰
## 男士T恤
### PLTM001 190g纯棉T恤(QLTC9000
### PLTM031 大码纯棉T恤(QLTC9000
### PLTM008 190G纯棉彩色T恤(QLTC9000
### PLTM006 230G水洗T恤(PLTM006
### PLTM024 水洗破洞T恤(SS508
## 女士T恤
### PLTF004 女款紧身T恤(SS5015
### PLTF026 女款修身长袖T恤(WT01180
### PLTF027 女款插肩袖短款(SST507
### PLTF028 女款露脐T恤(SST508
## 男士卫衣
### PLHM002 圆领卫衣(TWZ360
### PLHM003 连帽卫衣(MWZKD360
## 童装T恤
### PLTK005 童装纯棉T恤(KT01180
### PLHM012 女士童装短款纯棉T恤(SST506)
### PLHM013 女士童插肩短袖T恤(SST5010
### PLTK015 女士童短款落肩纯棉T恤(DTG180)
### PLTK016 童装纯色插肩短袖T恤(DTG180)
## 女士卫衣
### PLHF029 女士短款长袖帽衫卫衣(SS513)
## 下装(短裤 & 长裤)
### PLHM011 女款短裤(SS510250
### PLPM014 男式卫裤(WKC260
### PLSU025 男女同款五分裤(QP01280
## 童装卫衣
### PLHM009 男女童同款抓毛圆领卫衣(TGZ360)
### PLHM010 男女童同款抓毛连帽卫衣(MGZ360)
## 男士夹克
### PLJM030 牛仔外套(DKJKT0010
# 西班牙
## 男士T恤
### ESTM002 190g纯棉T恤
## 童装T恤
### ESTK005 童装纯棉T恤
## 男士卫衣
### ESHM003 圆领卫衣
### ESHM004 连帽卫衣
# 德国
## 男士T恤
### DETM001 180g纯棉T恤
### DETM002 230水洗T恤
## 男士卫衣
### DEHM003 250g连帽有绳卫衣
## 下装(短裤 & 长裤)
### DEPM004 255g卫裤
# 意大利
## 男士T恤
### ITTM001 180g纯棉T恤
## 女士T恤
### ITFM005 180G女士纯棉短袖T恤
## 童装T恤
### ITTK002 150G童装圆领短袖T恤
# 加拿大
## 男士T恤
### CATM001 180g纯棉T恤
### CATM006 180G成人足球运动服
### CATM007 230G纯棉水洗T恤
## 男士卫衣
### CAHM002 280g连帽卫衣
# 澳大利亚
## 男士T恤
### AUTM001 180g纯棉T恤
### AUTM002 220g水洗T恤
## 男士背心
### AUTM003 200g纯棉背心
# 韩国
## 男士T恤
### KRTM001 200g纯棉T恤
### KRTM004 200G纯棉长袖
## 男士卫衣
### KRHM002 圆领卫衣
### KRHM003 连帽卫衣
# 中国(国内工厂)
## 男士T恤
### 180g纯棉T恤
### 230g水洗T恤