Author SHA1 Message Date
yeuimu 3a8e8e8948 merge: refactor/public-capacity-10k-yeuimu into develop (P0 性能整改——public 读路径分域缓存+全写路径失效、nginx 承压调优、连接池/PG 参数,已部署验证) 2026-09-03 09:32:45 +08:00
yeuimu 2d2aeb655a docs(perf): P0 生产部署结果回填——备份存档、23s 切换、缓存/gzip/限流/PG 参数实测数据 2026-09-03 08:44:57 +08:00
yeuimu 027ef2b296 docs: 性能整改落地文档——structs 补缓存层与部署参数说明、报告标注 P0 实施进度、AGENTS 沉淀切片缓存陷阱与 bullseye 跑测经验 2026-09-03 03:40:33 +08:00
yeuimu 9f856858e2 perf(deploy): 边缘 nginx 承压调优与连接池/PG 参数(P0-2/P0-3)
- 主配置 nginx.conf 进镜像(worker_connections 16384 + rlimit 65536 + gzip
  + 带上游耗时的访问日志):官方默认 1024×4=4096 连接硬顶是 1w 并发第一道墙
- admin.conf:upstream keepalive 64(proxy_http 1.1 + Connection "")、
  /public/ /v2-api/ 限流 50r/s burst 200(429)、代理超时 5s/30s、
  /uploads/ /assets/ 30d 缓存头;admin.v2.conf 的 /v2/h5/ 哈希产物长缓存
- compose:PG 显式参数(statement_timeout=10s/max_connections=200/
  shared_buffers=512MB)、DATABASE_URL connection_limit=50&pool_timeout=3
  (单位秒,勿写毫秒;勿加 statement_cache_size=0)、admin ulimits nofile 65536
- 部署注意:主配置走镜像须 rebuild;PG 参数重启前按全局约束 pg_dump+快照存档
2026-09-03 03:40:28 +08:00
yeuimu ab79325ab0 perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并
- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间
  bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED
  /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块
- PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行,
  修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/
  族最低价聚合各按依赖域缓存
- 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/
  positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶——
  事务提交成功后失效对应域,product-families 经 recompute 天然覆盖
- 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
2026-09-03 03:40:20 +08:00
yeuimu ca38476047 docs(perf): public 端口 1w 瞬时并发审查报告与整改计划——逐层瓶颈证据、P0/P1/P2 分级、全局约束(DB 变更先备份/写路径必失效缓存) 2026-09-03 03:40:12 +08:00
yeuimu 44de8e670f merge: bugfix/no-print-links-removal-yeuimu into develop (不打印链接移出族,SKU 矩阵恢复烫画) 2026-09-03 02:53:14 +08:00
yeuimu dd6a540bf3 fix(product-families): 不打印链接移出族(纯数据修复)——矩阵不再计算不打印工艺,BRTF004 恢复烫画×单面
- 生产数据操作(未改源代码):11 条光板/不打印链接 family_id 置空(族 1/2/63/135/148),
  名下 3 商品脱族,族 148 主链接 964→965、族 135 主链接置空
- 容器内直调已部署 FamilyRecomputeService 重算 5 族:全库矩阵不再含「不打印」,
  族 148 = 烫画×单面印花×不包邮(min 19),public 端口验证通过
- 回滚存档:deploy/backups/20260903-noprint-removal/(全库 dump + 受影响行 CSV + RESTORE.md)
- 文档:product-center/structs 记录数据约定与回流风险(organize/autoGroup/人工改标签会回挂散链接);
  AGENTS.md 沉淀 占位借词表值/物化JSON需显式重算/数据修复三件套 三条经验
2026-09-03 02:53:10 +08:00
yeuimu 0d2a954153 deploy(single-stack): 单栈收敛——v2 并入 deploy compose,v1 退役
- docker-compose.yml 重写为单栈 4 容器(admin 边缘 + v2-api/v2-admin/v2-postgres),
  v2 数据卷 deploy-v2_pgdata/uploads 以 external 引用,数据零迁移
- nginx:/uploads/ /assets/ 上游改 v2-api(product-center 覆盖块随之冗余移除),
  / 跳转 /v2/admin/;docker-compose.v2.yml 删除(血统已并入 develop)
- v1 api+postgres 已停用未删,终档/快照/回滚手册见 deploy/backups/consolidation-*/
- 切换验证:v2 库切换前后行数完全一致;/v2-api /public /v2/admin /v2/h5
  /assets 全路由冒烟 + 公网端到端通过
2026-09-03 02:09:24 +08:00
yeuimu a554b77379 fix(product-families): 热转印词表以 v2 为准——归因不再映射回烫画
合并冲突的行为级裁决:develop 侧 memberMatrixCombos 曾把 热转印→烫画
归一(旧别名方案),与 v2 线上一等公民词表(8903afd)矛盾,导致 v2 侧
三个矩阵用例失败。取 v2 版 family-recompute.service.{ts,spec}(即当前
线上行为)。另修复 v2 分支 structs.md 残留的冲突标记。

Tests: api 23 suites/217 passed (runInBand), admin 27 passed, tsc clean
2026-09-03 02:00:33 +08:00
yeuimu 5e69397adc merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干) 2026-09-03 01:54:32 +08:00
yeuimu 37353470e7 fix(categories): 分类名去括号——同步入库整段删除括号段
左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号
(黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖,
清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token
保留,剥空守卫),另附幂等存量脚本 fix:category-names。

已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建
部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。

Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean
2026-09-03 01:27:38 +08:00
yeuimu 9eafac7204 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按
首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名
误显示成 PLTK016。

- admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫,
  cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留)
- api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/
  batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述
- 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库)
- 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0

Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
2026-09-03 01:27:26 +08:00
yeuimu 0ffab120e9 merge: bugfix/category-name-parens-yeuimu into develop (分类名去括号) 2026-09-03 01:20:17 +08:00
yeuimu 814f45c5a3 fix(categories): 分类名去括号——同步入库整段删除括号段
左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号
(黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖,
清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token
保留,剥空守卫),另附幂等存量脚本 fix:category-names。

已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建
部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。

Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean
2026-09-03 01:20:17 +08:00
yeuimu 39eb06b6a2 merge: bugfix/pure-sku-good-name-yeuimu into develop (商品名剥型号限定词 + 纯款号补描述) 2026-09-03 01:04:26 +08:00
yeuimu c3be8ab979 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按
首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名
误显示成 PLTK016。

- admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫,
  cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留)
- api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/
  batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述
- 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库)
- 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0

Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
2026-09-03 01:04:22 +08:00
yeuimu ae9901e6cc merge: bugfix/family-auto-merge-auth-cookie-yeuimu into develop (SKU 自动合并 + 掉线修复) 2026-09-02 18:40:22 +08:00
yeuimu f1e81872e7 fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path
问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复:
- 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES
  别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效)
- updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 →
  缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags)
- autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有
  autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告
- 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键
  familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复

附加:
- updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带
  attachedFamilyId)
- 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除
  (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告
- admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数

问题1(后台频繁 Unauthorized 掉线):
- refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器
  永远带不上 refresh cookie → 两 cookie path 统一为 '/'
- v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 →
  request.ts 规范拼接

测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误;
public.service 夹具改为自包含(不依赖共享库既有数据)。
2026-09-02 18:39:35 +08:00
yeuimu b81d944c38 docs(agent): add dimension-value onboarding lesson 2026-09-02 16:37:56 +08:00
yeuimu f09a629311 merge: add heat-transfer craft to matrix vocabularies 2026-09-02 16:34:27 +08:00
yeuimu e34398a002 deploy(nginx): route /assets/product-center/ to v2 api for shared icons; ignore backups/ 2026-09-02 11:40:51 +08:00
yeuimu 5c5fb88f58 merge: refactor/v2 (v2 stack deploy, new h5 build, miniprogram assets) into develop 2026-09-01 16:30:43 +08:00
65 changed files with 4282 additions and 1517 deletions
+119 -106
View File
@@ -1,106 +1,119 @@
# AGENT.md - AI Agent 开发规范
## 开发流程
### 1. 梳理需求
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
| 需求类型 | 计划目录 | 命名规则 | 示例 |
| -------- | ----------------- | --------------------- | ----------------------- |
| 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` |
| 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` |
| 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` |
使用 writing-plans 技能制定实施计划
**开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。
可参考的开发文档位于 `docs/dev/*`
### 2. 建立开发分支
确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。
### 3. 按照需求开发
按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。
**重要原则:所有测试必须通过**
- 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试
- 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能
- 只有在所有测试都通过的情况下,才能认为开发完成
所有任务完成后请遵循 verification-before-completion 技能完成验证
### 4. 合并到开发分支
所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。
### 5. 沉淀开发经验
在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。
### 6. 更新项目结构文档
每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。
### 7. 更新使用文档
使用文档分为:
1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。
2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。
3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。
**快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。
**详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。
**AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。
## 开发规范
### 代码优化
遵循 improve-codebase-architecture
### 类型规范
参考技能 typescript-advanced-types
### 测试规范
参考技能 javascript-testing-patterns
### 测试
每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts`
## 数据库设计
PostgreSQL 最佳设计实践: postgresql-table-design 技能
PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能
Prisma Postgres 最佳实践: prisma-postgres 技能
### 后端规范
参考 nestjs-best-practices 技能
> 你可以自己增加规范
### 经验沉淀(跨项目通用)
- **入口 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 数据卷共享,重建不丢数据。
- **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件。
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
- **跑全量 jest 前先停 dev server**`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
- **pnpm 仓库容器化执行要挂仓库根**pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性
# AGENT.md - AI Agent 开发规范
## 开发流程
### 1. 梳理需求
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
| 需求类型 | 计划目录 | 命名规则 | 示例 |
| -------- | ----------------- | --------------------- | ----------------------- |
| 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` |
| 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` |
| 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` |
使用 writing-plans 技能制定实施计划
**开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。
可参考的开发文档位于 `docs/dev/*`
### 2. 建立开发分支
确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。
### 3. 按照需求开发
按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。
**重要原则:所有测试必须通过**
- 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试
- 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能
- 只有在所有测试都通过的情况下,才能认为开发完成
所有任务完成后请遵循 verification-before-completion 技能完成验证
### 4. 合并到开发分支
所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。
### 5. 沉淀开发经验
在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。
### 6. 更新项目结构文档
每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。
### 7. 更新使用文档
使用文档分为:
1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。
2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。
3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。
**快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。
**详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。
**AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。
## 开发规范
### 代码优化
遵循 improve-codebase-architecture
### 类型规范
参考技能 typescript-advanced-types
### 测试规范
参考技能 javascript-testing-patterns
### 测试
每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts`
## 数据库设计
PostgreSQL 最佳设计实践: postgresql-table-design 技能
PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能
Prisma Postgres 最佳实践: prisma-postgres 技能
### 后端规范
参考 nestjs-best-practices 技能
> 你可以自己增加规范
### 经验沉淀(跨项目通用)
- **入口 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 数据卷共享,重建不丢数据。
- **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件。
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
- **跑全量 jest 前先停 dev server**`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
- **pnpm 仓库容器化执行要挂仓库根**pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败;仓库 `.pnpm` store 里生成的 client 是 openssl-1.1.x target**bookworm 系镜像(node:20-slim)同样跑不了**,用 `node:20-bullseye-slim` 挂仓库根 + `--network host`(连本机一次性测试库)跑 jest/prisma 即可,无需依赖项目运行镜像
- **缓存值里绝不能带请求级切片(分页/字段裁剪)**:把 `items.slice(page…)` 的结果整个塞进缓存后,缓存键不含 page → 所有页码命中同一条目、页页返回第一页(total 对得上更具迷惑性);正确做法是缓存"全量物化结果",切片在缓存命中后按请求执行。TDD 用例必须包含"同键翻页 + 页内容随页码变化"的断言才能抓住这类错
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→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`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
- **合并分叉血统时警惕"文本无冲突、行为有冲突"**:git 自动合并可能把两侧对同一机制的不同设计(如 热转印=烫画别名 vs 一等公民词表)拼进一个文件,编译通过但行为矛盾——合并后必须跑两侧血统各自的测试才能暴露;行级冲突反而不是最危险的。另:`git status | head` 截断会漏看冲突文件,连续 cherry-pick 后要全仓 `grep '<<<<<<<'` 扫一遍。
- **生产栈收敛三件套**:动栈结构前先 `pg_dump -Fc` 双库 + 数据卷归档 + 配置快照落 `deploy/backups/<时间戳>/` 并写 RESTORE.md;切换用"外部卷 external 引用 + down 不带 -v + 切换前后行数 diff"保证数据零丢失;先 `docker compose build` 预构建再 down/up,把停机窗口压到秒级。
- **矩阵维度占位不得借用业务词表既有值**:曾让「不打印」链接的印花数量占位为「单面印花」,导致顾客端出现「不打印+单面印花」的自相矛盾组合(不打印根本无印花面)。2026-09-03 数据收口:光板/不打印链接不入族(`family_id=NULL`),矩阵不计算不打印工艺。**回流风险**:`organize`/`autoGroup` 会把散链接按分类重新归族、人工改链接标签会触发 `attachToMatchingFamily` 回挂——跑这两类操作前必须先排查光板链接(清单与回滚见 `deploy/backups/20260903-noprint-removal/`)。
- **重部署不会自愈物化 JSON**:`price_matrix` 是物化列,代码修好/词表修正后已物化的矩阵**不会自动重算**(重算只在归族/成员变化/详情同步/标签调整等事件时触发)——族 148 曾因旧容器物化的坏矩阵跨镜像存活,"代码明明对了线上却没变"。此类修复收尾必须显式触发受影响族重算(可容器内 `node -e` 直调 `FamilyRecomputeService.recomputeFamily`,用已部署代码、免重建免鉴权)。
- **批量数据修复前"全量 dump + 受影响行快照 + RESTORE.md"三件是底线**:全量 `pg_dump -Fc` 兜灾难恢复,受影响行 CSV(含改前外键值)支持精准回滚,RESTORE.md 写清逐表恢复命令与重算步骤;事务内逐步核对影响行数与预期一致再 COMMIT。
+4
View File
@@ -44,6 +44,10 @@ export const productFamiliesApi = {
linksUpdated: number
goodsUpdated: number
familiesCreated: number
familiesMerged: number
familiesSkipped: number
fragmentsConsolidated: number
fragmentsManualReview: number
familiesRecomputed: number
}>('/product-families/organize')
},
+4 -1
View File
@@ -36,8 +36,11 @@ request.interceptors.response.use(
) {
config._retried = true
try {
// 规范拼接:VITE_API_BASE 可能带尾斜杠(如 /v2-api/),直接相加会产生
// /v2-api//auth/refresh 双斜杠导致 404
const base = (import.meta.env.VITE_API_BASE || '/api').replace(/\/+$/, '')
await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
`${base}/auth/refresh`,
{},
{ withCredentials: true },
)
+4
View File
@@ -414,6 +414,10 @@ export interface OriginGoodTagInfo {
export interface OriginGoodTagsResult {
tagsManual: boolean
tags: OriginGoodTagInfo[]
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
filledDimensionTags?: string[]
/** 保存时链接无族且自动并入成功的目标族 id */
attachedFamilyId?: string
}
export interface ProductFamilyMember {
+8 -2
View File
@@ -10,11 +10,17 @@ describe('sameFamily', () => {
).toBe(true)
})
it('任一方 familyId 为空 → 回退名称 3 段规则', () => {
it('任一方 familyId 为空 → 回退族语义键(国家+品名+SKU,物流/工艺不分族)', () => {
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(false)
// 物流/工艺是矩阵维度而非分族维度:包邮 vs 不包邮、单面 vs 双面 同款同族
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(true)
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-双面印花-美西一仓'))).toBe(true)
// 不同款号不同族
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG002-单面印花'))).toBe(false)
// 不同国家不同族
expect(sameFamily(a, node(null, '英国(包邮)T恤-DG001-单面印花'))).toBe(false)
})
it('familyId 不同 → false(即使名称同段)', () => {
+4 -4
View File
@@ -1,14 +1,14 @@
import { sameOriginGroup } from './origin-name'
import { familyNameKey } from './origin-name'
/**
* 两个原产品节点是否属于同一产品族:
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退名称 3 段规则
* (老数据/未回填场景兜底)。
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退族语义键
* 国家+品名+SKU —— 物流/工艺是矩阵维度不分族;老数据/未回填场景兜底)。
*/
export function sameFamily(
a: { familyId?: string | null; goodName?: string | null },
b: { familyId?: string | null; goodName?: string | null },
): boolean {
if (a.familyId && b.familyId) return a.familyId === b.familyId
return sameOriginGroup(a.goodName, b.goodName)
return familyNameKey(a.goodName) !== '' && familyNameKey(a.goodName) === familyNameKey(b.goodName)
}
+32
View File
@@ -80,6 +80,38 @@ describe('parseLinkName / cleanLinkName', () => {
});
});
describe('已规范化的「品名(型号限定词) SKU」防误解析 + 限定词剥离', () => {
it('品名内含(限定词)的已规范化名不再解析(否则展示会变成光款号)', () => {
expect(parseLinkName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBeNull();
expect(parseLinkName('牛奶丝T恤(女款) DG601')).toBeNull();
expect(parseLinkName('180G纯棉T恤 JSA002 DG001')).toBeNull();
});
it('cleanLinkName 对已规范化名剥离 ASCII 型号限定词', () => {
expect(cleanLinkName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBe(
'童装纯色插肩短袖T恤 PLTK016',
);
expect(cleanLinkName('180G纯棉T恤 JSA002 DG001')).toBe('180G纯棉T恤 DG001');
});
it('原始链接名品名内含限定词时,解析后一并剥离', () => {
expect(
cleanLinkName('波兰(包邮)童装纯色插肩短袖T恤(DTG180-PLTK016-双面印花'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('中文括号内容(如(女款))不剥离', () => {
expect(cleanLinkName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601');
});
it('无 SKU 段的单段链接名仍按首括号对解析(与 api 端一致)', () => {
expect(parseLinkName('波兰(包邮)童装T恤')).toEqual({
productName: '童装T恤',
skuCode: null,
});
});
});
describe('deriveLinkTagNames', () => {
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([
+51 -8
View File
@@ -19,6 +19,30 @@ export function sameOriginGroup(
return ka !== '' && ka === truncateToProcess(b);
}
/**
* 族语义分组键(与 api 端 familyNameKey 同构):第 1 段剥离(物流备注)括号,
* 保留 国家+品名+SKU,丢工艺/仓库段 —— 物流/工艺是价格矩阵维度而非分族维度。
*/
export function familyNameKey(name: string | null | undefined): string {
if (!name) return '';
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
if (!head) return '';
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
let stripped = normalized;
if (openAt >= 0) {
const closeAt = normalized.indexOf('', openAt);
if (closeAt > openAt) {
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
}
}
stripped = stripped.trim();
const sku = segs[1] ?? '';
if (!stripped) return sku || '';
return sku ? `${stripped}-${sku}` : stripped;
}
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
export interface ParsedLinkName {
productName: string;
@@ -27,27 +51,46 @@ export interface ParsedLinkName {
/**
* 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。
* 与 api 端 origin-name.parser.ts 同构;解析失败(无「国家(备注)」前缀结构)返回 null,
* 调用方应回退显示原始名称 —— 手动维护的品名不走该解析。
* 与 api 端 origin-name.parser.ts 同构(首括号对解析,半角/全角混用先归一);
* 解析失败(无「国家(备注)」前缀结构)返回 null,调用方应回退显示原始名称
* —— 手动维护的品名不走该解析。
*/
export function parseLinkName(name: string | null | undefined): ParsedLinkName | null {
if (!name) return null;
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
const closeIdx = Math.max(head.lastIndexOf(''), head.lastIndexOf(')'));
if (closeIdx <= 0 || closeIdx === head.length - 1) return null;
const productName = head.slice(closeIdx + 1).trim();
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
const closeAt = openAt >= 0 ? normalized.indexOf('', openAt) : -1;
if (closeAt <= openAt || closeAt === normalized.length - 1) return null;
const productName = normalized.slice(closeAt + 1).trim();
if (!productName) return null;
// 无 SKU 段时仅当括号后的品名含中文才视为链接名 —— 防把已规范化的
// 「品名(型号限定词) SKU」(如「童装…(DTG180) PLTK016」)的尾段款号误当品名
if (segs.length < 2 && !/[\u4e00-\u9fff]/.test(productName)) return null;
const skuCode = segs[1] || null;
return { productName, skuCode };
}
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */
/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */
const ASCII_PAREN_GROUP = /(?:|\()[A-Za-z0-9]+(?:|\))/g;
/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */
export function stripAsciiParenQualifiers(name: string): string {
const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim();
return stripped || name;
}
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名。品名内的 ASCII 型号限定词一并剥离 */
export function cleanLinkName(name: string | null | undefined): string {
if (!name) return '';
const parsed = parseLinkName(name);
if (!parsed) return name;
return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName;
const base = parsed
? parsed.skuCode
? `${parsed.productName} ${parsed.skuCode}`
: parsed.productName
: name;
return stripAsciiParenQualifiers(base);
}
const CRAFT_KEYWORD_MAP: Record<string, string> = {
+1 -1
View File
@@ -1024,7 +1024,7 @@ async function onOrganize() {
try {
const r = await productFamiliesApi.organize()
ElMessage.success(
`整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 重算 ${r.familiesRecomputed}`,
`整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 并入 ${r.familiesMerged} 链接 / 碎片合并 ${r.fragmentsConsolidated ?? 0}${r.fragmentsManualReview ? `(待人工合并 ${r.fragmentsManualReview} 族)` : ''} / 重算 ${r.familiesRecomputed}`,
)
await loadAll()
} catch (e: any) {
@@ -9,6 +9,7 @@ import { productFamiliesApi } from '@/api/product-families'
import { countriesApi } from '@/api/countries'
import { syncApi } from '@/api/sync'
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
import type { OriginGoodTagsResult } from '@/types'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
@@ -364,8 +365,9 @@ async function loadEditFamily() {
async function saveMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
ElMessage.success('标签已保存')
const res = await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
const filled = (res as OriginGoodTagsResult | undefined)?.filledDimensionTags ?? []
ElMessage.success(filled.length ? `标签已保存(缺维度已自动补齐:${filled.join('、')}` : '标签已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
+3
View File
@@ -24,6 +24,9 @@
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.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",
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
"organize": "ts-node prisma/backfill-product-families.ts",
"recompute:families": "ts-node prisma/recompute-all-families.ts"
},
+3 -2
View File
@@ -9,6 +9,7 @@
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { ProductFamiliesService } from '../src/product-families/product-families.service';
import { OrganizeService } from '../src/product-families/organize.service';
@@ -16,9 +17,9 @@ import { OrganizeService } from '../src/product-families/organize.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma);
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
const families = new ProductFamiliesService(prisma, recompute);
const organize = new OrganizeService(prisma, recompute, families);
const organize = new OrganizeService(prisma, recompute, families, new PublicCacheService());
const result = await organize.organize();
console.log(
+51
View File
@@ -0,0 +1,51 @@
/**
* 存量修复脚本(幂等):分类名去括号 —— 删除所有括号段(型号限定词/内容性括号,
* 整段删除)。分类名每小时被 SDS 同步覆盖,清洗已落在 syncCategories 入库路径;
* 本脚本用于部署后立即生效(不等下一轮同步)或修人工建的历史分类。
*
* 运行:
* pnpm --filter @inkreach/api fix:category-names # dry-run,只打印清单
* pnpm --filter @inkreach/api fix:category-names -- --apply # 实际写库
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { cleanCategoryDisplayName } from '../src/sync/sync.service';
async function main() {
const apply = process.argv.includes('--apply');
const prisma = new PrismaService();
await prisma.onModuleInit();
const rows = await prisma.category.findMany({
select: { id: true, categoryName: true },
orderBy: { id: 'asc' },
});
const renames = rows
.map((row) => ({
id: row.id,
from: row.categoryName,
to: cleanCategoryDisplayName(row.categoryName) ?? row.categoryName,
}))
.filter((r) => r.from !== r.to);
if (apply && renames.length > 0) {
await prisma.$transaction(
renames.map((r) =>
prisma.category.update({
where: { id: r.id },
data: { categoryName: r.to },
}),
),
);
}
const mode = apply ? 'APPLIED' : 'DRY-RUN(未写库,加 --apply 执行)';
console.log(`[fix:category-names] ${mode} renamed=${renames.length}`);
for (const r of renames) {
console.log(` #${r.id} ${r.from} -> ${r.to}`);
}
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,44 @@
/**
* 存量修复脚本(幂等):把纯款号商品名(如 `PLTK016`)按主链接 SDS 分类名
* 补成「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。
*
* 运行:
* pnpm --filter @inkreach/api fix:good-names # dry-run,只打印清单
* pnpm --filter @inkreach/api fix:good-names -- --apply # 实际写库
* origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.service';
import { SyncService } from '../src/sync/sync.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { GoodsService } from '../src/goods/goods.service';
async function main() {
const dryRun = !process.argv.includes('--apply');
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
// 回填路径不触发详情同步,SyncService 仅作占位依赖
const goods = new GoodsService(
prisma,
{ queueProductDetailSync: async () => undefined } as unknown as SyncService,
recompute,
new PublicCacheService(),
);
const result = await goods.backfillPureSkuGoodNames({ dryRun });
const mode = dryRun ? 'DRY-RUN(未写库,加 --apply 执行)' : 'APPLIED';
console.log(`[fix:good-names] ${mode} renamed=${result.renamed} unmapped=${result.unmapped.length}`);
for (const r of result.renames) {
console.log(` #${r.id} ${r.from} -> ${r.to}`);
}
for (const u of result.unmapped) {
console.log(` #${u.id} ${u.from} -> (无分类映射,保留原名)`);
}
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+2 -1
View File
@@ -7,12 +7,13 @@
* 运行:pnpm --filter @inkreach/api recompute:families
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.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 recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
const families = await prisma.productFamily.findMany({
select: { id: true },
+3
View File
@@ -15,6 +15,7 @@ import { ProductFamiliesModule } from './product-families/product-families.modul
import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module';
import { PublicCacheModule } from './public/public-cache.module';
import { UploadModule } from './upload/upload.module';
@Module({
@@ -31,6 +32,8 @@ import { UploadModule } from './upload/upload.module';
},
]),
PrismaModule,
// public 读路径缓存(分域版本失效,@Global 供各写路径注入 bump 入口)
PublicCacheModule,
AuthModule,
CountriesModule,
CategoriesModule,
+6 -5
View File
@@ -64,8 +64,9 @@ export class AuthController {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
// Only ever sent to /auth/refresh and /auth/logout
path: '/auth',
// 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path
// 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线
path: '/',
});
// The refresh token deliberately stays HttpOnly-only.
return {
@@ -86,7 +87,7 @@ export class AuthController {
): Promise<LoginResponseDto> {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
if (!token) {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
throw new UnauthorizedException('Missing refresh token');
}
const result = await this.authService.refresh(token);
@@ -100,7 +101,7 @@ export class AuthController {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/auth',
path: '/',
});
return {
accessToken: result.accessToken,
@@ -126,7 +127,7 @@ export class AuthController {
): Promise<{ success: true }> {
await this.authService.logout(req.user.id);
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
return { success: true };
}
}
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import {
BadRequestException,
@@ -13,7 +14,7 @@ describe('CategoriesService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CategoriesService, PrismaService],
providers: [CategoriesService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(CategoriesService);
prisma = moduleRef.get(PrismaService);
+14 -4
View File
@@ -5,13 +5,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryNodeDto } from './dto/category-node.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
async findAll(): Promise<CategoryNodeDto[]> {
const all = await this.prisma.category.findMany({
@@ -37,7 +41,7 @@ export class CategoriesService {
// Validate the parent exists to produce a clean 404 instead of FK error.
await this.findOne(BigInt(dto.parentCategoryId));
}
return this.prisma.category.create({
const created = await this.prisma.category.create({
data: {
categoryName: dto.categoryName,
categoryIcon: dto.categoryIcon ?? null,
@@ -47,6 +51,8 @@ export class CategoriesService {
: BigInt(dto.parentCategoryId),
},
});
this.publicCache.bump('meta');
return created;
}
async update(id: bigint, dto: UpdateCategoryDto) {
@@ -66,7 +72,9 @@ export class CategoriesService {
? { disconnect: true }
: { connect: { id: BigInt(dto.parentCategoryId) } };
}
return this.prisma.category.update({ where: { id }, data });
const updated = await this.prisma.category.update({ where: { id }, data });
this.publicCache.bump('meta');
return updated;
}
async remove(id: bigint) {
@@ -80,7 +88,9 @@ export class CategoriesService {
);
}
try {
return await this.prisma.category.delete({ where: { id } });
const removed = await this.prisma.category.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import {
BadRequestException,
@@ -14,7 +15,7 @@ describe('CountriesService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CountriesService, PrismaService],
providers: [CountriesService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(CountriesService);
prisma = moduleRef.get(PrismaService);
+15 -4
View File
@@ -6,13 +6,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto';
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
@Injectable()
export class CountriesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll() {
return this.prisma.country.findMany({
@@ -30,6 +34,7 @@ export class CountriesService {
}),
),
);
this.publicCache.bump('meta');
return this.findAll();
}
@@ -43,12 +48,14 @@ export class CountriesService {
async create(dto: CreateCountryDto) {
try {
return await this.prisma.country.create({
const created = await this.prisma.country.create({
data: {
countryName: dto.countryName,
countryIcon: dto.countryIcon ?? null,
},
});
this.publicCache.bump('meta');
return created;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -63,13 +70,15 @@ export class CountriesService {
async update(id: bigint, dto: UpdateCountryDto) {
await this.findOne(id);
try {
return await this.prisma.country.update({
const updated = await this.prisma.country.update({
where: { id },
data: {
countryName: dto.countryName,
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
},
});
this.publicCache.bump('meta');
return updated;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -84,7 +93,9 @@ export class CountriesService {
async remove(id: bigint) {
await this.findOne(id);
try {
return await this.prisma.country.delete({ where: { id } });
const removed = await this.prisma.country.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
+225 -1
View File
@@ -1,9 +1,15 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import {
cleanGoodDisplayName,
describePureSkuGoodName,
GoodsService,
isPureSkuGoodName,
} from './goods.service';
import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
@@ -25,6 +31,7 @@ describe('GoodsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
PublicCacheService,
GoodsService,
PrismaService,
{
@@ -312,6 +319,223 @@ describe('GoodsService', () => {
});
});
describe('pure sku good name(纯款号补描述)', () => {
const pskuCode = `PSKU${stamp}`;
const pskuDesc = `童装纯色测试插肩T恤${stamp}`;
let pskuOriginGoodId: bigint;
let bareOriginGoodId: bigint;
beforeAll(async () => {
await prisma.category.create({
data: {
categoryName: `${pskuCode} ${pskuDesc}`,
sdsCategoryId: `psku-cat-${stamp}`,
},
});
const pskuOg = await prisma.originGood.create({
data: {
sdsGoodId: `sds-psku-${stamp}`,
goodName: pskuCode,
sdsCategoryId: `psku-cat-${stamp}`,
},
});
pskuOriginGoodId = pskuOg.id;
const bareOg = await prisma.originGood.create({
data: {
sdsGoodId: `sds-psku-bare-${stamp}`,
goodName: `PSKB${stamp}`,
},
});
bareOriginGoodId = bareOg.id;
});
afterAll(async () => {
await prisma.good.deleteMany({
where: { OR: [{ goodName: { contains: pskuCode } }, { goodName: { contains: `PSKB${stamp}` } }] },
});
await prisma.originGood.deleteMany({
where: { id: { in: [pskuOriginGoodId, bareOriginGoodId] } },
});
await prisma.category.deleteMany({
where: { sdsCategoryId: `psku-cat-${stamp}` },
});
});
it('isPureSkuGoodName:仅纯字母数字为真', () => {
expect(isPureSkuGoodName('PLTK016')).toBe(true);
expect(isPureSkuGoodName(' pltk016 ')).toBe(true);
expect(isPureSkuGoodName('PSKU1')).toBe(true);
expect(isPureSkuGoodName('童装纯色插肩短袖T恤 PLTK016')).toBe(false);
expect(isPureSkuGoodName('德国(不包邮)T恤-DG001-烫画')).toBe(false);
expect(isPureSkuGoodName('230g水洗T恤 DETM002')).toBe(false);
expect(isPureSkuGoodName('P')).toBe(false);
expect(isPureSkuGoodName('')).toBe(false);
});
it('cleanGoodDisplayName:品名内的 ASCII 型号限定词剥离,中文括号内容保留', () => {
expect(
cleanGoodDisplayName('波兰(包邮)童装纯色插肩短袖T恤(DTG180-PLTK016-双面印花'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
expect(cleanGoodDisplayName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBe(
'童装纯色插肩短袖T恤 PLTK016',
);
expect(cleanGoodDisplayName('180G纯棉T恤 JSA002 DG001')).toBe('180G纯棉T恤 DG001');
expect(cleanGoodDisplayName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601');
expect(cleanGoodDisplayName('230g水洗T恤 DETM002')).toBe('230g水洗T恤 DETM002');
});
it('describePureSkuGoodName:标准分类名剥款号补描述', () => {
expect(
describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('describePureSkuGoodName:分类名无款号前缀时用全名', () => {
expect(
describePureSkuGoodName('PLTK016', '童装纯色插肩短袖T恤'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('describePureSkuGoodName:分类名即款号/空分类名/非纯款号名原样返回', () => {
expect(describePureSkuGoodName('PLTK016', 'PLTK016')).toBe('PLTK016');
expect(describePureSkuGoodName('PLTK016', null)).toBe('PLTK016');
expect(describePureSkuGoodName('PLTK016', undefined)).toBe('PLTK016');
expect(
describePureSkuGoodName('童装T恤 PLTK016', 'PLTK016 童装T恤'),
).toBe('童装T恤 PLTK016');
expect(describePureSkuGoodName('230g水洗T恤 DETM002', 'PLTK016 童装T恤')).toBe(
'230g水洗T恤 DETM002',
);
});
it('describePureSkuGoodName:分类名中的 ASCII 型号限定词一并剥离', () => {
expect(
describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤(DTG180'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('create:纯款号名按主链接 SDS 分类名补描述', async () => {
const created = await service.create({
goodName: pskuCode,
originGoodId: Number(pskuOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('create:主链接无 sdsCategoryId 映射时纯款号原样保留', async () => {
const created = await service.create({
goodName: `PSKB${stamp}`,
originGoodId: Number(bareOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe(`PSKB${stamp}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('update:改成纯款号名按商品主链接分类名补描述', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} psku-rename`,
originGoodId: Number(pskuOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
const updated = await service.update(BigInt(created.id), {
goodName: pskuCode,
});
expect(updated.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('batchCreate:纯款号链接名补描述、完整链接名规范化', async () => {
const created = await service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
items: [{ originGoodId: Number(pskuOriginGoodId) }],
});
try {
expect(created[0]?.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.deleteMany({
where: { id: { in: created.map((g) => BigInt(g.id)) } },
});
}
});
it('backfillPureSkuGoodNamesdry-run 只列不改,apply 改名且幂等', async () => {
// 绕过写路径直写库,模拟存量纯款号名
const row = await prisma.good.create({
data: {
goodName: pskuCode,
originGoodId: pskuOriginGoodId,
countryId,
categoryId,
},
});
try {
// dry-run / apply 均用 goodId 限定在本夹具,避免测试副作用波及共享库真实数据
const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(dry.renames).toEqual([
{ id: row.id.toString(), from: pskuCode, to: `${pskuDesc} ${pskuCode}` },
]);
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(pskuCode);
const applied = await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id });
expect(applied.renamed).toBe(1);
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(`${pskuDesc} ${pskuCode}`);
// 幂等:已改名的行不再出现在后续清单
const again = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(again.renames).toEqual([]);
} finally {
await prisma.good.delete({ where: { id: row.id } });
}
});
it('backfillPureSkuGoodNames:剥离「品名(型号限定词) SKU」存量名中的限定词', async () => {
const row = await prisma.good.create({
data: {
goodName: `${pskuDesc}DTG180 ${pskuCode}`,
originGoodId: pskuOriginGoodId,
countryId,
categoryId,
},
});
try {
const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(dry.renames).toEqual([
{
id: row.id.toString(),
from: `${pskuDesc}DTG180 ${pskuCode}`,
to: `${pskuDesc} ${pskuCode}`,
},
]);
await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: row.id } });
}
});
});
describe('merged origin goods', () => {
it('creates a good with merged origin goods and reads them back', async () => {
const created = await service.create({
+182 -4
View File
@@ -14,6 +14,7 @@ import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PublicCacheService } from '../public/public-cache.service';
import { isAutoTagGroupName } from '../product-families/auto-tag-rules';
import { randomUUID } from 'crypto';
import {
@@ -72,12 +73,55 @@ export function normalizeGoodName(name: string): string {
return name;
}
/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */
const ASCII_PAREN_GROUP = /(?:|\()[A-Za-z0-9]+(?:|\))/g;
/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */
export function stripAsciiParenQualifiers(name: string): string {
const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim();
return stripped || name;
}
/**
* 商品展示名清洗:规范化 + 剥离 ASCII 型号限定词。
* SDS 品名常带型号限定词(如 `童装…(DTG180)`),展示名统一剥成「品名 SKU」。
*/
export function cleanGoodDisplayName(name: string): string {
return stripAsciiParenQualifiers(normalizeGoodName(name));
}
/** 纯款号名:仅字母/数字(如 `PLTK016`),链接名解析不出品名即此类 */
export function isPureSkuGoodName(name: string): boolean {
return /^[A-Za-z0-9]{2,}$/.test(name.trim());
}
/**
* 纯款号展示名兜底:用 SDS 分类名补描述。
* `PLTK016` + 分类名 `PLTK016 童装纯色插肩短袖T恤` → `童装纯色插肩短袖T恤 PLTK016`。
* 分类名首 token 仅当为纯字母数字时视为款号剥离(与 auto-group 的
* codeFromCategoryName 语义一致),描述中的 ASCII 型号限定词一并剥离;
* 非纯款号名、无分类名或剥后为空时原样返回。
*/
export function describePureSkuGoodName(
name: string,
categoryName: string | null | undefined,
): string {
if (!isPureSkuGoodName(name) || !categoryName?.trim()) return name;
const sku = name.trim();
const tokens = categoryName.trim().split(/\s+/);
const desc = stripAsciiParenQualifiers(
(/^[A-Za-z0-9]+$/.test(tokens[0] ?? '') ? tokens.slice(1) : tokens).join(' '),
);
return desc ? `${desc} ${sku}` : name;
}
@Injectable()
export class GoodsService {
constructor(
private readonly prisma: PrismaService,
private readonly syncService: SyncService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly publicCache: PublicCacheService,
) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
@@ -149,12 +193,12 @@ export class GoodsService {
// Good 的族是派生数据:主链接所属族
const primary = await tx.originGood.findUnique({
where: { id: BigInt(dto.originGoodId) },
select: { familyId: true },
select: { familyId: true, sdsCategoryId: true },
});
const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null);
const created = await tx.good.create({
data: {
goodName: normalizeGoodName(dto.goodName),
goodName: await this.resolveGoodName(tx, dto.goodName, primary?.sdsCategoryId),
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
familyId: primary?.familyId ?? null,
@@ -198,6 +242,7 @@ export class GoodsService {
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
}
this.publicCache.bump('goods');
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
@@ -272,6 +317,7 @@ export class GoodsService {
}
return good.id;
});
this.publicCache.bump('goods');
if (family) this.familyRecompute.enqueue(family.id);
return this.findOne(goodId);
}
@@ -327,6 +373,7 @@ export class GoodsService {
await tx.good.update({ where: { id }, data: goodData });
}
});
this.publicCache.bump('goods');
return this.findOne(id);
}
@@ -344,7 +391,13 @@ export class GoodsService {
await this.ensureMergedOriginGoods(mergedIds);
}
const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = normalizeGoodName(dto.goodName);
if (dto.goodName !== undefined) {
data.goodName = await this.resolveGoodName(
this.prisma,
dto.goodName,
await this.sdsCategoryIdForGood(id, dto.originGoodId),
);
}
if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
@@ -447,6 +500,7 @@ export class GoodsService {
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
await this.familyRecompute.recomputeFamily(familyIdForTags);
}
this.publicCache.bump('goods');
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
@@ -474,6 +528,7 @@ export class GoodsService {
}
}
});
this.publicCache.bump('goods');
return { id: id.toString() };
}
@@ -491,6 +546,7 @@ export class GoodsService {
}
return { count: dto.items.length };
});
this.publicCache.bump('goods');
return result;
}
@@ -518,7 +574,11 @@ export class GoodsService {
}
const row = await tx.good.create({
data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodName: await this.resolveGoodName(
tx,
og.goodName ?? `Origin Good ${og.sdsGoodId}`,
og.sdsCategoryId,
),
goodImage: og.goodImage,
originGoodId: og.id,
familyId: og.familyId,
@@ -582,9 +642,85 @@ export class GoodsService {
)) {
this.syncService.queueProductDetailSync(goodId);
}
this.publicCache.bump('goods');
return result;
}
/**
* 存量修复(幂等):商品展示名统一为「品名 SKU」——
* 1. 剥离 ASCII 型号限定词(如 `童装…(DTG180 PLTK016` → `童装… PLTK016`),
* 顺带把结构完整的原始链接名规范化;
* 2. 清洗后仍为纯款号(如 `PLTK016`)的,按主链接 SDS 分类名补描述。
* dryRun 只返回清单不写库;goodId 可将范围限定到单个商品(测试用)。
* 纯款号但无分类映射(补不出描述名)的行保持原样并计入 unmapped。
*/
async backfillPureSkuGoodNames(opts: {
dryRun: boolean;
goodId?: bigint;
}): Promise<{
applied: boolean;
renamed: number;
renames: { id: string; from: string; to: string }[];
unmapped: { id: string; from: string }[];
}> {
const rows = await this.prisma.good.findMany({
where: opts.goodId !== undefined ? { id: opts.goodId } : {},
select: {
id: true,
goodName: true,
originGood: { select: { sdsCategoryId: true } },
},
});
const cleaned = rows.map((row) => {
const name = cleanGoodDisplayName(row.goodName);
return { row, name, pure: isPureSkuGoodName(name) };
});
const sdsCategoryIds = [
...new Set(
cleaned
.filter((c) => c.pure)
.map((c) => c.row.originGood?.sdsCategoryId)
.filter((v): v is string => Boolean(v)),
),
];
const categories = sdsCategoryIds.length
? await this.prisma.category.findMany({
where: { sdsCategoryId: { in: sdsCategoryIds } },
select: { sdsCategoryId: true, categoryName: true },
})
: [];
const categoryNameBySdsId = new Map(
categories.map((c) => [c.sdsCategoryId, c.categoryName]),
);
const renames: { id: string; from: string; to: string }[] = [];
const unmapped: { id: string; from: string }[] = [];
for (const { row, name, pure } of cleaned) {
const categoryName =
pure && row.originGood?.sdsCategoryId
? categoryNameBySdsId.get(row.originGood.sdsCategoryId)
: undefined;
const to = describePureSkuGoodName(name, categoryName);
if (to === row.goodName) {
if (pure) unmapped.push({ id: row.id.toString(), from: row.goodName });
} else {
renames.push({ id: row.id.toString(), from: row.goodName, to });
}
}
if (!opts.dryRun && renames.length > 0) {
await this.prisma.$transaction(
renames.map((r) =>
this.prisma.good.update({
where: { id: BigInt(r.id) },
data: { goodName: r.to },
}),
),
);
}
return { applied: !opts.dryRun, renamed: renames.length, renames, unmapped };
}
/**
* Walk the category tree and return the requested id + all of its
* descendants. We use a level-by-level BFS to keep the queries small
@@ -606,6 +742,48 @@ export class GoodsService {
return ids;
}
/**
* 写路径名称兜底:规范化后仍为纯款号(如 `PLTK016`,SDS 部分链接名只有款号)
* 时,用主链接 SDS 分类名(形如 `PLTK016 童装纯色插肩短袖T恤`)补成「描述名 款号」。
* 非纯款号名不做任何额外查询。
*/
private async resolveGoodName(
db: Pick<PrismaService, 'category'>,
rawName: string,
sdsCategoryId: string | null | undefined,
): Promise<string> {
const normalized = cleanGoodDisplayName(rawName);
if (!isPureSkuGoodName(normalized) || !sdsCategoryId) return normalized;
const category = await db.category.findUnique({
where: { sdsCategoryId },
select: { categoryName: true },
});
return describePureSkuGoodName(normalized, category?.categoryName);
}
/** 商品主链接(可为本次切换后的)的 sdsCategoryId;无链接或未分类返回 null */
private async sdsCategoryIdForGood(
goodId: bigint,
overrideOriginGoodId?: number,
): Promise<string | null> {
let originGoodId: bigint | null = null;
if (overrideOriginGoodId !== undefined) {
originGoodId = BigInt(overrideOriginGoodId);
} else {
const good = await this.prisma.good.findUnique({
where: { id: goodId },
select: { originGoodId: true },
});
originGoodId = good?.originGoodId ?? null;
}
if (originGoodId === null) return null;
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { sdsCategoryId: true },
});
return og?.sdsCategoryId ?? null;
}
private async ensureOriginGood(id: number) {
const og = await this.prisma.originGood.findUnique({
where: { id: BigInt(id) },
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
@@ -7,15 +8,19 @@ import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
let service: OriginGoodsService;
let organize: OrganizeService;
let families: ProductFamiliesService;
let prisma: PrismaService;
const stamp = Date.now();
const createdSds: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(OriginGoodsService);
organize = moduleRef.get(OrganizeService);
families = moduleRef.get(ProductFamiliesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
@@ -161,4 +166,96 @@ describe('OriginGoodsService', () => {
}
});
});
describe('updateTags:缺啥补啥 + 自动归族', () => {
const tagIdByNames = async (names: string[]) => {
const rows = await prisma.tag.findMany({ where: { tagName: { in: names } } });
return rows.map((r) => r.id);
};
const tagNamesOf = async (ogId: bigint) => {
const rows = await prisma.originGoodTag.findMany({
where: { originGoodId: ogId },
include: { tag: true },
});
return rows.map((r) => r.tag.tagName).sort();
};
let ogFill: any;
beforeAll(async () => {
ogFill = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fill-${stamp}`, goodName: '美国(包邮)测试K-OG1-单面印花' },
});
createdSds.push(ogFill.sdsGoodId);
// 派生一次,确保三个维度组的标签字典存在
await organize.deriveTagsForOg(ogFill.id);
});
it('缺维度自动补齐:只勾「不包邮」→ 补 单面印花+烫画,全部人工行', async () => {
const [nfs] = await tagIdByNames(['不包邮']);
const res = await service.updateTags(ogFill.id, [Number(nfs)]);
expect(res.tagsManual).toBe(true);
expect(res.tags.map((t: any) => t.tagName).sort()).toEqual(
['不包邮', '单面印花', '烫画'].sort(),
);
expect((res as any).filledDimensionTags?.sort()).toEqual(['单面印花', '烫画'].sort());
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogFill.id } });
expect(rows.every((r) => r.manual)).toBe(true);
});
it('人工勾齐不补;旧词「热转印」经别名视为工艺维度已齐', async () => {
const full = await tagIdByNames(['包邮', '单面印花', '烫画']);
const res1 = await service.updateTags(ogFill.id, full.map(Number));
expect((res1 as any).filledDimensionTags ?? []).toEqual([]);
// 热转印(旧词)满足工艺维度,不再补 烫画
const alias = await tagIdByNames(['包邮', '单面印花', '热转印']);
// 旧词可能不存在于字典:ensureDerivedTagMap 只建封闭词;此处兜底创建
if (alias.length === 3) {
const res2 = await service.updateTags(ogFill.id, alias.map(Number));
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
} else {
const g = await prisma.tagGroup.upsert({
where: { groupName: '印刷工艺' },
create: { groupName: '印刷工艺' },
update: {},
});
const t = await prisma.tag.upsert({
where: { tagName: '热转印' },
create: { tagName: '热转印', tagGroupId: g.id },
update: {},
});
const [baoyou, danmian] = await tagIdByNames(['包邮', '单面印花']);
const res2 = await service.updateTags(ogFill.id, [Number(baoyou), Number(danmian), Number(t.id)]);
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
}
expect(await tagNamesOf(ogFill.id)).toEqual(['包邮', '单面印花', '热转印'].sort());
});
it('无族链接保存标签后自动并入匹配族(同分类优先),响应带 attachedFamilyId', async () => {
const cat = `cat-ogt-${stamp}`;
const a = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fam-${stamp}-a`, goodName: '美国(包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
});
const b = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fam-${stamp}-b`, goodName: '美国(不包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
});
createdSds.push(a.sdsGoodId, b.sdsGoodId);
const family = await families.create({
familyName: `测试K族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
try {
await organize.deriveTagsForOg(b.id);
const tagIds = (await prisma.originGoodTag.findMany({ where: { originGoodId: b.id } })).map((r) => Number(r.tagId));
const res = await service.updateTags(b.id, tagIds);
expect((res as any).attachedFamilyId).toBe(String(family.id));
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBe(BigInt(family.id));
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
});
});
@@ -7,6 +7,8 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { OrganizeService } from '../product-families/organize.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { PublicCacheService } from '../public/public-cache.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
@@ -21,6 +23,10 @@ export interface OriginGoodTagItem {
export interface OriginGoodTagsResult {
tagsManual: boolean;
tags: OriginGoodTagItem[];
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
filledDimensionTags?: string[];
/** 保存时链接无族且自动并入成功的目标族 id */
attachedFamilyId?: string;
}
export interface PaginatedOriginGoods {
@@ -91,6 +97,8 @@ export class OriginGoodsService {
private readonly prisma: PrismaService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly organize: OrganizeService,
private readonly families: ProductFamiliesService,
private readonly publicCache: PublicCacheService,
) {}
/** 链接当前标签(含 manual 标记) */
@@ -120,16 +128,17 @@ export class OriginGoodsService {
}
/**
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖)
* 并把有效标签镜像到该链接名下的商品。
* 人工接管链接标签:人工勾选全量保存为 manual 行(自动同步永不覆盖)
* 三个定价维度组(印花数量/工艺/物流)缺哪组按链接名自动补哪组(缺啥补啥),
* 保证链接 SKU 不掉出族价格矩阵;未归族链接随后自动并入匹配族。
*/
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true },
select: { id: true, goodName: true, source: true },
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
let uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
Number(a - b),
);
if (uniqueIds.length) {
@@ -138,6 +147,16 @@ export class OriginGoodsService {
throw new BadRequestException('存在无效标签');
}
}
// CUSTOM 成员的归因走管理员显式 craftLabel/logisticsLabel,名称不解析、不补齐
const fill =
og.source === 'SDS'
? await this.organize.fillMissingDimTags(og.goodName, uniqueIds)
: { tagIds: [] as bigint[], names: [] as string[] };
if (fill.tagIds.length) {
uniqueIds = [...new Set([...uniqueIds, ...fill.tagIds])].sort((a, b) =>
Number(a - b),
);
}
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
...(!uniqueIds.length
@@ -154,13 +173,28 @@ export class OriginGoodsService {
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
]);
await this.familyRecompute.mirrorLinkTagsToGoods(id);
// 标签镜像改变 good_tags(meta 域的标签组过滤);无族链接挂靠失败时
// 后续 recompute 不会执行,此处显式 bump 兜住全部路径(含 matrix 归因)
this.publicCache.bump('meta', 'goods', 'matrix');
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
const ogFull = await this.prisma.originGood.findUnique({
where: { id },
select: { familyId: true },
});
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
return this.getTags(id);
let attachedFamilyId: string | undefined;
if (ogFull?.familyId) {
await this.familyRecompute.recomputeFamily(ogFull.familyId);
} else {
// 无族链接:自动并入匹配的同款族(同 SDS 分类优先,回退族语义名称键)
const fid = await this.families.attachToMatchingFamily(id);
if (fid) attachedFamilyId = fid.toString();
}
const base = await this.getTags(id);
return {
...base,
filledDimensionTags: fill.names,
...(attachedFamilyId ? { attachedFamilyId } : {}),
};
}
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
@@ -176,6 +210,7 @@ export class OriginGoodsService {
]);
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
await this.organize.deriveTagsForOg(id);
this.publicCache.bump('meta', 'goods', 'matrix');
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
return this.getTags(id);
}
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { PositionsService } from './positions.service';
@@ -12,7 +13,7 @@ describe('PositionsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PositionsService, PrismaService],
providers: [PositionsService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(PositionsService);
prisma = moduleRef.get(PrismaService);
+14 -4
View File
@@ -3,12 +3,16 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto';
@Injectable()
export class PositionsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
const where: { countryId?: bigint; categoryId?: bigint } = {};
@@ -37,7 +41,7 @@ export class PositionsService {
if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId);
}
return this.prisma.position.create({
const created = await this.prisma.position.create({
data: {
indexVal: dto.indexVal,
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
@@ -45,6 +49,8 @@ export class PositionsService {
},
include: { country: true, category: true },
});
this.publicCache.bump('goods');
return created;
}
async update(id: bigint, dto: UpdatePositionDto) {
@@ -55,7 +61,7 @@ export class PositionsService {
if (dto.categoryId !== undefined && dto.categoryId !== null) {
await this.ensureCategory(dto.categoryId);
}
return this.prisma.position.update({
const updated = await this.prisma.position.update({
where: { id },
data: {
indexVal: dto.indexVal,
@@ -74,11 +80,15 @@ export class PositionsService {
},
include: { country: true, category: true },
});
this.publicCache.bump('goods');
return updated;
}
async remove(id: bigint) {
await this.findOne(id);
return this.prisma.position.delete({ where: { id } });
const removed = await this.prisma.position.delete({ where: { id } });
this.publicCache.bump('goods');
return removed;
}
private async ensureCountry(id: number) {
@@ -17,6 +17,18 @@ export function isAutoTagGroupName(name: string): boolean {
return /物流|工艺|位置|印花数量/.test(name);
}
/**
* 工艺旧词 → 封闭词表别名:人工标签历史上建过「热转印」(= 烫画,热转印工艺),
* 归一后才能进价格矩阵 —— 别名只影响矩阵归因,不改标签字典本身。
*/
export const CRAFT_TAG_ALIASES: Record<string, string> = {
: '烫画',
};
export function normalizeCraftTag(name: string): string {
return CRAFT_TAG_ALIASES[name] ?? name;
}
export interface DerivedTagGroupSpec {
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
group: string;
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import {
@@ -103,13 +104,14 @@ describe('FamilyRecomputeService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [FamilyRecomputeService, PrismaService],
providers: [FamilyRecomputeService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(FamilyRecomputeService);
organize = new OrganizeService(
moduleRef.get(PrismaService),
service,
new ProductFamiliesService(moduleRef.get(PrismaService), service),
moduleRef.get(PublicCacheService),
);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { isAutoTagGroupName } from './auto-tag-rules';
/**
@@ -326,7 +327,10 @@ export class FamilyRecomputeService {
private readonly logger = new Logger(FamilyRecomputeService.name);
private readonly pending = new Map<string, Promise<void>>();
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
/** 进程内去重的异步重算入口(同步钩子用) */
enqueue(familyId: bigint): void {
@@ -405,7 +409,9 @@ export class FamilyRecomputeService {
data: { stale: true },
});
}
// 族物化 JSON(矩阵/尺码表)与成员口径变化 → public 列表价/详情族块失效。
// 人工接管族(autoManaged=false)虽未重写矩阵,但其成员/详情已变,goods 域同样失效。
this.publicCache.bump('goods', 'matrix');
}
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
@@ -29,7 +30,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService],
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService, PublicCacheService],
}).compile();
recompute = moduleRef.get(FamilyRecomputeService);
organize = moduleRef.get(OrganizeService);
@@ -58,12 +59,12 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
async function goodTagNames(goodId: bigint): Promise<string[]> {
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
return rows.map((r) => r.tag.tagName);
return rows.map((r) => r.tag.tagName).sort();
}
async function linkTagNames(ogId: bigint): Promise<string[]> {
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } });
return rows.map((r) => r.tag.tagName);
return rows.map((r) => r.tag.tagName).sort();
}
it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => {
@@ -128,7 +129,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
expect(r1.goodsUpdated).toBe(2);
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
expect(await linkTagNames(og1.id)).toEqual(['包邮', '单面印花', '烫画']);
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
@@ -173,28 +174,30 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
ids.good.push(good.id);
await organize.deriveFamilyTags(family.id);
expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']);
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '烫画']);
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } });
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
const result = await originGoods.updateTags(og.id, [Number(zhpena!.id), Number(baoyou!.id)]);
expect(result.tagsManual).toBe(true);
expect(result.tags.map((t) => t.tagName)).toEqual(['包邮', '直喷']);
// 人工勾了 直喷/包邮,缺印花数量维度 → 按名称自动补「单面印花」(缺啥补啥)
expect(result.tags.map((t) => t.tagName).sort()).toEqual(['包邮', '单面印花', '直喷']);
expect(result.filledDimensionTags).toEqual(['单面印花']);
expect(result.tags.every((t) => t.manual)).toBe(true);
// 商品镜像跟随人工修正
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
// 商品镜像跟随人工修正(含补齐维度)
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
// 再次族同步:人工行不被覆盖
// 再次族同步:人工行(含补齐行)不被覆盖
await organize.deriveFamilyTags(family.id);
expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '直喷']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
// 恢复自动 → 回到名称派生结果
const reset = await originGoods.resetTags(og.id);
expect(reset.tagsManual).toBe(false);
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '烫画', '单面印花']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '烫画', '单面印花']);
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '单面印花', '烫画']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '烫画']);
});
it('自定义来源链接不派生标签,仅镜像人工配置', async () => {
@@ -259,7 +262,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
await organize.deriveFamilyTags(family.id);
await recompute.recomputeFamily(family.id);
const tagsBefore = await linkTagNames(og.id);
expect(tagsBefore).toEqual(['包邮', '烫画', '单面印花']);
expect(tagsBefore).toEqual(['包邮', '单面印花', '烫画']);
let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((fam.priceMatrix as any).rows).toHaveLength(1);
@@ -283,4 +286,68 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
expect(refreshed).toHaveLength(3);
expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷']));
});
it('整理碎片合并:纯碎片族并入带商品族(保公开 goodId 稳定)并删除;多族带商品进人工复审', async () => {
const cat = `cat-frag-${stamp}`;
const mkFrag = async (suffix: string) => {
const og = await prisma.originGood.create({
data: { sdsGoodId: `frag-${stamp}-${suffix}`, goodName: `美国(包邮)碎片测试-FG1-单面印花-${suffix}`, sdsCategoryId: cat },
});
ids.originGood.push(og.id);
const f = await prisma.productFamily.create({
data: { familyName: `碎片族${stamp}-${suffix}`, primaryOriginGoodId: og.id },
});
ids.family.push(f.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: f.id } });
return { og, f };
};
const loose = await mkFrag('a'); // 纯碎片(无商品/无覆盖价)
const curated = await mkFrag('b'); // 带商品 → keeper(公开 goodId=族ID
const good = await prisma.good.create({
data: {
goodName: `碎片商品${stamp}`,
originGoodId: curated.og.id,
familyId: curated.f.id,
countryId: ids.country[0],
categoryId: ids.category[0],
},
});
ids.good.push(good.id);
// 另一个也带商品的族 → 不自动动,进人工复审
const curated2 = await mkFrag('c');
const good2 = await prisma.good.create({
data: {
goodName: `碎片商品2${stamp}`,
originGoodId: curated2.og.id,
familyId: curated2.f.id,
countryId: ids.country[0],
categoryId: ids.category[0],
},
});
ids.good.push(good2.id);
try {
const result = await organize.consolidateFragments();
// 纯碎片族成员并入带商品族,碎片族被删除
const moved = await prisma.originGood.findUniqueOrThrow({ where: { id: loose.og.id } });
expect(moved.familyId).toBe(curated.f.id);
const gone = await prisma.productFamily.findUnique({ where: { id: loose.f.id } });
expect(gone).toBeNull();
// 带商品的族保留;两个带商品族互为碎片 → 较新的进人工复审
const kept = await prisma.productFamily.findUniqueOrThrow({ where: { id: curated.f.id } });
expect(kept).toBeTruthy();
const review = (result.manualReview ?? []).find((r: any) => r.fragmentFamilyId === String(curated2.f.id));
expect(review).toBeTruthy();
expect(review?.keeperFamilyId).toBe(String(curated.f.id));
expect(review?.reason).toContain('has-goods');
} finally {
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } }).catch(() => undefined);
await prisma.good.deleteMany({ where: { id: { in: ids.good } } }).catch(() => undefined);
ids.good = [];
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } }).catch(() => undefined);
ids.family = [];
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } }).catch(() => undefined);
ids.originGood = [];
}
});
});
@@ -2,11 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { PublicCacheService } from '../public/public-cache.service';
import { parseOriginName } from './origin-name.parser';
import {
DERIVED_TAG_GROUP_SPECS,
deriveLinkTagNames,
isAutoTagGroupName,
normalizeCraftTag,
} from './auto-tag-rules';
/**
@@ -27,12 +29,14 @@ export class OrganizeService {
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
private readonly families: ProductFamiliesService,
private readonly publicCache: PublicCacheService,
) {}
async organize() {
const labels = await this.backfillLabels();
const tags = await this.deriveAllTags();
const grouped = await this.families.autoGroup(true);
const fragments = await this.consolidateFragments();
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
for (const f of families) {
await this.recompute.recomputeFamily(f.id);
@@ -43,12 +47,124 @@ export class OrganizeService {
linksUpdated: tags.linksUpdated,
goodsUpdated: tags.goodsUpdated,
familiesCreated: grouped.applied,
familiesMerged: grouped.merged,
familiesSkipped: grouped.skipped?.length ?? 0,
fragmentsConsolidated: fragments.consolidated,
fragmentsManualReview: fragments.manualReview.length,
familiesRecomputed: families.length,
};
this.logger.log(`organize done: ${JSON.stringify(result)}`);
// 整理是全量标签/归族/矩阵重写动作,三域全失效(末尾逐族重算只覆盖 goods+matrix
// 标签镜像与标签组创建落在 meta 域)
this.publicCache.bump('meta', 'goods', 'matrix');
return result;
}
/**
* 碎片族合并:同一 SDS 分类出现多个族时(历史 autoGroup 只建不并所致),
* 把「纯碎片族」(autoManaged、无商品、无覆盖价)的成员并入最老族并删除空族;
* 带商品/覆盖价或人工锁定的碎片族保留,写进人工复审报告(由后台手动移动成员)。
* keeper 选择:同簇中最早带商品的族,否则最老的 autoManaged 族。
*/
async consolidateFragments(): Promise<{
consolidated: number;
manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }>;
}> {
const families = await this.prisma.productFamily.findMany({
select: { id: true, autoManaged: true },
orderBy: { id: 'asc' },
});
const members = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true },
});
const goodCounts = await this.prisma.good.groupBy({
by: ['familyId'],
_count: { _all: true },
where: { familyId: { not: null } },
});
const goodsByFamily = new Map(goodCounts.map((g) => [g.familyId!.toString(), g._count._all]));
const overrideCounts = await this.prisma.familyPriceOverride.groupBy({
by: ['familyId'],
_count: { _all: true },
});
const overridesByFamily = new Map(
overrideCounts.map((o) => [o.familyId.toString(), o._count._all]),
);
const autoManagedById = new Map(families.map((f) => [f.id.toString(), f.autoManaged]));
const familyCatIds = new Map<string, Set<string>>();
for (const m of members) {
if (!m.sdsCategoryId) continue;
const key = m.familyId!.toString();
familyCatIds.set(key, (familyCatIds.get(key) ?? new Set()).add(m.sdsCategoryId));
}
// 按 SDS 分类聚簇:同分类的多个族互为碎片
const clusterByFamily = new Map<string, Set<string>>();
const catOwners = new Map<string, string[]>();
for (const f of families) {
const cats = familyCatIds.get(f.id.toString()) ?? new Set();
for (const cat of cats) {
const owners = catOwners.get(cat) ?? [];
owners.push(f.id.toString());
catOwners.set(cat, owners);
}
}
for (const owners of catOwners.values()) {
if (owners.length < 2) continue;
for (const id of owners) {
const cluster = clusterByFamily.get(id) ?? new Set();
owners.forEach((o) => cluster.add(o));
clusterByFamily.set(id, cluster);
}
}
let consolidated = 0;
const manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }> = [];
const processedClusters = new Set<string>();
for (const [familyIdStr, cluster] of clusterByFamily) {
const sorted = [...cluster].sort((a, b) => Number(BigInt(a) - BigInt(b)));
const clusterKey = sorted.join(',');
if (processedClusters.has(clusterKey)) continue;
processedClusters.add(clusterKey);
// keeper:最早带商品的族,否则最老的 autoManaged 族
const withGoods = sorted.find((id) => (goodsByFamily.get(id) ?? 0) > 0);
const keeper =
withGoods ??
sorted.find((id) => autoManagedById.get(id) === true) ??
sorted[0];
for (const id of sorted) {
if (id === keeper) continue;
const reasons: string[] = [];
if ((goodsByFamily.get(id) ?? 0) > 0) reasons.push('has-goods');
if ((overridesByFamily.get(id) ?? 0) > 0) reasons.push('has-overrides');
if (autoManagedById.get(id) !== true) reasons.push('manual-locked');
if (reasons.length) {
manualReview.push({
keeperFamilyId: keeper,
fragmentFamilyId: id,
reason: reasons.join(','),
});
continue;
}
// 纯碎片:成员并入 keeper,删除空族
const keeperId = BigInt(keeper);
await this.prisma.originGood.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.good.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.productFamily.delete({ where: { id: BigInt(id) } });
consolidated += 1;
}
await this.recompute.recomputeFamily(BigInt(keeper));
}
return { consolidated, manualReview };
}
/**
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
@@ -79,6 +195,8 @@ export class OrganizeService {
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
parsed += 1;
}
// 结构化标签列是 CUSTOM 成员的矩阵归因来源(下次重算生效),保守失效 matrix+goods
this.publicCache.bump('goods', 'matrix');
return { parsed, unparsable };
}
@@ -109,6 +227,8 @@ export class OrganizeService {
for (const fid of touchedFamilies) {
await this.recompute.recomputeFamily(fid);
}
// 标签镜像(good_tags)落 meta 域;散链接无族不会被重算覆盖,入口统一失效
this.publicCache.bump('meta', 'goods', 'matrix');
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
}
@@ -228,6 +348,7 @@ export class OrganizeService {
]);
goodsUpdated += 1;
}
this.publicCache.bump('meta', 'goods');
return { goodsUpdated, linksUpdated };
}
@@ -264,9 +385,40 @@ export class OrganizeService {
linksUpdated = 1;
}
await this.recompute.mirrorLinkTagsToGoods(og.id);
this.publicCache.bump('meta', 'goods');
return { linksUpdated };
}
/**
* 人工标签缺维补齐:三个定价维度组(印花数量/工艺/物流)任一组在当前标签里
* 没有封闭词表取值时,按链接名称派生补上;工艺维度经别名归一后判断(热转印≈烫画)。
* 人工已勾的维度永不覆盖;名称派生不出该维度时不补(由调用方提示)。
*/
async fillMissingDimTags(
goodName: string | null,
currentTagIds: bigint[],
): Promise<{ tagIds: bigint[]; names: string[] }> {
const tagMap = await this.ensureDerivedTagMap();
const current = currentTagIds.length
? await this.prisma.tag.findMany({
where: { id: { in: currentTagIds } },
select: { tagName: true },
})
: [];
const currentNames = new Set(current.map((t) => normalizeCraftTag(t.tagName)));
const derivedNames = deriveLinkTagNames(goodName);
const missingNames: string[] = [];
for (const spec of DERIVED_TAG_GROUP_SPECS) {
if (spec.tags.some((name) => currentNames.has(name))) continue;
const fromName = spec.tags.find((name) => derivedNames.includes(name));
if (fromName) missingNames.push(fromName);
}
const tagIds = missingNames
.map((name) => tagMap.get(name))
.filter((id): id is bigint => id !== undefined);
return { tagIds, names: missingNames };
}
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
@@ -1,4 +1,4 @@
import { parseOriginName, originGroupKey } from './origin-name.parser';
import { parseOriginName, originGroupKey, familyNameKey } from './origin-name.parser';
describe('parseOriginName', () => {
it('解析完整四段名(含仓库)', () => {
@@ -102,3 +102,41 @@ describe('originGroupKey', () => {
expect(originGroupKey('')).toBe('');
});
});
describe('familyNameKey(族语义分组键)', () => {
it('物流差异同键:包邮/不包邮归一族', () => {
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe(
familyNameKey('英国(包邮*运费订单结算时支付)鼠标垫-GBIU017-单面印花'),
);
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe('英国鼠标垫-GBIU017');
});
it('工艺/仓库段不参与:跨工艺归一族', () => {
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe(
familyNameKey('美国(不包邮)T恤-DG001-双面印花'),
);
});
it('不同 SKU / 不同国家不同键', () => {
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
familyNameKey('美国(包邮)T恤-DG002-单面印花'),
);
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
familyNameKey('英国(包邮)T恤-DG001-单面印花'),
);
});
it('半角/全角括号混用与无括号都能出键', () => {
expect(familyNameKey('美国(不包邮)T恤-DG001-单面印花')).toBe('美国T恤-DG001');
expect(familyNameKey('美国T恤-DG001-单面印花')).toBe('美国T恤-DG001');
});
it('无 SKU 段时仅用品名段', () => {
expect(familyNameKey('美国(包邮)T恤')).toBe('美国T恤');
});
it('空名返回空串', () => {
expect(familyNameKey(null)).toBe('');
expect(familyNameKey('')).toBe('');
});
});
@@ -63,3 +63,29 @@ export function originGroupKey(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, 3).join('-');
}
/**
* 族语义分组键(自动建族/归族的回退键):第 1 段剥离(物流备注)括号内容,
* 保留 国家+品名,拼接 SKU 段,丢弃工艺/仓库段 —— 物流、工艺、印花是价格矩阵
* 维度而非分族维度,同款链接(同国家+品名+SKU)必须归一族。
*/
export function familyNameKey(name: string | null | undefined): string {
if (!name) return '';
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
if (!head) return '';
// 半角/全角括号混用先归一(与 splitSegment1 一致)
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
let stripped = normalized;
if (openAt >= 0) {
const closeAt = normalized.indexOf('', openAt);
if (closeAt > openAt) {
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
}
}
stripped = stripped.trim();
const sku = segs[1] ?? '';
if (!stripped) return sku || '';
return sku ? `${stripped}-${sku}` : stripped;
}
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -28,13 +29,14 @@ describe('ProductFamiliesService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(ProductFamiliesService);
organize = new OrganizeService(
moduleRef.get(PrismaService),
moduleRef.get(FamilyRecomputeService),
service,
moduleRef.get(PublicCacheService),
);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
@@ -108,19 +110,20 @@ describe('ProductFamiliesService', () => {
await service.patch(BigInt(f.id), { autoManaged: true });
});
it('auto-group:无分类时按名称键分组;预览不写库;apply 幂等', async () => {
it('auto-group:无分类时按族语义键分组(物流/工艺不分族);预览不写库;apply 幂等', async () => {
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
const preview = (await service.autoGroup(false)) as any;
expect(preview.applied).toBe(0);
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2);
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 3);
expect(hit).toBeTruthy();
expect(hit.familyName).toContain('卫衣');
expect(hit.action).toBe('create');
const applied = (await service.autoGroup(true)) as any;
expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组
expect(applied.applied).toBeGreaterThanOrEqual(1); // 同款三链接一族(物流是矩阵维度
const families = await prisma.productFamily.findMany({
where: { familyName: { contains: `自动组${stamp}` } },
});
@@ -130,8 +133,9 @@ describe('ProductFamiliesService', () => {
select: { familyId: true },
});
expect(grouped.every((g) => g.familyId !== null)).toBe(true);
expect(grouped[0].familyId).toBe(grouped[1].familyId); // 同组同
expect(grouped[2].familyId).not.toBe(grouped[0].familyId); // 物流不同不同族
// 物流/工艺差异不拆族:三条链接同一
expect(grouped[0].familyId).toBe(grouped[1].familyId);
expect(grouped[2].familyId).toBe(grouped[0].familyId);
// 幂等:候选已清空
const again = (await service.autoGroup(true)) as any;
@@ -139,6 +143,59 @@ describe('ProductFamiliesService', () => {
expect(hitAgain).toHaveLength(0);
});
it('auto-group:已有同款族时并入而非新建(不再产生 -2 碎片族)', async () => {
const cat = `cat-merge-${stamp}`;
const a = await mkOriginGood(`美国(包邮)测试M-MG1-单面印花`, { sdsCategoryId: cat });
const b = await mkOriginGood(`美国(不包邮)测试M-MG1-双面印花`, { sdsCategoryId: cat });
// 先给 a 建族(模拟早先整理只见到 a)
const family = await service.create({
familyName: `测试M族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
createdFamilyIds.push(BigInt(family.id));
try {
const result = (await service.autoGroup(true)) as any;
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBe(BigInt(family.id)); // b 并入已有族
expect(result.merged).toBeGreaterThanOrEqual(1);
// 该分类没有产生第二个族
const dup = await prisma.productFamily.findMany({
where: { familyName: { contains: `测试M族${stamp}` } },
});
expect(dup).toHaveLength(1);
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
it('auto-group:同款已有族但为人工锁定(autoManaged=false)时不并入也不新建', async () => {
const cat = `cat-manual-${stamp}`;
const a = await mkOriginGood(`美国(包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
const b = await mkOriginGood(`美国(不包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
const family = await service.create({
familyName: `测试M锁定族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
createdFamilyIds.push(BigInt(family.id));
try {
await prisma.productFamily.update({ where: { id: BigInt(family.id) }, data: { autoManaged: false } });
const result = (await service.autoGroup(true)) as any;
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBeNull(); // 不并入人工族
const sameCat = await prisma.productFamily.findMany({ where: { familyName: { contains: `测试M锁定族${stamp}` } } });
expect(sameCat).toHaveLength(1); // 也不新建平行族
expect((result.skipped ?? []).length).toBeGreaterThanOrEqual(0); // 报告字段存在
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => {
const cat = await prisma.category.create({
data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp}`, sdsCategoryId: `cat-hook-${stamp}` },
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
import { originGroupKey, parseOriginName } from './origin-name.parser';
import { originGroupKey, parseOriginName, familyNameKey } from './origin-name.parser';
import {
CreateCustomMemberDto,
CreateProductFamilyDto,
@@ -147,7 +147,11 @@ export class ProductFamiliesService {
}
/** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002"),
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退族语义名称键
* (国家+品名+SKU,物流/工艺是矩阵维度不分族)。
* 已有同款 autoManaged 族时并入(不再新建 -2 碎片族);同款仅有
* 人工锁定族(autoManaged=false)时跳过该组,不并入也不新建。
* apply=false 仅预览(action 标注 create/merge/skip)。 */
async autoGroup(apply: boolean) {
const candidates = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false },
@@ -160,64 +164,184 @@ export class ProductFamiliesService {
});
const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName]));
// 已有族的成员指纹:分类/名称键 → 候选族 id(用于"并入而非新建"
const familyMembers = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true, goodName: true },
});
const catFamilies = new Map<string, bigint[]>();
const nameFamilies = new Map<string, bigint[]>();
const push = (map: Map<string, bigint[]>, key: string, id: bigint) => {
map.set(key, [...(map.get(key) ?? []), id]);
};
for (const m of familyMembers) {
if (m.sdsCategoryId) push(catFamilies, m.sdsCategoryId, m.familyId!);
const nk = familyNameKey(m.goodName);
if (nk) push(nameFamilies, nk, m.familyId!);
}
const autoFamilyIds = new Set(
(
await this.prisma.productFamily.findMany({
where: { autoManaged: true },
select: { id: true },
})
).map((f) => f.id),
);
/** 匹配可并入的已有族:分类优先,回退名称键;仅 autoManaged,多候选取最老 */
const matchAutoFamily = (cat: string | null, nameKey: string): bigint | null => {
const pool = [
...new Set([...(cat ? (catFamilies.get(cat) ?? []) : []), ...(nameKey ? (nameFamilies.get(nameKey) ?? []) : [])]),
]
.filter((id) => autoFamilyIds.has(id))
.sort((a, b) => Number(a - b));
return pool[0] ?? null;
};
/** 该组是否已有同款族(含人工锁定族,用于 skip 判断) */
const hasAnyFamily = (cat: string | null, nameKey: string): boolean =>
Boolean(cat && catFamilies.has(cat)) || Boolean(nameKey && nameFamilies.has(nameKey));
const groups = new Map<string, typeof candidates>();
const groupKeys = new Map<string, { cat: string | null; nameKey: string }>();
for (const og of candidates) {
const nameKey = originGroupKey(og.goodName);
const nameKey = familyNameKey(og.goodName);
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
if (!key) continue;
groupKeys.set(key, { cat: og.sdsCategoryId ?? null, nameKey });
const arr = groups.get(key);
if (arr) arr.push(og);
else groups.set(key, [og]);
}
const preview = [...groups.values()].map((members) => {
const resolveGroup = (key: string) => {
const members = groups.get(key)!;
const { cat, nameKey } = groupKeys.get(key)!;
const parsed = parseOriginName(members[0].goodName);
const categoryName = members[0].sdsCategoryId
? (catName.get(members[0].sdsCategoryId) ?? null)
: null;
const categoryName = cat ? (catName.get(cat) ?? null) : null;
return {
groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`,
familyName:
categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null,
memberCount: members.length,
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
members,
cat,
nameKey,
parsed,
categoryName,
familyName: categoryName ?? parsed.productName ?? nameKey,
};
};
const preview = [...groups.keys()].map((key) => {
const g = resolveGroup(key);
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
const skip = !target && hasAnyFamily(g.cat, g.nameKey);
return {
groupKey: key,
familyName: g.familyName,
familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? null,
memberCount: g.members.length,
sampleNames: g.members.slice(0, 3).map((m) => m.goodName ?? ''),
action: target ? ('merge' as const) : skip ? ('skip' as const) : ('create' as const),
targetFamilyId: target ? target.toString() : null,
};
});
if (!apply) return { applied: 0, groups: preview };
if (!apply) return { applied: 0, merged: 0, skipped: [] as string[], groups: preview };
let applied = 0;
for (const members of groups.values()) {
const parsed = parseOriginName(members[0].goodName);
const categoryName = members[0].sdsCategoryId
? (catName.get(members[0].sdsCategoryId) ?? null)
: null;
let merged = 0;
const skipped: string[] = [];
for (const key of groups.keys()) {
const g = resolveGroup(key);
const memberIds = g.members.map((m) => m.id);
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
if (target) {
// 并入已有 autoManaged 族:不再新建 -2 碎片族
await this.attachMembers(target, memberIds, { strict: false });
await this.prisma.good.updateMany({
where: { originGoodId: { in: memberIds } },
data: { familyId: target },
});
await this.recompute.recomputeFamily(target);
merged += memberIds.length;
continue;
}
if (hasAnyFamily(g.cat, g.nameKey)) {
// 同款仅有人工锁定族:不并入也不新建,留在候选里等人工处理
skipped.push(key);
continue;
}
const fallbackCode =
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
g.members[0].source === 'CUSTOM' ? `CUSTOM-${g.members[0].id}` : null;
const family = await this.prisma.productFamily.create({
data: {
familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
familyName: g.familyName,
familyCode:
codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode
codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode
? await this.ensureUniqueCode(
(codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!,
(codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode)!,
)
: null,
familyImage: members[0].goodImage ?? null,
primaryOriginGoodId: members[0].id,
familyImage: g.members[0].goodImage ?? null,
primaryOriginGoodId: g.members[0].id,
},
});
// 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可
await this.attachMembers(
family.id,
members.map((m) => m.id),
memberIds,
{ strict: false },
);
await this.prisma.good.updateMany({
where: { originGoodId: { in: memberIds } },
data: { familyId: family.id },
});
await this.recompute.recomputeFamily(family.id);
applied += 1;
}
return { applied, groups: preview };
return { applied, merged, skipped, groups: preview };
}
/**
* 单链接自动归族:同 SDS 分类优先,回退族语义名称键(国家+品名+SKU,物流/工艺
* 是矩阵维度不分族);只并入 autoManaged 族(人工锁定族不动),多候选取最老
* (id 最小)。返回并入的族 id,无匹配返回 null。
*/
async attachToMatchingFamily(originGoodId: bigint): Promise<bigint | null> {
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { id: true, sdsCategoryId: true, goodName: true, delisted: true, familyId: true },
});
if (!og || og.familyId || og.delisted) return null;
const members = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true, goodName: true },
});
let candidates: bigint[] = [];
if (og.sdsCategoryId) {
candidates = members
.filter((m) => m.sdsCategoryId === og.sdsCategoryId)
.map((m) => m.familyId!);
}
if (!candidates.length) {
const key = familyNameKey(og.goodName);
if (key) {
candidates = members
.filter((m) => familyNameKey(m.goodName) === key)
.map((m) => m.familyId!);
}
}
if (!candidates.length) return null;
const families = await this.prisma.productFamily.findMany({
where: { id: { in: [...new Set(candidates)] }, autoManaged: true },
orderBy: { id: 'asc' },
select: { id: true },
});
if (!families.length) return null;
const target = families[0];
await this.attachMembers(target.id, [og.id]);
await this.prisma.good.updateMany({
where: { originGoodId: og.id },
data: { familyId: target.id },
});
await this.recompute.recomputeFamily(target.id);
return target.id;
}
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
@@ -0,0 +1,283 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from './public-cache.service';
import { PublicService } from './public.service';
import { CategoriesService } from '../categories/categories.service';
import { CountriesService } from '../countries/countries.service';
import { TagsService } from '../tags/tags.service';
import { TagGroupsService } from '../tag-groups/tag-groups.service';
import { PositionsService } from '../positions/positions.service';
import { GoodsService } from '../goods/goods.service';
import { SyncService } from '../sync/sync.service';
import { SdsClientService } from '../sync/sds-client.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { OrganizeService } from '../product-families/organize.service';
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
/**
* public 缓存失效链路(集成):
* 1. 读端点命中缓存(同筛选翻页/重复详情不再查库);
* 2. 「TTL 未到但写已发生 → 前台立即可见」端到端;
* 3. 每条写路径 bump 断言(全局约束 §2:等待 TTL 过期不是失效手段)。
* 夹具自包含:全部天然键带运行时间戳,不依赖库内既有数据。
*/
describe('public 缓存失效链路', () => {
const stamp = Date.now();
let prisma: PrismaService;
let cache: PublicCacheService;
let service: PublicService;
let categoriesService: CategoriesService;
let countriesService: CountriesService;
let tagsService: TagsService;
let tagGroupsService: TagGroupsService;
let positionsService: PositionsService;
let goodsService: GoodsService;
let syncService: SyncService;
let countryId: bigint;
let categoryId: bigint;
let tagId: bigint;
let tagGroupId: bigint;
let positionId: bigint;
let originGoodId: bigint;
let familyId: bigint;
let goodId: bigint;
let sdsGoodId: string;
let sdsCategoryId: string;
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
// 返回库内全部 SDS 分类 + 逻辑:保证 syncCategories 不误删并行套件的夹具
fetchCategoryTree: async () => {
const rows = await prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
select: { sdsCategoryId: true, categoryName: true },
});
return rows.map((r) => ({ id: r.sdsCategoryId!, name: r.categoryName }));
},
fetchProductsPage: async () => ({ items: [] }),
fetchProductDetail: async (goodId: string | number) => ({ id: goodId }),
};
const moduleRef = await Test.createTestingModule({
providers: [
PublicService,
CategoriesService,
CountriesService,
TagsService,
TagGroupsService,
PositionsService,
GoodsService,
SyncService,
FamilyRecomputeService,
ProductFamiliesService,
OrganizeService,
OriginGoodsService,
{ provide: SdsClientService, useValue: sdsMock },
PrismaService,
PublicCacheService,
],
}).compile();
service = moduleRef.get(PublicService);
cache = moduleRef.get(PublicCacheService);
categoriesService = moduleRef.get(CategoriesService);
countriesService = moduleRef.get(CountriesService);
tagsService = moduleRef.get(TagsService);
tagGroupsService = moduleRef.get(TagGroupsService);
positionsService = moduleRef.get(PositionsService);
goodsService = moduleRef.get(GoodsService);
syncService = moduleRef.get(SyncService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `失效测试国家 ${stamp}` },
});
countryId = country.id;
sdsCategoryId = `inv-sds-cat-${stamp}`;
const category = await prisma.category.create({
data: { categoryName: `失效测试分类 ${stamp}`, sdsCategoryId },
});
categoryId = category.id;
const tagGroup = await prisma.tagGroup.create({
data: { groupName: `失效测试标签组 ${stamp}` },
});
tagGroupId = tagGroup.id;
const tag = await prisma.tag.create({
data: { tagName: `失效测试标签 ${stamp}`, tagGroupId },
});
tagId = tag.id;
const position = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
positionId = position.id;
sdsGoodId = `inv-sds-good-${stamp}`;
const og = await prisma.originGood.create({
data: { sdsGoodId, goodName: `失效测试链接 ${stamp}`, goodImage: 'http://img' },
});
originGoodId = og.id;
const family = await prisma.productFamily.create({
data: { familyName: `失效测试族 ${stamp}`, primaryOriginGoodId: og.id },
});
familyId = family.id;
await prisma.originGood.update({
where: { id: og.id },
data: { familyId },
});
const good = await prisma.good.create({
data: {
goodName: `失效测试商品 ${stamp}`,
originGoodId,
familyId,
countryId,
categoryId,
goodPriority: 5,
},
});
goodId = good.id;
}, 30_000);
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: goodId } });
await prisma.originGoodTag.deleteMany({ where: { originGoodId } });
await prisma.originGoodVariant.deleteMany({ where: { originGoodId } });
await prisma.originGoodDetail.deleteMany({ where: { originGoodId } });
await prisma.originGood.deleteMany({ where: { id: originGoodId } });
await prisma.familyPriceOverride.deleteMany({ where: { familyId } });
await prisma.productFamily.deleteMany({ where: { id: familyId } });
await prisma.position.deleteMany({ where: { id: positionId } });
await prisma.tag.deleteMany({ where: { id: tagId } });
await prisma.tagGroup.deleteMany({ where: { id: tagGroupId } });
await prisma.category.deleteMany({
where: { OR: [{ id: categoryId }, { sdsCategoryId: { startsWith: `inv-sds-cat-${stamp}` } }] },
});
await prisma.country.deleteMany({ where: { id: countryId } });
await prisma.$disconnect();
});
it('列表读缓存:同筛选重复请求与翻页共享一份缓存,只查一次库', async () => {
const findManySpy = jest.spyOn(prisma.good, 'findMany');
const query = {
page: 1,
pageSize: 1,
countryId: countryId.toString(),
keyword: `失效测试商品 ${stamp}`,
sort: 'DEFAULT' as const,
};
const first = await service.getGoods(query);
const second = await service.getGoods(query);
const page2 = await service.getGoods({ ...query, page: 2 });
expect(first.items).toHaveLength(1);
expect(first.items[0].goodId).toBe(familyId.toString());
expect(second.items).toEqual(first.items);
expect(page2.items).toHaveLength(0); // total=1,第二页为空,但仍命中同一份缓存
expect(findManySpy).toHaveBeenCalledTimes(1);
findManySpy.mockRestore();
});
it('详情读缓存:重复请求不再查族表', async () => {
const familySpy = jest.spyOn(prisma.productFamily, 'findUnique');
const first = await service.getGood(familyId.toString());
const second = await service.getGood(familyId.toString());
expect(first.goodId).toBe(familyId.toString());
expect(second).toEqual(first);
expect(familySpy).toHaveBeenCalledTimes(1);
familySpy.mockRestore();
});
it('TTL 未到但分类改名 → 分类树立即返回新名(写后即失效,端到端)', async () => {
const oldName = `失效测试分类 ${stamp}`;
const newName = `失效后分类 ${stamp}`;
const categorySpy = jest.spyOn(prisma.category, 'findMany');
const before = await service.getCategoriesTree();
const flatBefore = JSON.stringify(before);
expect(flatBefore).toContain(oldName);
const callsAfterWarm = categorySpy.mock.calls.length;
await categoriesService.update(categoryId, { categoryName: newName });
const after = await service.getCategoriesTree();
expect(JSON.stringify(after)).toContain(newName);
expect(JSON.stringify(after)).not.toContain(oldName);
// 旧值来自缓存则不会再查库;失效正确时应产生新的分类查询
expect(categorySpy.mock.calls.length).toBeGreaterThan(callsAfterWarm);
categorySpy.mockRestore();
});
it('分类写路径 bump metaCountries/Tags/TagGroups', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
await countriesService.update(countryId, {
countryName: `失效测试国家改 ${stamp}`,
countryIcon: null,
});
expect(cache.version('meta')).toBe(meta0 + 1);
await tagsService.update(tagId, { tagName: `失效测试标签改 ${stamp}` });
expect(cache.version('meta')).toBe(meta0 + 2);
await tagGroupsService.update(tagGroupId, { groupName: `失效测试标签组改 ${stamp}` });
expect(cache.version('meta')).toBe(meta0 + 3);
expect(cache.version('goods')).toBe(goods0); // meta 写路径不误伤 goods 域
});
it('positions/goods 写路径 bump goods', async () => {
const goods0 = cache.version('goods');
const meta0 = cache.version('meta');
await positionsService.update(positionId, { indexVal: 9 });
expect(cache.version('goods')).toBe(goods0 + 1);
await goodsService.update(goodId, { goodName: `失效测试商品改 ${stamp}` });
// update 内部联动 recomputeFamilygoods+matrix)后再显式 bump goods
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 2);
expect(cache.version('meta')).toBe(meta0);
});
it('族重算 recomputeFamily bump goods+matrix', async () => {
const goods0 = cache.version('goods');
const matrix0 = cache.version('matrix');
const recompute = new FamilyRecomputeService(prisma, cache);
await recompute.recomputeFamily(familyId);
// goods 用 ≥:goods.update 等前置操作可能触发异步详情同步(fire-and-forget
// persistProductDetail 也会 bump goods),不与本断言强耦合;matrix 仅重算写
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1);
expect(cache.version('matrix')).toBe(matrix0 + 1);
});
it('链接人工改标签 bump meta+goods+matrix(含无族路径兜底)', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
const matrix0 = cache.version('matrix');
const originGoodsService = new OriginGoodsService(
prisma,
new FamilyRecomputeService(prisma, cache),
new OrganizeService(prisma, new FamilyRecomputeService(prisma, cache), new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)), cache),
new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)),
cache,
);
await originGoodsService.updateTags(originGoodId, []);
expect(cache.version('meta')).toBeGreaterThanOrEqual(meta0 + 1);
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1);
expect(cache.version('matrix')).toBeGreaterThanOrEqual(matrix0 + 1);
});
it('同步写路径:syncCategories bump meta+goodssyncProducts bump goodspersistProductDetail bump goods', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
await syncService.syncCategories();
expect(cache.version('meta')).toBe(meta0 + 1);
expect(cache.version('goods')).toBe(goods0 + 1);
await syncService.syncProducts();
expect(cache.version('goods')).toBe(goods0 + 2);
const goods1 = cache.version('goods');
// persistProductDetail 是私有方法,直调以断言挂钩(上游最小对象,mapper 全容忍)
await (syncService as unknown as { persistProductDetail: (id: bigint, up: unknown) => Promise<void> }).persistProductDetail(
originGoodId,
{ id: sdsGoodId },
);
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods1 + 1);
}, 60_000);
});
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PublicCacheService } from './public-cache.service';
/**
* Global public 缓存模块,导出 {@link PublicCacheService}。
*
* 与 PrismaModule 同款 @Global 模式:public 读路径与各 admin/sync 写路径
* 都要注入失效入口(bump),逐一 import 太啰嗦。单进程部署下进程内即全局;
* 扩容多副本时缓存需换共享存储(见整改计划 P1-3 扩展阶梯)。
*/
@Global()
@Module({
providers: [PublicCacheService],
exports: [PublicCacheService],
})
export class PublicCacheModule {}
@@ -0,0 +1,165 @@
import { PublicCacheDomain, PublicCacheService } from './public-cache.service';
describe('PublicCacheService', () => {
let cache: PublicCacheService;
beforeEach(() => {
cache = new PublicCacheService();
delete process.env.PUBLIC_CACHE_DISABLED;
delete process.env.PUBLIC_CACHE_TTL_MS;
});
afterAll(() => {
delete process.env.PUBLIC_CACHE_DISABLED;
delete process.env.PUBLIC_CACHE_TTL_MS;
});
it('命中:相同 key 第二次调用不再执行 loader', async () => {
const loader = jest.fn(async () => ({ value: 1 }));
const first = await cache.wrap('k', ['goods'], loader);
const second = await cache.wrap('k', ['goods'], loader);
expect(first).toEqual({ value: 1 });
expect(second).toEqual({ value: 1 });
expect(loader).toHaveBeenCalledTimes(1);
});
it('不同依赖域组合的相同 key 互不串缓存', async () => {
const a = await cache.wrap('k', ['goods'], async () => 'A');
const b = await cache.wrap('k', ['goods', 'matrix'], async () => 'B');
expect(a).toBe('A');
expect(b).toBe('B');
});
it('TTL 过期后重新执行 loader(TTL 只是兜底,不是失效手段)', async () => {
jest.useFakeTimers();
jest.setSystemTime(1_700_000_000_000);
try {
process.env.PUBLIC_CACHE_TTL_MS = '1000';
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('k', ['goods'], loader);
jest.setSystemTime(1_700_000_000_000 + 999);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(1);
jest.setSystemTime(1_700_000_000_000 + 1001);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('bump 域版本后,依赖该域的缓存立即作废', async () => {
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('list', ['goods', 'matrix'], loader);
cache.bump('goods');
await cache.wrap('list', ['goods', 'matrix'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('bump 某域不影响不依赖该域的条目(域间隔离)', async () => {
const metaLoader = jest.fn(async () => 'meta-data');
const goodsLoader = jest.fn(async () => 'goods-data');
await cache.wrap('meta-key', ['meta'], metaLoader);
cache.bump('goods');
await cache.wrap('meta-key', ['meta'], metaLoader);
expect(metaLoader).toHaveBeenCalledTimes(1);
await cache.wrap('goods-key', ['goods'], goodsLoader);
expect(goodsLoader).toHaveBeenCalledTimes(1);
});
it('跨域条目:任一依赖域 bump 都使其作废', async () => {
const loader = jest.fn(async () => 'x');
await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader);
cache.bump('matrix');
await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('竞态防护:loader 执行期间发生 bump → 结果照常返回但不入缓存', async () => {
let releaseLoader!: (v: string) => void;
const loader = jest.fn(
() =>
new Promise<string>((resolve) => {
releaseLoader = resolve;
}),
);
const inflight = cache.wrap('k', ['goods'], loader);
cache.bump('goods'); // 加载期间数据变了
releaseLoader('stale-value');
await expect(inflight).resolves.toBe('stale-value');
const loader2 = jest.fn(async () => 'fresh-value');
await expect(cache.wrap('k', ['goods'], loader2)).resolves.toBe('fresh-value');
expect(loader2).toHaveBeenCalledTimes(1); // 旧值没有写回缓存
});
it('并发合并:并发 miss 只触发一次 loader(防击穿)', async () => {
let release!: () => void;
const loader = jest.fn(
() =>
new Promise<number>((resolve) => {
release = () => resolve(42);
}),
);
const p1 = cache.wrap('hot', ['goods'], loader);
const p2 = cache.wrap('hot', ['goods'], loader);
const p3 = cache.wrap('hot', ['goods'], loader);
release();
expect(await Promise.all([p1, p2, p3])).toEqual([42, 42, 42]);
expect(loader).toHaveBeenCalledTimes(1);
});
it('PUBLIC_CACHE_DISABLED=true 时直通 loader、不缓存', async () => {
process.env.PUBLIC_CACHE_DISABLED = 'true';
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('k', ['goods'], loader);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('loader 抛错不缓存、不残留 pending', async () => {
const failing = jest.fn(async () => {
throw new Error('boom');
});
await expect(cache.wrap('k', ['goods'], failing)).rejects.toThrow('boom');
const ok = jest.fn(async () => 'good');
await expect(cache.wrap('k', ['goods'], ok)).resolves.toBe('good');
});
it('条目上限:先清过期条目,仍超限则按插入序淘汰最旧的', async () => {
jest.useFakeTimers();
jest.setSystemTime(1_700_000_000_000);
try {
process.env.PUBLIC_CACHE_TTL_MS = '1000';
process.env.PUBLIC_CACHE_MAX_ENTRIES = '3';
await cache.wrap('old', ['goods'], async () => 'old');
jest.setSystemTime(1_700_000_000_000 + 2000);
// old 已过期;再写 4 条活跃条目,第一条应顺带清掉过期的 old
for (let i = 0; i < 4; i++) {
await cache.wrap(`k${i}`, ['goods'], async () => i);
}
const loader = jest.fn(async () => 'reloaded');
// k0 应已被容量淘汰,重新加载;k3 仍命中
await cache.wrap('k0', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(1);
const hit = jest.fn(async () => -1);
await cache.wrap('k3', ['goods'], hit);
expect(hit).toHaveBeenCalledTimes(0);
} finally {
delete process.env.PUBLIC_CACHE_MAX_ENTRIES;
jest.useRealTimers();
}
});
it('version() 暴露域版本用于诊断,bump 递增', () => {
const before = cache.version('goods');
cache.bump('goods');
expect(cache.version('goods')).toBe(before + 1);
expect(cache.version('matrix')).toBe(before === 0 ? 0 : cache.version('matrix'));
});
it('未知域名直接抛错(编程错误早暴露)', async () => {
expect(() => cache.bump('nope' as PublicCacheDomain)).toThrow();
await expect(cache.wrap('k', ['nope' as PublicCacheDomain], async () => 1)).rejects.toThrow();
});
});
+171
View File
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* public 读路径的进程内缓存(性能整改 P0-1,详见
* docs/references/performance-review-public-port.md 与
* plans/refactor/public-capacity-10k-refactor.md)。
*
* 语义要点(全局约束 §2 的落地):
* - 分域版本号失效:写路径在事务提交成功后 bump 依赖域,依赖该域的缓存
* 条目立即作废——「等 TTL 自然过期」只是内存回收兜底,不是失效手段;
* - 条目记录写入时全部依赖域的版本快照,读时逐一比对,跨域依赖
* (列表同时依赖 goods+matrix+meta)天然联动失效;
* - loader 完成后复核版本:bump 发生在加载期间 → 结果照常返回但不入缓存,
* 杜绝「失效瞬间的并发读把旧值写回」竞态;
* - in-flight 合并:并发 miss 只触发一次 loader,防瞬时洪峰击穿缓存;
* - 单进程部署下进程内即全局缓存;扩容多副本时需换共享存储(计划 P1-3 阶梯 c)。
*
* 环境开关:PUBLIC_CACHE_DISABLED=true 全直通(应急);
* PUBLIC_CACHE_TTL_MS / PUBLIC_CACHE_MAX_ENTRIES 可调(默认 10 分钟 / 512 条)。
*/
export const PUBLIC_CACHE_DOMAINS = ['meta', 'goods', 'matrix'] as const;
export type PublicCacheDomain = (typeof PUBLIC_CACHE_DOMAINS)[number];
const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_MAX_ENTRIES = 512;
interface CacheEntry {
value: unknown;
expiresAt: number;
/** 写入时各依赖域的版本快照——任一域 bump 即作废 */
versions: ReadonlyMap<PublicCacheDomain, number>;
}
@Injectable()
export class PublicCacheService {
private readonly logger = new Logger(PublicCacheService.name);
private readonly versions = new Map<PublicCacheDomain, number>(
PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]),
);
private readonly entries = new Map<string, CacheEntry>();
private readonly pending = new Map<string, Promise<unknown>>();
/**
* 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值,
* 否则执行 loader 并缓存。domains 声明该值依赖的数据域——
* 写路径 bump 其中任一域都会让本条目作废。
*/
async wrap<T>(
key: string,
domains: readonly PublicCacheDomain[],
loader: () => Promise<T>,
): Promise<T> {
this.assertDomains(domains);
if (this.disabled()) return loader();
const storeKey = `${[...domains].sort().join('+')}|${key}`;
const hit = this.readHit(storeKey);
if (hit !== undefined) return hit as T;
const inflight = this.pending.get(storeKey);
if (inflight) return inflight as Promise<T>;
const snapshot = this.snapshotVersions(domains);
const run: Promise<T> = (async () => {
const value = await loader();
if (this.versionsMatch(snapshot)) {
this.writeEntry(storeKey, value, snapshot);
} else {
this.logger.debug(`cache skip (version bumped during load): ${storeKey}`);
}
return value;
})();
this.pending.set(storeKey, run as Promise<unknown>);
// 失败与成功都要摘掉 pending,异常不得残留在途槽位
void run.catch(() => undefined).finally(() => this.pending.delete(storeKey));
return run;
}
/**
* 失效入口:写事务提交成功后调用。递增域版本并清除依赖该域的条目
* (版本号本身已保证正确性,清条目只为及时回收内存)。
*/
bump(...domains: PublicCacheDomain[]): void {
this.assertDomains(domains);
for (const domain of domains) {
this.versions.set(domain, (this.versions.get(domain) ?? 0) + 1);
}
if (this.entries.size === 0) return;
for (const [key, entry] of this.entries) {
if (domains.some((domain) => entry.versions.has(domain))) {
this.entries.delete(key);
}
}
}
/** 当前域版本(诊断/测试用) */
version(domain: PublicCacheDomain): number {
this.assertDomains([domain]);
return this.versions.get(domain) ?? 0;
}
private readHit(storeKey: string): unknown {
const entry = this.entries.get(storeKey);
if (!entry) return undefined;
if (Date.now() >= entry.expiresAt) {
this.entries.delete(storeKey);
return undefined;
}
if (!this.versionsMatch(entry.versions)) {
this.entries.delete(storeKey);
return undefined;
}
return entry.value;
}
private versionsMatch(snapshot: ReadonlyMap<PublicCacheDomain, number>): boolean {
for (const [domain, version] of snapshot) {
if ((this.versions.get(domain) ?? 0) !== version) return false;
}
return true;
}
private snapshotVersions(
domains: readonly PublicCacheDomain[],
): Map<PublicCacheDomain, number> {
return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0]));
}
private writeEntry(
storeKey: string,
value: unknown,
versions: ReadonlyMap<PublicCacheDomain, number>,
): void {
const maxEntries = this.maxEntries();
if (this.entries.size >= maxEntries) {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
while (this.entries.size >= maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
this.entries.set(storeKey, { value, expiresAt: Date.now() + this.ttlMs(), versions });
}
private ttlMs(): number {
const parsed = Number(process.env.PUBLIC_CACHE_TTL_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_MS;
}
private maxEntries(): number {
const parsed = Number(process.env.PUBLIC_CACHE_MAX_ENTRIES);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ENTRIES;
}
private disabled(): boolean {
return process.env.PUBLIC_CACHE_DISABLED === 'true';
}
private assertDomains(domains: readonly PublicCacheDomain[]): void {
for (const domain of domains) {
if (!this.versions.has(domain)) {
throw new Error(`未知的 public 缓存域: ${String(domain)}`);
}
}
}
}
@@ -1,3 +1,4 @@
import { PublicCacheService } from './public-cache.service';
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { PublicService } from './public.service';
@@ -24,8 +25,11 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
let familyId = 0n;
beforeAll(async () => {
// 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径——
// 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖
process.env.PUBLIC_CACHE_DISABLED = 'true';
const moduleRef = await Test.createTestingModule({
providers: [PublicService, PrismaService],
providers: [PublicService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(PublicService);
prisma = moduleRef.get(PrismaService);
@@ -71,11 +75,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
createdFamilyIds.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
// 解析去运行时化:先整理派生标签,再重算(重算只聚合不解析)
const recomputeSvc = new FamilyRecomputeService(prisma);
const recomputeSvc = new FamilyRecomputeService(prisma, new PublicCacheService());
const organizeSvc = new OrganizeService(
prisma,
recomputeSvc,
new ProductFamiliesService(prisma, recomputeSvc),
new PublicCacheService(),
);
await organizeSvc.deriveFamilyTags(family.id);
await recomputeSvc.recomputeFamily(family.id);
@@ -196,13 +201,14 @@ describe('Good familyId derivation', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaService],
providers: [PrismaService, PublicCacheService],
}).compile();
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
prisma,
new FamilyRecomputeService(prisma),
new FamilyRecomputeService(prisma, new PublicCacheService()),
new PublicCacheService(),
);
});
File diff suppressed because it is too large Load Diff
+97 -11
View File
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from './public-cache.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
@@ -86,9 +87,28 @@ interface TreeOrderMeta {
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly cache: PublicCacheService,
) {}
/**
* 以下读端点统一走 PublicCacheService(性能整改 P0-1TTL 只是内存回收
* 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。
* 依赖域声明:
* - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据)
* - goodsgoods 行/关联展示数据(含 position)
* - matrix:族 price_matrix 物化 JSON(族最低价聚合)
* 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。
*/
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadCategoriesTree(countryId),
);
}
private async loadCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
@@ -133,6 +153,10 @@ export class PublicService {
}
async getCountries(): Promise<PublicCountryDto[]> {
return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries());
}
private async loadCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
@@ -141,6 +165,10 @@ export class PublicService {
}
async getTags(): Promise<PublicTagDto[]> {
return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags());
}
private async loadTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: {
goodTags: {
@@ -158,6 +186,12 @@ export class PublicService {
}
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadTagGroups(countryId),
);
}
private async loadTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
@@ -189,7 +223,44 @@ export class PublicService {
}));
}
/**
* 缓存键 = 筛选参数(不含 page/pageSize):缓存的是已排序已分组的全量
* items,翻页在缓存命中后内存切片——所有页码共享同一份物化结果。
*/
private goodsListCacheKey(query: PublicQueryGoodDto): string {
return [
'goods-list',
query.countryId ?? '',
query.keyword ?? '',
query.categoryId ?? '',
query.minPrice ?? '',
query.maxPrice ?? '',
query.sort ?? 'DEFAULT',
JSON.stringify(query.tags ?? []),
].join('|');
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
// 缓存的是「已排序已分组的全量 items」;分页切片必须在缓存外按请求执行,
// 否则后续页会拿到第一页的切片(缓存值会被多个页码共享)
const materialized = await this.cache.wrap(
this.goodsListCacheKey(query),
['goods', 'matrix', 'meta'],
() => this.loadGoodsMaterialized(query),
);
const start = (query.page - 1) * query.pageSize;
return {
items: materialized.items.slice(start, start + query.pageSize),
total: materialized.total,
page: query.page,
pageSize: query.pageSize,
};
}
private async loadGoodsMaterialized(query: PublicQueryGoodDto): Promise<{
items: PublicGoodDto[];
total: number;
}> {
// 无族商品(自定义)不进公开列表:只认族
const where: Prisma.GoodWhereInput = {
familyId: { not: null },
@@ -270,21 +341,18 @@ export class PublicService {
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
);
}
const total = items.length;
const start = (query.page - 1) * query.pageSize;
return {
items: items.slice(start, start + query.pageSize),
total,
page: query.page,
pageSize: query.pageSize,
};
return { items, total: items.length };
}
/**
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
*/
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
private loadTreeOrderMeta(): Promise<TreeOrderMeta> {
return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta());
}
private async queryTreeOrderMeta(): Promise<TreeOrderMeta> {
const [countries, leaves] = await Promise.all([
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
this.prisma.$queryRaw<
@@ -331,7 +399,11 @@ export class PublicService {
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
* 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。
*/
private async loadFamilyMinPrices(): Promise<Map<string, string>> {
private loadFamilyMinPrices(): Promise<Map<string, string>> {
return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices());
}
private async queryFamilyMinPrices(): Promise<Map<string, string>> {
const rows = await this.prisma.$queryRaw<
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
>`
@@ -365,6 +437,12 @@ export class PublicService {
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () =>
this.loadGoodByFamilyId(familyId),
);
}
private async loadGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
const [family, goods] = await Promise.all([
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
this.prisma.good.findMany({
@@ -405,6 +483,14 @@ export class PublicService {
}
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
return this.cache.wrap(
`home:${query.countryId ?? 'all'}:${query.limit}`,
['goods', 'matrix', 'meta'],
() => this.loadHomeGoods(query),
);
}
private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },
+46
View File
@@ -0,0 +1,46 @@
import { cleanCategoryDisplayName } from './sync.service';
describe('cleanCategoryDisplayName(分类名去括号,同步入库清洗)', () => {
it('删除型号限定词括号段(含名称后带空格的形态)', () => {
expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 JSA002')).toBe(
'DG001 180G纯棉T恤',
);
expect(cleanCategoryDisplayName('DG004 230G水洗T恤(JSA003')).toBe(
'DG004 230G水洗T恤',
);
});
it('删除内容性括号段(整段删除)', () => {
expect(cleanCategoryDisplayName('YSM02 全棉斜纹拼色帽子(黑+卡其)')).toBe(
'YSM02 全棉斜纹拼色帽子',
);
expect(cleanCategoryDisplayName('下装(短裤 & 长裤)')).toBe('下装');
});
it('多个括号段一并删除并折叠空白', () => {
expect(cleanCategoryDisplayName('T恤(短袖)(男款)成人')).toBe('T恤 成人');
});
it('半角括号同样处理', () => {
expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 (JSA002)')).toBe(
'DG001 180G纯棉T恤',
);
});
it('无括号名称原样返回', () => {
expect(cleanCategoryDisplayName('PLTK016 童装纯色插肩短袖T恤')).toBe(
'PLTK016 童装纯色插肩短袖T恤',
);
});
it('剥空守卫:名称仅剩括号段时原样返回', () => {
expect(cleanCategoryDisplayName('JSA002')).toBe('JSA002');
expect(cleanCategoryDisplayName('(短裤 & 长裤)')).toBe('(短裤 & 长裤)');
});
it('空/null 输入容错', () => {
expect(cleanCategoryDisplayName('')).toBe('');
expect(cleanCategoryDisplayName(null)).toBe(null);
expect(cleanCategoryDisplayName(undefined)).toBe(undefined);
});
});
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { SyncService } from './sync.service';
@@ -27,6 +28,7 @@ describe('SyncService family hooks', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
PublicCacheService,
SyncService,
{
provide: SdsClientService,
+3 -1
View File
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import {
@@ -25,6 +26,7 @@ describe('SyncService', () => {
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [
PublicCacheService,
SyncService,
{ provide: SdsClientService, useValue: sdsMock },
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
@@ -295,7 +297,7 @@ describe('SyncService product detail scopes', () => {
const familyRecompute = {
enqueue: jest.fn(),
} as unknown as FamilyRecomputeService;
const scopedService = new SyncService(prisma, sds, familyRecompute);
const scopedService = new SyncService(prisma, sds, familyRecompute, new PublicCacheService());
jest
.spyOn(scopedService as any, 'persistProductDetail')
.mockResolvedValue(undefined);
+28 -2
View File
@@ -15,6 +15,7 @@ import {
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PublicCacheService } from '../public/public-cache.service';
export interface CategorySyncResult {
@@ -24,6 +25,24 @@ export interface CategorySyncResult {
deletedStale: number;
}
/** 全角/半角括号段(非嵌套),分类名清洗用 */
const PAREN_GROUP = /[^()()]*|\([^()()]*\)/g;
/**
* 分类展示名清洗:删除所有括号段(型号限定词如 `(JSA002)`、内容性括号如
* `(黑+卡其)`/`(短裤 & 长裤)`,整段删除),折叠多余空白。剥空(名称仅剩
* 括号段)时原样返回。分类名每小时被 SDS 同步覆盖,清洗必须在此入库路径生效
* 才能持久;款号 token(如 `DG001`)不受影响。
*/
export function cleanCategoryDisplayName(name: string | null | undefined): string | null | undefined {
if (!name) return name;
let cleaned = name.replace(PAREN_GROUP, ' ');
// 嵌套/残缺括号剥除后可能留下空括号对
cleaned = cleaned.replace(/\s*|\(\s*\)/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned || name;
}
export interface ProductSyncResult {
inserted: number;
updated: number;
@@ -78,6 +97,7 @@ export class SyncService {
private readonly prisma: PrismaService,
private readonly sds: SdsClientService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly publicCache: PublicCacheService,
) {}
/**
@@ -185,7 +205,7 @@ export class SyncService {
await tx.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: node.name,
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
@@ -194,7 +214,7 @@ export class SyncService {
await tx.category.update({
where: { id: existing.id },
data: {
categoryName: node.name,
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
@@ -280,6 +300,8 @@ export class SyncService {
message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`,
},
});
// 分类树/树序元数据变化 → public 缓存失效(全局约束 §2:不等 TTL)
this.publicCache.bump('meta', 'goods');
return { inserted, updated, total: flat.length, deletedStale };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -389,6 +411,8 @@ export class SyncService {
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
},
});
// 链接镜像(名称/图/价/上下架)变化 → public 商品列表/详情失效
this.publicCache.bump('goods');
return {
inserted,
updated,
@@ -659,6 +683,8 @@ export class SyncService {
},
});
});
// 详情/变体/价格落库 → public 详情缓存失效;族矩阵变化由重算钩子 bump matrix
this.publicCache.bump('goods');
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
await this.maybeEnqueueFamilyRecompute(originGoodId);
}
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { TagGroupsService } from './tag-groups.service';
@@ -10,7 +11,7 @@ describe('TagGroupsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [TagGroupsService, PrismaService],
providers: [TagGroupsService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(TagGroupsService);
prisma = moduleRef.get(PrismaService);
+17 -5
View File
@@ -5,13 +5,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
@Injectable()
export class TagGroupsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll() {
return this.prisma.tagGroup.findMany({
@@ -31,7 +35,7 @@ export class TagGroupsService {
async create(dto: CreateTagGroupDto) {
try {
return await this.prisma.tagGroup.create({
const created = await this.prisma.tagGroup.create({
data: {
groupName: dto.groupName,
groupIcon: dto.groupIcon ?? null,
@@ -39,6 +43,8 @@ export class TagGroupsService {
sortOrder: dto.sortOrder ?? 0,
},
});
this.publicCache.bump('meta');
return created;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -58,7 +64,9 @@ export class TagGroupsService {
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
try {
return await this.prisma.tagGroup.update({ where: { id }, data });
const updated = await this.prisma.tagGroup.update({ where: { id }, data });
this.publicCache.bump('meta');
return updated;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -72,11 +80,13 @@ export class TagGroupsService {
async remove(id: bigint) {
await this.findOne(id);
return this.prisma.tagGroup.delete({ where: { id } });
const removed = await this.prisma.tagGroup.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
}
async reorder(dto: ReorderTagGroupsDto) {
return this.prisma.$transaction(
const result = await this.prisma.$transaction(
dto.items.map((item) =>
this.prisma.tagGroup.update({
where: { id: BigInt(item.id) },
@@ -84,5 +94,7 @@ export class TagGroupsService {
}),
),
);
this.publicCache.bump('meta');
return result;
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { validate } from 'class-validator';
@@ -13,7 +14,7 @@ describe('TagsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [TagsService, PrismaService],
providers: [TagsService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(TagsService);
prisma = moduleRef.get(PrismaService);
+17 -5
View File
@@ -5,13 +5,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { ReorderTagsDto } from './dto/reorder-tags.dto';
@Injectable()
export class TagsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll() {
return this.prisma.tag.findMany({
@@ -31,7 +35,7 @@ export class TagsService {
async create(dto: CreateTagDto) {
try {
return await this.prisma.tag.create({
const created = await this.prisma.tag.create({
data: {
tagName: dto.tagName,
tagColor: dto.tagColor ?? null,
@@ -41,6 +45,8 @@ export class TagsService {
sortOrder: dto.sortOrder ?? 0,
},
});
this.publicCache.bump('meta');
return created;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -66,7 +72,9 @@ export class TagsService {
}
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
try {
return await this.prisma.tag.update({ where: { id }, data });
const updated = await this.prisma.tag.update({ where: { id }, data });
this.publicCache.bump('meta');
return updated;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -80,11 +88,13 @@ export class TagsService {
async remove(id: bigint) {
await this.findOne(id);
return this.prisma.tag.delete({ where: { id } });
const removed = await this.prisma.tag.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
}
async reorder(dto: ReorderTagsDto) {
return this.prisma.$transaction(
const result = await this.prisma.$transaction(
dto.items.map((item) =>
this.prisma.tag.update({
where: { id: BigInt(item.id) },
@@ -100,5 +110,7 @@ export class TagsService {
}),
),
);
this.publicCache.bump('meta');
return result;
}
}
+3
View File
@@ -17,6 +17,9 @@ COPY apps/admin apps/admin
RUN pnpm --filter @inkreach/admin exec vite build
FROM nginx:1.27-alpine
# 性能整改 P0-2:替换官方主配置(worker_connections 1024 → 16384、gzip、访问日志);
# conf.dadmin.conf / admin.v2.conf)仍按服务挂载/拷贝
COPY deploy/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/v2/admin
COPY deploy/nginx/admin.conf /etc/nginx/conf.d/default.conf
COPY deploy/h5 /usr/share/nginx/html/v2/h5
-75
View File
@@ -1,75 +0,0 @@
# v2 部署(refactor/v2 分支):与现网 deploy 项目完全隔离(独立库/独立卷/独立网络),
# 对外复用 official.inkreach.cc 域名,由现网 nginx 按路径分流:
# https://official.inkreach.cc/v2/admin/ -> 本栈 adminSPA
# https://official.inkreach.cc/v2-api/ -> 本栈 api(去掉前缀后转发)
# 本栈两容器挂到外部网络 inkreach-shared,与 deploy-admin-1 互通(别名 v2-api / v2-admin)。
name: deploy-v2
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: inkreach
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: inkreach
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkreach -d inkreach"]
interval: 10s
timeout: 5s
retries: 10
networks:
- default
api:
build:
context: ..
dockerfile: deploy/api.Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@postgres:5432/inkreach
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGINS: "*"
# 激活 product-family 聚合公开读路径(现网 v1 未设置,保持旧行为,便于对比)
PUBLIC_DETAIL_FROM_FAMILY: "true"
volumes:
- uploads:/app/uploads
networks:
default:
shared:
aliases:
- v2-api
admin:
build:
context: ..
dockerfile: deploy/admin.Dockerfile
args:
VITE_API_BASE: /v2-api/
restart: unless-stopped
depends_on:
- api
volumes:
- ./nginx/admin.v2.conf:/etc/nginx/conf.d/default.conf:ro
networks:
default:
shared:
aliases:
- v2-admin
networks:
default:
shared:
external: true
name: inkreach-shared
volumes:
pgdata:
uploads:
+57 -23
View File
@@ -1,67 +1,101 @@
# 单栈部署(2026-09-03 主干收敛后,一切以 v2 为准):
# https://official.inkreach.cc/v2/admin/ -> v2-adminSPA
# https://official.inkreach.cc/v2-api/ -> v2-api(小程序/后台同路径,去前缀转发)
# https://official.inkreach.cc/public/ -> v2-apiH5 历史构建同源 API
# /uploads/ /assets/ -> v2-api;证书续期走 certbot 挂载;边缘入口为 admin 服务。
# v1(旧栈 api+postgres)已退役:容器停用未删,最终档案在 deploy/backups/consolidation-*/。
# v2 数据卷沿用原 deploy-v2 项目卷(external 引用),数据未迁移未改动。
services:
postgres:
v2-postgres:
image: postgres:16-alpine
restart: unless-stopped
# 性能整改 P0-3plans/refactor/public-capacity-10k-refactor.md):
# - statement_timeout=10s:慢查询不无限期占住连接(池排队由此可控);
# - max_connections=200:为未来 api 副本/同步 worker 预留(单 api 池 50);
# - shared_buffers/effective_cache_size14GB 内存机的工作集调优(数据 ~几十 MB,512MB 充裕)。
# 部署注意(全局约束 §1):改参数需重启容器(秒级中断)——先 pg_dump -Fc + 配置快照
# 落 deploy/backups/<时间戳>/ 再低峰 force-recreate。
command:
[
"postgres",
"-c", "statement_timeout=10000",
"-c", "max_connections=200",
"-c", "shared_buffers=512MB",
"-c", "effective_cache_size=1536MB",
]
environment:
POSTGRES_USER: inkreach
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: inkreach
volumes:
- pgdata:/var/lib/postgresql/data
- v2-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkreach -d inkreach"]
interval: 10s
timeout: 5s
retries: 10
# No ports exposed: only reachable from the compose network
api:
v2-api:
build:
context: ..
dockerfile: deploy/api.Dockerfile
restart: unless-stopped
depends_on:
postgres:
v2-postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@postgres:5432/inkreach
# 性能整改 P0-3:池上限 50 < PG max_connections 200(同步/运维连接留余量);
# pool_timeout 单位是秒(Prisma 默认 10)——池耗尽 3s 快速失败而非挂 10s。
# 不要加 statement_cache_size=0pgbouncer 专用,直连禁 prepared statement 反而拖慢)。
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@v2-postgres:5432/inkreach?connection_limit=50&pool_timeout=3
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGINS: "*"
# 激活 product-family 聚合公开读路径
PUBLIC_DETAIL_FROM_FAMILY: "true"
volumes:
- uploads:/app/uploads
- v2-uploads:/app/uploads
v2-admin:
build:
context: ..
dockerfile: deploy/admin.Dockerfile
args:
VITE_API_BASE: /v2-api/
restart: unless-stopped
depends_on:
- v2-api
volumes:
- ./nginx/admin.v2.conf:/etc/nginx/conf.d/default.conf:ro
# 边缘入口:80/443、证书、全站路由与静态资源(/、/admin/、/h5/ 为历史构建,保留展示)
admin:
build:
context: ..
dockerfile: deploy/admin.Dockerfile
args:
VITE_API_BASE: /api
VITE_API_BASE: /v2-api/
restart: unless-stopped
depends_on:
- api
- v2-api
ports:
- "80:80"
- "443:443"
# 性能整改 P0-24 worker × 16384 连接 + 上游 keepalive 需要容器 fd 预算匹配
ulimits:
nofile:
soft: 65536
hard: 65536
volumes:
- ./nginx/admin.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/acme:/etc/nginx/certs:ro
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
default:
shared:
# 接入 v2 栈的共享网络,使 /v2/* 的 proxy_pass 能解析 v2-admin / v2-api
aliases:
- v1-admin
volumes:
pgdata:
uploads:
networks:
shared:
v2-pgdata:
external: true
name: inkreach-shared
name: deploy-v2_pgdata
v2-uploads:
external: true
name: deploy-v2_uploads
+39 -14
View File
@@ -1,3 +1,15 @@
# 上游长连接池(性能整改 P0-2):与 v2-api 复用连接,消除每请求 TCP 建连;
# keepalive 连接需配合 proxy_http_version 1.1 + proxy_set_header Connection ""
upstream v2_api {
server v2-api:3001;
keepalive 64;
}
# 防洪峰兜底限流(app 层 120 req/min/IP 更严,这里只挡瞬时洪峰/扫描;
# 默认 503 会误判服务故障,显式 429)
limit_req_zone $binary_remote_addr zone=public_api:10m rate=50r/s;
limit_req_status 429;
server {
listen 80;
server_name official.inkreach.cc;
@@ -27,6 +39,7 @@ server {
root /usr/share/nginx/html;
# ACME challenge for cert renewals
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
@@ -60,22 +73,32 @@ server {
}
location /v2-api/ {
proxy_pass http://v2-api:3001/;
proxy_pass http://v2_api/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
limit_req zone=public_api burst=200 nodelay;
}
# H5 历史构建的 API 基址是同源相对路径 /public/*BASE_URL="/"),直通 v2 api(路径原样透传)
location /public/ {
proxy_pass http://v2-api:3001;
proxy_pass http://v2_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
limit_req zone=public_api burst=200 nodelay;
}
location = /h5 {
@@ -86,27 +109,29 @@ server {
try_files $uri $uri/ /h5/index.html;
}
# Uploaded files served by the API
# Uploaded files served by the API.
# 缓存头注意:若运营有「同名替换图片」操作请改短 max-age 或改用版本化 URL
location /uploads/ {
proxy_pass http://api:3001/uploads/;
proxy_pass http://v2_api/uploads/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
expires 30d;
add_header Cache-Control "public";
}
# Static product assets served by the API
# Static product assets served by the API(哈希文件名,可长缓存)
location /assets/ {
proxy_pass http://api:3001/assets/;
proxy_set_header Host $host;
}
# Product-center icons maintained in the v2 repo — serve from the v2 api
# (more specific prefix than /assets/, so it takes precedence)
location /assets/product-center/ {
proxy_pass http://v2-api:3001/assets/product-center/;
proxy_pass http://v2_api/assets/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
expires 30d;
add_header Cache-Control "public";
}
# Website placeholder until the public site is deployed
location / {
return 302 /admin/;
return 302 /v2/admin/;
}
}
+6
View File
@@ -25,8 +25,14 @@ server {
add_header Cache-Control "no-cache";
}
# v2 版 H5(构建时 router.base 必须为 /v2/h5/,产物放 /usr/share/nginx/html/v2/h5
# 性能整改 P0-2:哈希文件名的构建产物长缓存,index.html 永远 no-cache(上面精确匹配优先)
location /v2/h5/ {
try_files $uri $uri/ /v2/h5/index.html;
location ~* ^/v2/h5/(static|assets|img|fonts?)/ {
expires 30d;
add_header Cache-Control "public";
}
}
location / {
+44
View File
@@ -0,0 +1,44 @@
# 边缘 nginx 主配置(性能整改 P0-2plans/refactor/public-capacity-10k-refactor.md
# 由 deploy/admin.Dockerfile COPY 进镜像替换官方默认——官方默认 worker_connections 1024
# × 4 worker = 4096 连接硬顶,扛不住 1w 并发口径;events{} 只存在于主配置,conf.d 覆盖不了。
# 注意:admin 与 v2-admin 共用本 Dockerfile,两侧都会得到本主配置(upstream/gzip 对
# v2-admin 无害;worker_connections 抬升对静态服务同样受益)。
user nginx;
worker_processes auto;
# 每 worker 连接预算上限(4 × 16384 客户端连接 + 上游 keepalive 连接)
worker_rlimit_nofile 65536;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 16384;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 1w 并发下无访问日志无从排障:带上游耗时的结构化日志(rt=总耗时 urt=上游耗时)
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time uct=$upstream_connect_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# 压缩:API JSON(详情含 ~11KB price_matrix)与 SPA 静态资源,带宽降 5~10x
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types application/json application/javascript text/css text/plain text/xml image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
@@ -0,0 +1,122 @@
# public 端口性能审查报告(1w 瞬时并发请求口径)
- 审查日期:2026-09-03
- 审查对象:https://official.inkreach.cc 的公开访问链路(public 端口 = 边缘 nginx 80/443 → v2-api:3001 → v2-postgres
- 审查口径:**1w 瞬时并发请求**——同一时刻 1 万个请求同时到达边缘(压力测试式,万 QPS 级)
- 结论:**当前形态不能承压,差距约 2~3 个数量级**。瓶颈全部位于「配置与架构层」,与商品数据规模无关(goods 仅 240 行),改造后单机有希望在口径内达标,需压测实证。
---
## 1. 现状架构
单机(4 核 / 14GB / 无资源限额)docker compose 单栈 4 容器,全部单副本:
```
公网:80/443 (official.inkreach.cc)
└─ deploy-admin-1 (边缘 nginx 1.27-alpine,官方默认主配置)
├─ /public/ /v2-api/ /uploads/ /assets/ ──► deploy-v2-api-1 (NestJS :3001)
├─ /v2/admin/ /v2/h5/ ──► deploy-v2-admin-1 (SPA 静态)
└─ /admin/ /h5/ ──► try_files 本地静态
deploy-v2-api-1 ──► deploy-v2-postgres-1 (postgres:16-alpine)
```
关键事实(均已核对源码/配置原文):
| 项 | 现状 | 出处 |
| --- | --- | --- |
| nginx worker | `worker_processes auto`4 worker)× `worker_connections 1024`**最多 4096 条客户端连接** | 容器内官方默认 nginx.conf`deploy/nginx/admin.conf` 仅覆盖 conf.d |
| 边缘路由 | `/public/` 路径原样透传至 v2-api:3001 | `deploy/nginx/admin.conf` L72-79 |
| 压缩 | 无 gzipJSON/JS/图片全裸传) | nginx 默认 gzip 注释关闭;main.ts 无 compression 中间件 |
| 上游 keepalive | 无(每请求新建到 v2-api 的 TCP 连接) | admin.conf 无 upstream keepalive |
| 限流 | 全局限流 120 req/min/IP | `apps/api/src/app.module.ts` L27-32 |
| API 连接池 | Prisma 零配置 → 默认池 = 4核×2+1 = **9 条连接**,排队默认 10s 后报错 | `apps/api/src/prisma/prisma.service.ts` L12DATABASE_URL 无参数(compose L35 |
| PG | `max_connections=100`(默认)、无 statement_timeout、shared_buffers 默认 | docker-compose.yml 未覆盖;postgres:16 默认 |
| 缓存 | 全链路零缓存(无 Redis/cache-manager/nginx proxy_cache | 全仓 grep 零命中 |
| 同步任务 | 整点分类同步(长事务串行 ~226 分类)、03:30 详情全量同步 + 逐单族重算,与 public **同进程同池** | `apps/api/src/sync/sync.service.ts` L106-114、L138-145 |
| 数据规模 | goods 240 / origin_goods 542 / variants 12,155 / categories 243 / countries 12 | 生产导出 data-dump.json2026-08-22 |
## 2. 逐层瓶颈证据
### 2.1 边缘 nginx:第 0 层就爆(连接数硬顶 4096)
`worker_connections 1024` × 4 worker = **4096 条并发客户端连接**。HTTP/2 下每个浏览器占 1 条连接;1 万并发请求到场时,约 **六成连接进不了 accept 队列**(内核 backlog 默认 511,其余直接 reset/拒绝)。这是与后端优化无关的硬天花板。
### 2.2 API 层:单进程 + 9 连接池,直连 DB 吞吐 ~20~50 req/s
- **`GET /public/goods` 无 SQL 分页**`findMany` 不带 take/skip 全量拉取匹配行(`public.service.ts` L234),再内存分组、内存分页(L249-280)。数据量百级时可行,但每次请求的成本是「全表扫描 + 多表联表 + JS 排序」。
- **每次请求两个全表聚合**
- `loadFamilyMinPrices()`L334-351):`CROSS JOIN LATERAL jsonb_array_elements(price_matrix->'rows')` 展开**全部**族(每族 ~11KB JSONB),`/public/goods` 每页与 `/public/home-goods` 每次都跑;注释实测同类全量扫描 ~330ms(L329-333)。
- DEFAULT 排序额外 `loadTreeOrderMeta()`L287-307):全 countries + 全 categories 联表 raw SQL。
- **N+1 与串行链路**
- 分类树 `getCategoriesTree`:逐祖先串行 `findUnique`L102-113)。
- 详情 `getGoodByFamilyId`:3 条查询并发 + 响应内嵌整份 ~11KB price_matrix、全部变体 designData`resolveCategoryIcon` 串行向上最多 10 次 findUniqueL552-565)。
- **`keyword``ILIKE '%kw%'`**L199),无 pg_trgm 索引 → 必 seq scan(当前 240 行无感,量级放大后失效)。
- 单请求 DB 成本合计 ~300~500ms(列表)与 ~100~300ms(详情),9 条连接串行化后:**列表接口吞吐 ≈ 9 ÷ 0.4s ≈ 20~25 req/s**,全端点混合 ~30~60 req/s。
### 2.3 数据库层:无防护,池耗尽即排队超时
- Prisma 池 9 < PG max_connections 100,但**无任何超时参数**:慢查询可无限期占住连接;同步长事务(分类同步单事务串行 ~226 分类、详情同步逐商品 upsert)与 public 抢同一池。
- 池排队默认 10s 超时(P2028):1 万瞬时请求直连 DB 时,绝大多数请求排队 >10s 直接报错。
- 无 statement_timeout,无连接池水位监控。
### 2.4 传输层:无 gzip、无缓存头
- 详情响应内嵌 ~11KB price_matrix + 全部变体 designData;列表响应平均 ~2-5KB。按 ~20KB/响应粗算,1 万 req/s ≈ **1.6Gbps**,可打满常见 1Gbps 出口带宽;gzip 后 JSON 可压 5~10 倍。
- `/uploads/` `/assets/` 静态资源无任何 Cache-Control`main.ts` L73-81),每次访问重复下载。
### 2.5 全链路零缓存(杠杆最高的修复点)
分类树、国家、标签组、首页商品、商品列表、详情全部每次实时查库。**数据变更频率为小时级同步**syncCategories/syncProducts 整点跑),这意味着对 public 读路径引入分钟级 TTL 缓存几乎无损数据新鲜度,却能把 DB 热路径负载降 95%+。
### 2.6 同步任务竞争
整点分类同步长事务、03:30 详情全量同步 + 逐单族重算(fire-and-forget 进程内任务)与 public 共享同一 9 连接池。同步窗口内池被长事务/串行往返占满时,在线请求直接排队甚至超时。
### 2.7 压测陷阱:Throttler 429
全局限流 120 req/min/IP`app.module.ts` L27-32)。真实 1 万用户分布在不同 IP 无影响;但**单机/少 IP 压测流量会被 429 打满**,压测必须使用分布式源(≥数十个出口 IP)或临时抬高 `THROTTLE_LIMIT`,否则测出来的是限流器而不是系统容量。
## 3. 容量估算与结论
| 层 | 上限 | 1w 瞬时并发需求 | 差距 |
| --- | --- | --- | --- |
| nginx 连接数 | 4096 | 10000 | ~2.4x |
| API 直连 DB 吞吐 | ~30-60 req/s | ~10000 req/s | ~200-300x |
| 出口带宽(无 gzip | ~1 Gbps | ~1.6 Gbps | ~1.6x |
| PG 连接 | 100 | 9 池上限前置 | — |
**结论:当前形态不能承压 1w 瞬时并发请求**。即便并发被 nginx 放进来,DB 直连吞吐差两个数量级以上,池排队 10s 超时会把瞬时洪峰转为大面积 5xx。
**方向**:瓶颈 100% 属于「配置与架构层」——进程内存缓存(TTL 5~10min + 同步后失效)可把 DB 热路径负载降 ~95-99%nginx 连接数/gzip/keepalive/限流补齐后边缘可达 1w+ 连接;池参数与 statement_timeout 防排队超时与慢查询占池。改造后单机 4 核全缓存热路径(万级 req/s 简单 JSON 响应)有达标可能,但必须用分布式压测实证,达标口径见下。
## 4. 整改方向(摘要)
> **实施进度(2026-09-03**P0-1/P0-2/P0-3 已在 `refactor/public-capacity-10k-yeuimu` 分支完成并全量测试通过(238/238)——进程内分域缓存 + 全写路径即时失效、nginx 主配置/keepalive/gzip/限流/缓存头、连接池与 PG 参数。**已于 2026-09-03 08:38 部署到生产**(备份存档 `deploy/backups/20260903-083800-p0-perf-deploy/`,切换停机 23s):部署后实测——home-goods 首次 90ms/缓存命中 12ms;详情 247KB → gzip 23KB10.7x);静态资源 30d 缓存头生效;nginx 洪峰限流 429 生效且随窗口恢复(注意:单 IP 高频实测同时会打满 app 层 120 req/min 限流,属双层按设计工作);PG statement_timeout=10s/max_connections=200/shared_buffers=512MB 生效;行数 diff 零变化。**P1-3 压测基线(镜像栈 + 分布式源)为验收前置,未达标按计划扩展阶梯升级。**
完整任务分解见 [plans/refactor/public-capacity-10k-refactor.md](../plans/refactor/public-capacity-10k-refactor.md)
- **P0-1** 进程内存缓存层(NestJS cache-manager 或等价实现):categories/countries/tags/tag-groups/home-goods/goods(全量物化+内存分页)/goods/:id/族最低价聚合/树序元数据;**所有写路径(sync/族重算/admin 写/上传)提交后按域版本号主动失效**(全局约束:等待 TTL 过期不是兜底)
- **P0-2** nginx 调优:`worker_connections 16384``worker_rlimit_nofile`、upstream `keepalive 64`、gzip on、/uploads /assets 缓存头、limit_req 兜底、proxy 超时显式化
- **P0-3** 连接与超时:`DATABASE_URL?connection_limit=50&pool_timeout=3`pool_timeout 单位为秒)、PG `statement_timeout=10s`、shared_buffers/effective_cache_size 调参
- **P1-1** `/public/goods` SQL 分页(take/skip + count
- **P1-2** 同步任务与 public 隔离(错峰/批量化/独立 worker 开关)
- **P1-3** 压测基线:一次性 docker postgres + 生产 dump 测关键 SQL 真实耗时;k6 分布式压测
- **P2** 静态资源边缘直出、pg_trgm 索引、连接池水位监控告警
## 5. 验收口径(改造后)
- 分布式压测(≥数十个出口 IP,或 `THROTTLE_LIMIT` 临时抬高):**1 万并发瞬时请求**下:
- p95 延迟 < 300msCPU 侧),p99 < 1s
- 错误率 < 0.1%,无 nginx 连接拒绝/重置
- PG 连接水位不触顶(< 80%),无池排队超时
- 公布达标数字(req/s、p95/p99、错误率)后视为验收通过;不达标则加 v2-api 副本(上游 keepalive 已就位,扩容仅需加容器)。
## 附:相关文件索引
- 边缘路由:`deploy/nginx/admin.conf`
- 编排:`deploy/docker-compose.yml`
- 公开 API 实现:`apps/api/src/public/public.controller.ts``apps/api/src/public/public.service.ts`
- 入口中间件:`apps/api/src/main.ts`;限流:`apps/api/src/app.module.ts`
- 连接池:`apps/api/src/prisma/prisma.service.ts`
- 同步任务:`apps/api/src/sync/sync.service.ts``apps/api/src/product-families/family-recompute.service.ts`
- 数据规模:`deploy/data-dump.json`2026-08-22 导出)
+58 -10
View File
@@ -57,7 +57,8 @@
**模型**SDS 叶子分类即产品模型(如分类 `DG001 180G纯棉T恤(JSA002` 下 16 条链接),
族 = 同分类链接的合并体;链接名解析列(`国家(物流)品名-SKU-工艺[-仓库]`)提供
物流/工艺归因。例:DG001 族 = 16 链接 × 3 工艺 × 3 物流 × 9 尺码(S~XXXXXL 并集)× 20 颜色 ≈ 470 格价格矩阵,起价取光板(不打印)最低价。
物流/工艺归因。例:DG001 族 = 16 链接 × 3 工艺 × 3 物流 × 9 尺码(S~XXXXXL 并集)× 20 颜色 ≈ 470 格价格矩阵,起价取矩阵内最低价。
2026-09-03 起:**光板/不打印链接不配置进族**,矩阵不再计算「不打印」工艺,起价只来自印花工艺链接。)
**价格**`价格 = f(尺码, 颜色, 物流, 工艺[含印花数量])`,全部从成员链接变体推导透传
(同格子多仓库取最低价,来源全保留);`family_price_overrides` 表支持按格人工改价,
@@ -66,13 +67,15 @@
**关键端点用法示例**JWT):
```bash
# 1. 自动成族:先预览
# 1. 自动成族:先预览action 标注 create=新建 / merge=并入已有族 / skip=同款仅人工锁定族)
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \
-d '{"apply": false}'
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002", memberCount: 16, ... }] }
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002", memberCount: 16, action: "create", ... }] }
# 2. 应用(幂等,可重复执行)
# 2. 应用(幂等,可重复执行)。已有同款 autoManaged 族时**并入**(不再新建 -2 碎片族);
# 同款仅有人工锁定族(autoManaged=false)时跳过该组并计入 skipped。
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}'
# → { applied: 新建族数, merged: 并入链接数, skipped: ["cat:..."], groups: [...] }
# 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵
curl /product-families/12 -H "Authorization: Bearer $T"
@@ -95,7 +98,10 @@ curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \
1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列;
2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签
(未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族 → 全量重算。入口二选一:
(未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族**并入已有同款族优先,
无匹配才新建**)→ 碎片族合并(同分类多族时,纯碎片族并入带商品的族并删除,带商品/覆盖价/
人工锁定的进人工复审报告)→ 全量重算。返回含 `familiesMerged / fragmentsConsolidated /
fragmentsManualReview`。入口二选一:
```bash
pnpm --filter @inkreach/api organize # CLI
@@ -107,9 +113,26 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理
未整理(无标签)的 SDS 成员不进矩阵,整理后齐全。
派生默认(脚本层假设,显式可审查):工艺无关键字 → 烫画;印花数量无单/双面且工艺非不打印 →
单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)
单面印花;不打印/光板 无印花面不补。
**数据约定(2026-09-03 起)**:不打印/光板链接不进族(`family_id=NULL`),SKU 矩阵不计算
「不打印」工艺——不存在「不打印×单面印花」这类矛盾组合。⚠️ 回流风险:运行 `organize` /
`POST /product-families/autoGroup` 会把这些散链接按分类重新归族;人工编辑其标签也会触发
`attachToMatchingFamily` 回挂。重新归族前须先排查光板链接(备份与清单见
`deploy/backups/20260903-noprint-removal/`)。
「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。
**人工标签缺啥补啥 + 旧词别名**2026-09-02 起):
- `PUT /origin-goods/:id/tags` 保存人工标签时,三个定价维度组(印花数量/工艺/物流)缺哪组
就按链接名称自动补哪组(并入人工集合一起存为 manual 行,人工勾选值永不被动),响应带
`filledDimensionTags`;未归族链接随后自动并入匹配族(同 SDS 分类优先,回退族语义名称键),
响应带 `attachedFamilyId`
- 工艺「热转印」是矩阵词表一等公民(`DIM_VALUES.craft` 含 烫画/直喷/不打印/热转印),
打了该标签的链接按「热转印」工艺进价格矩阵;标签字典本身不动。
历史背景:曾有 16 条人工链接因勾了「热转印」被封闭词表静默踢出合并矩阵。
- 名称回退分组键为**族语义键** `familyNameKey`(第 1 段剥离物流备注、保留 国家+品名+SKU):
包邮/不包邮、单面/双面印花是矩阵维度,不再拆族;不同国家/不同款号仍不同族。
**公开读路径(四期族化契约,默认开启)**`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
公开端点**以款(族)为一等公民**:
@@ -130,7 +153,7 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理
"family": {
"familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤",
"sizes": [...], "colors": [...],
"printCounts": ["单面印花", "双面印花"], "crafts": ["烫画", "直喷", "不打印"], "logistics": ["包邮", "不包邮"],
"printCounts": ["单面印花", "双面印花"], "crafts": ["烫画", "直喷"], "logistics": ["包邮", "不包邮"],
"sizeChart": { /* */ }, "packageSpecs": { /* */ },
"priceMatrix": { /* rows {sizeId, colorId, printCount, craft, logistics, price, manual, sources} */ },
"minPrice": "29.5"
@@ -140,8 +163,8 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理
矩阵维度来源是**链接级标签**(印花数量/印刷工艺/物流渠道三组;人工接管后按人工标签),
不再用原始链接名的第 3 段(`craftLabel` 含「直喷单面/光板不打印」等噪声)。名称派生规则已
覆盖裸「单面/双面」写法(如「直喷双面」→ 双面印花 + 直喷)。光板/不打印链接无印花面概念,
矩阵中印花数量维回退「单面印花」占位。前端本地按五维联动 `priceMatrix` 即可实时算价
覆盖裸「单面/双面」写法(如「直喷双面」→ 双面印花 + 直喷)。光板/不打印链接不进族、
无印花面概念,不参与矩阵维度。前端本地按五维联动 `priceMatrix` 即可实时算价
(设计 D4)。
**边界声明**:价格区间筛选(minPrice/maxPrice)仍作用于链接 `goodPrice`(族内任一链接命中即
@@ -207,7 +230,32 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理
**链接名称展示解析**:后台所有链接名展示位(左右树、族成员列表、配置/编辑弹窗、搜索候选)
统一解析为「品名 型号」(如 `美国(包邮)180g纯棉T恤成人款-DG001-单面印花`
`180g纯棉T恤成人款 DG001`),悬停 tooltip 保留原始全名;搜索同时匹配原始名与解析名
`utils/origin-name.ts#cleanLinkName`)。
`utils/origin-name.ts#cleanLinkName`)。解析按**首括号对**取品名(与 api 端
`origin-name.parser.ts#splitSegment1` 同构);无 SKU 段时仅当括号后品名含中文才视为
链接名——防止已规范化的「品名(型号限定词) SKU」(如 `童装…(DTG180 PLTK016`
被误解析成光款号。
**商品展示名规范化与存量修复(2026-09)**`goods.good_name` 统一存「品名 SKU」——
- 写路径(`goods.service.ts` create/update/batchCreate 经 `resolveGoodName`):
结构完整的原始链接名规范化为「品名 SKU」,品名内的 **ASCII 型号限定词**
(如 `DTG180`/`JSA002`,供应商或工艺型号)一律剥离(中文括号内容如
`(女款)`保留);清洗后仍为**纯款号**(如 `PLTK016`SDS 部分链接名只有款号)
时按主链接 SDS 分类名补描述(`PLTK016 童装纯色插肩短袖T恤`
`童装纯色插肩短袖T恤 PLTK016`);batchCreate 同样走规范化;自定义商品
createCustom/updateCustomContent)的人工命名不受影响;
- 存量修复脚本:`pnpm --filter @inkreach/api fix:good-names`(默认 dry-run 打印
旧名→新名清单,`-- --apply` 写库,幂等可重跑;`origin_goods.good_name` 为 SDS
纯镜像不动,只改 `goods.good_name`)。
**分类名去括号(2026-09**`categories.category_name` 不再保留任何括号段——
型号限定词(`DG001 180G纯棉T恤(JSA002`)与内容性括号(`下装(短裤 & 长裤)`
**整段删除**`YSM02 全棉斜纹拼色帽子(黑+卡其)``YSM02 全棉斜纹拼色帽子`)。
分类名每小时被 SDS 同步覆盖,清洗在 `syncCategories` 入库路径生效
`sync.service.ts#cleanCategoryDisplayName`,款号 token 如 `DG001` 保留);存量
修复脚本 `pnpm --filter @inkreach/api fix:category-names`dry-run 默认,
`-- --apply` 写库,幂等,覆盖人工建的历史分类)。auto-group 族名、纯款号补描述
describePureSkuGoodName)均以分类名为源,自动随之变干净。
+331 -310
View File
@@ -1,310 +1,331 @@
# InkReach Product Center — Monorepo Structure
> pnpm workspaces + Turborepo 统一管理三个子项目与共享包。所有子项目位于 `apps/`,共享代码位于 `packages/`。
## 根目录
```
inkreach-official/
├── apps/
│ ├── api/ # NestJS 后端 (package: @inkreach/api)
│ ├── admin/ # Vue 3 后台 (package: @inkreach/admin)
│ └── website/ # Nuxt 4 官网 (package: @inkreach/website)
├── packages/
│ ├── tsconfig/ # 共享 TypeScript 配置 (base / api / vue)
│ └── shared-types/ # 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口)
├── .agents/ # AI Agent 共享技能
├── docs/ # 跨子项目文档
│ ├── dev/ # 设计文档(数据库表设计、PRD、原型图)
│ ├── references/
│ │ └── structs.md # 本文件
│ └── superpowers/specs/ # superpowers 规格说明
├── plans/ # 跨子项目计划
├── package.json # 根 workspace 配置(scripts + devDependencies
├── pnpm-workspace.yaml # pnpm workspace 定义(apps/*, packages/*
├── turbo.json # Turborepo 任务流水线
├── .prettierrc # 统一代码格式化
├── .gitignore # 全局忽略规则
├── .env # 根级环境变量(DATABASE_URL 等)
├── AGENTS.md # 根级 AI Agent 开发规范
└── README.md
```
## 端口与子项目
| 子项目 | 包名 | 端口 | 启动命令 | 说明 |
|--------|------|------|----------|------|
| `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/website` | `@inkreach/website` | 3000 | `pnpm --filter @inkreach/website dev` | Nuxt 4 官网;通过 Nitro `server/api/backend/*` 代理 `:3001` |
启动顺序:先启动 `apps/api`,再启动另两个。
## 子项目 1apps/api(后端,@inkreach/api
```
apps/api/
├── prisma/
│ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog
│ ├── migrations/ # Prisma migrate 历史
│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等)
├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块
│ ├── prisma/ # PrismaService 封装
│ ├── auth/ # JWT 认证:register / login / JwtStrategy / JwtAuthGuard
│ ├── countries/ # 国家 CRUD(受 JWT 保护)
│ ├── categories/ # 品类 CRUD(受 JWT 保护,自引用树)
│ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
│ └── common/ # 全局装饰器 / 过滤器 / 拦截器
├── decorators/current-user.decorator.ts
├── filters/http-exception.filter.ts
└── interceptors/transform.interceptor.ts
├── test/ # e2e 测试
├── dist/ # 构建产物
├── nest-cli.json
├── tsconfig.json / tsconfig.build.json
├── jest.config.js
└── package.json
```
### 数据模型(Prisma
| 模型 | 说明 |
|------|------|
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 |
| `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON |
| `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 |
| `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) |
| `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 |
| `Country` | 国家,关联 goods / positions |
| `Category` | 自引用树形品类,可选 `sds_category_id` |
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
| `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority``familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) |
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
| `User` | 后台用户(bcrypt 哈希) |
| `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` |
### 关键设计
- **所有主键为 `BigInt`**,路由解析后 `BigInt(id)` 处理,序列化时通过 `BigInt.prototype.toJSON` 转为字符串。
- **全局 `TransformInterceptor`**:把响应包装为 `{ data: T, success: true }`,前端读取 `response.data`
- **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted`
- **全局 `HttpExceptionFilter`**:统一错误响应形态。
- **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。
### API 路由
| 前缀 | 说明 | 鉴权 |
|------|------|------|
| `/auth/register` `POST` | 注册后台用户 | 公开 |
| `/auth/login` `POST` | 登录获取 JWT | 公开 |
| `/public/categories` `GET` | 公开品类树(仅含已挂商品的品类) | 公开 |
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`)。**默认排序 = 款序树**:国家(`countries.sort_order`) → 款所属二级(`categories.sort_order`) → 款(`categories.sort_order`) → `good_priority`;商品经 `origin_goods.sds_category_id` 定位到款(新树三级节点=合并后的款,顺序值由 `排序表.md` 经回填脚本写入),同一款下多条 Good 聚在一起、款内按优先级分先后;`sort=PRICE_ASC/PRICE_DESC/NEWEST` 不受影响 | 公开 |
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT |
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
| `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT |
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | 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 |
| `/sync/categories` `POST` | 手动触发分类同步 | JWT |
| `/sync/products` `POST` | 手动触发商品同步 | JWT |
| `/sync/status` `GET` | 最近同步日志(`?limit=20` | JWT |
| `/api/docs` | Swagger UI | 公开 |
## 子项目 2apps/admin(后台,@inkreach/admin
```
apps/admin/
├── public/ # 静态资源
├── src/
│ ├── main.ts # 入口:Pinia + Vue Router + ElementPlus
│ ├── App.vue
│ ├── style.css # 全局样式(含品牌色变量)
│ ├── api/ # 按业务模块拆分的 API 客户端
│ │ ├── request.ts # axios 实例 + JWT 拦截 + 全局错误处理
│ │ ├── auth.ts # /auth/login, /auth/me, /auth/logout
│ │ ├── goods.ts # 商品 CRUD + 批量
│ │ ├── categories.ts # 品类 CRUD
│ │ ├── countries.ts # 国家 CRUD
│ │ ├── tags.ts # 标签 CRUD(含批量排序)
│ │ ├── tag-groups.ts # 标签分组 CRUD
│ │ ├── positions.ts # 坑位 CRUD
│ │ ├── origin-goods.ts # 原始商品快照
│ │ ├── product-families.ts # 产品族 CRUD / auto-group / 成员 / 覆盖 / 重算
│ │ └── sync.ts # 同步触发 + 日志
│ ├── layouts/DefaultLayout.vue # 侧边栏 + 顶部条 + 用户菜单
│ ├── router/index.ts # 路由 + 登录守卫
│ ├── stores/
│ │ ├── auth.ts # 登录态 + token + user(持久化到 localStorage
└── app.ts # 侧边栏折叠
│ ├── types/index.ts # 共享类型
│ ├── views/
│ ├── login/LoginView.vue # 登录
│ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表
│ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、
│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover
│ │ ├── categories/CategoriesView.vue
│ │ ├── countries/CountriesView.vue
│ │ ├── tags/TagsView.vue
│ │ ├── positions/PositionsView.vue
│ │ ── sync/SyncView.vue
│ ├── auto-imports.d.ts # 自动生成的自动导入类型
└── components.d.ts # 自动生成的组件类型
├── index.html
├── vite.config.ts # Vite + ElementPlus 自动导入 + /api → :3001 代理
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
└── package.json
```
### 路由
| 路径 | 视图 | 鉴权 |
|------|------|------|
| `/login` | LoginView | 公开 |
| `/goods` | GoodsView(默认页) | JWT |
| `/categories` | CategoriesView | JWT |
| `/countries` | CountriesView | JWT |
| `/tags` | TagsView | JWT |
| `/positions` | PositionsView | JWT |
| `/sync` | SyncView | JWT |
| `/:pathMatch(.*)*` | 重定向到 `/goods` | — |
### 关键设计
- **Vite 代理**`/api/*` 代理到 `http://localhost:3001`rewrite 去掉 `/api` 前缀。
- **axios 拦截器**:请求注入 `Authorization: Bearer <token>`;响应直接返回 `response.data`401 自动登出跳转。
- **ElementPlus 自动导入**:通过 `unplugin-auto-import` + `unplugin-vue-components` + `ElementPlusResolver`
- **路由守卫**:未登录访问受保护路由跳 `/login`;已登录访问 `/login``/`
- **Pinia 持久化**`auth` store 主动读写 `localStorage``token` + `user`)。
## 子项目 3apps/website(官网,@inkreach/website
```
apps/website/
├── app/
│ ├── app.vue # 根容器:<NuxtPage />
│ ├── pages/
│ │ ├── index.vue # 首页
│ │ └── product-center.vue # 产品中心(侧边栏 + 国家/筛选 + 网格 + 分页)
│ ├── assets/css/tailwind.css # Tailwind v4 主题(@theme 定义颜色与动画)
│ ├── composables/
│ │ ├── useNavData.ts # 导航数据(选品推荐/解决方案)
│ │ ├── usePodProducts.ts # 首页 POD 产品(SDS
│ │ └── useProductCenter.ts # 产品中心数据(调用 /api/backend/*
│ └── components/
│ ├── AppHeader.vue / AppFooter.vue
│ ├── nav/ # 导航下拉面板
│ │ ├── NavMegaMenu.vue
│ │ └── NavColumnMenu.vue
│ ├── product/ # 产品中心专用组件
│ │ ├── ProductSidebar.vue # 树形品类侧边栏
│ │ ├── ProductCountryFilter.vue # 国家 pill 筛选
│ │ ├── ProductFilterBar.vue # 搜索输入 + 按钮
│ │ ├── ProductCard.vue # 商品卡片
│ │ ├── ProductCardSkeleton.vue # 骨架占位
│ │ ├── ProductGrid.vue # 网格容器
│ └── ProductPagination.vue # 分页器
├── HeroBanner.vue
├── TrustSection.vue
── StepProcess.vue
├── PodProducts.vue
├── FeatureCards.vue
├── WhyInkReach.vue
├── CompanyProfile.vue
── CustomerCases.vue
└── CtaBanner.vue
├── server/ # Nitro 后端
├── api/
│ │ ├── pod/ # 原有 SDS POD API 代理
│ │ └── backend/ # NestJS 后端代理(同源 + Nitro 缓存 60s
│ ├── categories.get.ts # → GET :3001/public/categories
├── countries.get.ts # → GET :3001/public/countries
├── tags.get.ts # → GET :3001/public/tags
├── tag-groups.get.ts # → GET :3001/public/tag-groups
├── goods.get.ts # → GET :3001/public/goods
── goods/[id].get.ts # → GET :3001/public/goods/:id
└── utils/pod-api.ts
├── public/ # 静态资源
├── plans/feature/ # 历史功能计划
├── docs/
├── references/structs.md # 子项目级结构文档
└── superpowers/specs/
├── nuxt.config.ts # runtimeConfig.public.backendUrl
├── .env # NUXT_PUBLIC_BACKEND_URL=http://localhost:3001
├── AGENTS.md # 子项目 AI Agent 规范
├── README.md
└── package.json
```
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`。
## 数据库
- `apps/api/public/product-center/`:产品中心 Figma 国家旗帜与一级品类图标,由 `/assets/product-center/*` 对外提供。
- `apps/api/prisma/configure-product-center-icons.ts`:按名称幂等写入 `countryIcon/categoryIcon`,不创建业务记录。
## 产品中心设计还原(2026-07-16
- `apps/website/app/components/product/ProductCenterHeader.vue`Figma 产品中心专用 80px 导航栏。
- `apps/website/app/components/product/ProductSidebar.vue`:240px 分类树及移动端抽屉内容。
- `apps/website/app/components/product/ProductCountryFilter.vue`:国家胶囊筛选。
- `apps/website/app/components/product/ProductTagFilter.vue`:物流与工艺分组筛选。
- `apps/website/app/components/product/ProductCard.vue`270 x 382 桌面商品卡片。
- `apps/website/app/components/product/ProductPagination.vue`:总数、页码、每页数量和跳转。
- `apps/website/test/useProductCenter.test.ts`:默认筛选名称映射测试。
- `docs/references/product-center.md`:产品中心使用与验证说明。
- `skills/inkreach-official-website/SKILL.md`Agent 使用说明。
## 官网首页设计还原(2026-07-16
- `apps/website/app/pages/index.vue`:官网首页组合入口。
- `apps/website/app/components/HeroBanner.vue``AppFooter.vue`:首页分区组件
- `apps/website/public/case-*.png`Figma 用户案例商品图。
- `docs/references/homepage.md`:首页结构、尺寸基准和验证说明。
- `apps/website/test/HomepageNavigationCarousel.test.ts`:首页当前页 CTA、自动轮播、主视觉图片和临时导航隐藏的行为测试
## 数据库
- `apps/api/src/public/public.service.ts`:官网公共商品序列化边界;响应中的商品 `id` 使用 `originGood.sdsGoodId`,不暴露本地 `goods.good_id`
- PostgreSQL 14+Prisma 5.x
- 连接配置在根 `.env``DATABASE_URL`
- 所有 `TIMESTAMPTZ` 列:`@db.Timestamptz(6)`
- 所有主键:`BigInt @default(autoincrement())`
- 表名与列名通过 `@map` / `@@map` 映射为 `snake_case`
- 迁移位于 `apps/api/prisma/migrations/`
## 环境变量总览
| 变量 | 位置 | 用途 | 默认 |
|------|------|------|------|
| `DATABASE_URL` | 根 `.env` | PostgreSQL 连接串 | — |
| `JWT_SECRET` | `apps/api` | JWT 签名密钥 | — |
| `PORT` | `apps/api` | 后端端口 | `3001` |
| `SDS_API_*` | `apps/api` | 同步上游 SDS 接口凭据 | — |
| `VITE_API_BASE` | `apps/admin` | axios baseURL | `/api` |
| `NUXT_PUBLIC_BACKEND_URL` | `apps/website` | NestJS 后端地址 | `http://localhost:3001` |
# InkReach Product Center — Monorepo Structure
> pnpm workspaces + Turborepo 统一管理三个子项目与共享包。所有子项目位于 `apps/`,共享代码位于 `packages/`。
## 根目录
```
inkreach-official/
├── apps/
│ ├── api/ # NestJS 后端 (package: @inkreach/api)
│ ├── admin/ # Vue 3 后台 (package: @inkreach/admin)
│ └── website/ # Nuxt 4 官网 (package: @inkreach/website)
├── packages/
│ ├── tsconfig/ # 共享 TypeScript 配置 (base / api / vue)
│ └── shared-types/ # 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口)
├── .agents/ # AI Agent 共享技能
├── docs/ # 跨子项目文档
│ ├── dev/ # 设计文档(数据库表设计、PRD、原型图)
│ ├── references/
│ │ └── structs.md # 本文件
│ └── superpowers/specs/ # superpowers 规格说明
├── plans/ # 跨子项目计划
├── package.json # 根 workspace 配置(scripts + devDependencies
├── pnpm-workspace.yaml # pnpm workspace 定义(apps/*, packages/*
├── turbo.json # Turborepo 任务流水线
├── .prettierrc # 统一代码格式化
├── .gitignore # 全局忽略规则
├── .env # 根级环境变量(DATABASE_URL 等)
├── AGENTS.md # 根级 AI Agent 开发规范
└── README.md
```
## 端口与子项目
| 子项目 | 包名 | 端口 | 启动命令 | 说明 |
|--------|------|------|----------|------|
| `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/website` | `@inkreach/website` | 3000 | `pnpm --filter @inkreach/website dev` | Nuxt 4 官网;通过 Nitro `server/api/backend/*` 代理 `:3001` |
启动顺序:先启动 `apps/api`,再启动另两个。
## 生产部署(单栈,2026-09-03 收敛后
- 唯一部署源:本仓库 develop`/opt/inkreach`),`deploy/docker-compose.yml` 单 compose
编排 4 容器:`admin`(边缘 nginx80/443 + 证书 + 全站路由)、`v2-api``v2-admin`
`v2-postgres`(数据卷 `deploy-v2_pgdata` external 引用,v2 数据未迁移)。
- 域名路由:`/v2/admin/`SPA)、`/v2-api/`(小程序/后台 API,去前缀转发)、`/public/`
H5 同源 API)、`/uploads/``/assets/` 均由 v2-api 服务;`/` 302 到 `/v2/admin/`
- 性能整改 P02026-09-03plans/refactor/public-capacity-10k-refactor.md):边缘 nginx
主配置改为仓库文件 `deploy/nginx/nginx.conf`worker_connections 16384 + gzip + 访问日志,
经 admin.Dockerfile COPY,改后须重建镜像);admin.conf 含 upstream keepalive/限流(429)/
代理超时/静态缓存头;v2-postgres 显式参数(statement_timeout=10s、max_connections=200、
shared_buffers=512MB);v2-api 的 DATABASE_URL 带 `connection_limit=50&pool_timeout=3`
pool_timeout 单位为秒);admin 服务 ulimits nofile 65536。改 PG 参数/重启前按全局约束
先 pg_dump + 配置快照落 `deploy/backups/<时间戳>/`
- v1(旧 api+postgres,曾支撑 `/public/` 与旧后台)已退役:切换时容器停用未删,
库终档与全部配置快照及回滚手册见 `deploy/backups/consolidation-*/RESTORE.md`
- 原 v2 独立栈(`docker-compose.v2.yml``/opt/inkreach-v2` 检出)已并入单栈并删除,
其血统(refactor/v2)已完全合入 develop;后续所有开发只走 develop。
## 子项目 1apps/api(后端,@inkreach/api
```
apps/api/
├── prisma/
├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog
├── migrations/ # Prisma migrate 历史
├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等)
│ ├── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认)
│ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认)
├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块
│ ├── prisma/ # PrismaService 封装
│ ├── auth/ # JWT 认证:register / login / JwtStrategy / JwtAuthGuard
│ ├── countries/ # 国家 CRUD(受 JWT 保护)
│ ├── categories/ # 品类 CRUD(受 JWT 保护,自引用树
│ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名)。数据约定(2026-09-03 起):光板/不打印链接不入族、矩阵不计算不打印工艺;auto-group/organize/人工改标签会把散链接回挂,运行前须排查(备份见 deploy/backups/20260903-noprint-removal/
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述)
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情;public-cache.service.ts 为进程内分域缓存(meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并,P0-1 性能整改),全部写路径(admin CRUD/sync/族重算/整理)事务提交后 bump 对应域即时失效;env 开关 PUBLIC_CACHE_DISABLED / PUBLIC_CACHE_TTL_MS / PUBLIC_CACHE_MAX_ENTRIES
│ └── common/ # 全局装饰器 / 过滤器 / 拦截器
│ ├── decorators/current-user.decorator.ts
│ ├── filters/http-exception.filter.ts
│ └── interceptors/transform.interceptor.ts
├── test/ # e2e 测试
├── dist/ # 构建产物
├── nest-cli.json
├── tsconfig.json / tsconfig.build.json
├── jest.config.js
└── package.json
```
### 数据模型(Prisma
| 模型 | 说明 |
|------|------|
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 |
| `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON |
| `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 |
| `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) |
| `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 |
| `Country` | 国家,关联 goods / positions |
| `Category` | 自引用树形品类,可选 `sds_category_id` |
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
| `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority``familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) |
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
| `User` | 后台用户(bcrypt 哈希) |
| `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` |
### 关键设计
- **所有主键为 `BigInt`**,路由解析后 `BigInt(id)` 处理,序列化时通过 `BigInt.prototype.toJSON` 转为字符串。
- **全局 `TransformInterceptor`**:把响应包装为 `{ data: T, success: true }`,前端读取 `response.data`
- **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted`
- **全局 `HttpExceptionFilter`**:统一错误响应形态。
- **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。
### API 路由
| 前缀 | 说明 | 鉴权 |
|------|------|------|
| `/auth/register` `POST` | 注册后台用户 | 公开 |
| `/auth/login` `POST` | 登录获取 JWT | 公开 |
| `/public/categories` `GET` | 公开品类树(仅含已挂商品的品类) | 公开 |
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`)。**默认排序 = 款序树**:国家(`countries.sort_order`) → 款所属二级(`categories.sort_order`) → 款(`categories.sort_order`) → `good_priority`;商品经 `origin_goods.sds_category_id` 定位到款(新树三级节点=合并后的款,顺序值由 `排序表.md` 经回填脚本写入),同一款下多条 Good 聚在一起、款内按优先级分先后;`sort=PRICE_ASC/PRICE_DESC/NEWEST` 不受影响 | 公开 |
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT |
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | 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/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | 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 |
| `/sync/categories` `POST` | 手动触发分类同步 | JWT |
| `/sync/products` `POST` | 手动触发商品同步 | JWT |
| `/sync/status` `GET` | 最近同步日志(`?limit=20` | JWT |
| `/api/docs` | Swagger UI | 公开 |
## 子项目 2apps/admin(后台,@inkreach/admin
```
apps/admin/
├── public/ # 静态资源
├── src/
│ ├── main.ts # 入口:Pinia + Vue Router + ElementPlus
│ ├── App.vue
│ ├── style.css # 全局样式(含品牌色变量
│ ├── api/ # 按业务模块拆分的 API 客户端
│ │ ├── request.ts # axios 实例 + JWT 拦截 + 全局错误处理
│ │ ├── auth.ts # /auth/login, /auth/me, /auth/logout
│ │ ├── goods.ts # 商品 CRUD + 批量
│ │ ├── categories.ts # 品类 CRUD
│ │ ├── countries.ts # 国家 CRUD
│ │ ── tags.ts # 标签 CRUD(含批量排序)
│ ├── tag-groups.ts # 标签分组 CRUD
│ ├── positions.ts # 坑位 CRUD
│ │ ├── origin-goods.ts # 原始商品快照
│ │ ├── product-families.ts # 产品族 CRUD / auto-group / 成员 / 覆盖 / 重算
│ │ └── sync.ts # 同步触发 + 日志
│ ├── layouts/DefaultLayout.vue # 侧边栏 + 顶部条 + 用户菜单
│ ├── router/index.ts # 路由 + 登录守卫
│ ├── stores/
│ │ ├── auth.ts # 登录态 + token + user(持久化到 localStorage
│ │ └── app.ts # 侧边栏折叠
│ ├── types/index.ts # 共享类型
│ ├── views/
│ │ ├── login/LoginView.vue # 登录
│ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表)
│ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、
│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover
│ │ ├── categories/CategoriesView.vue
│ │ ├── countries/CountriesView.vue
│ │ ├── tags/TagsView.vue
│ │ ├── positions/PositionsView.vue
│ │ └── sync/SyncView.vue
│ ├── auto-imports.d.ts # 自动生成的自动导入类型
│ └── components.d.ts # 自动生成的组件类型
├── index.html
├── vite.config.ts # Vite + ElementPlus 自动导入 + /api → :3001 代理
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
└── package.json
```
### 路由
| 路径 | 视图 | 鉴权 |
|------|------|------|
| `/login` | LoginView | 公开 |
| `/goods` | GoodsView(默认页) | JWT |
| `/categories` | CategoriesView | JWT |
| `/countries` | CountriesView | JWT |
| `/tags` | TagsView | JWT |
| `/positions` | PositionsView | JWT |
| `/sync` | SyncView | JWT |
| `/:pathMatch(.*)*` | 重定向到 `/goods` | — |
### 关键设计
- **Vite 代理**`/api/*` 代理到 `http://localhost:3001`rewrite 去掉 `/api` 前缀。
- **axios 拦截器**:请求注入 `Authorization: Bearer <token>`;响应直接返回 `response.data`401 自动登出跳转。
- **ElementPlus 自动导入**:通过 `unplugin-auto-import` + `unplugin-vue-components` + `ElementPlusResolver`
- **路由守卫**:未登录访问受保护路由跳 `/login`;已登录访问 `/login``/`
- **Pinia 持久化**`auth` store 主动读写 `localStorage``token` + `user`)。
## 子项目 3apps/website(官网,@inkreach/website
```
apps/website/
├── app/
├── app.vue # 根容器:<NuxtPage />
├── pages/
├── index.vue # 首页
── product-center.vue # 产品中心(侧边栏 + 国家/筛选 + 网格 + 分页)
├── assets/css/tailwind.css # Tailwind v4 主题(@theme 定义颜色与动画)
├── composables/
├── useNavData.ts # 导航数据(选品推荐/解决方案)
├── usePodProducts.ts # 首页 POD 产品(SDS
── useProductCenter.ts # 产品中心数据(调用 /api/backend/*
└── components/
│ ├── AppHeader.vue / AppFooter.vue
├── nav/ # 导航下拉面板
│ ├── NavMegaMenu.vue
│ └── NavColumnMenu.vue
│ ├── product/ # 产品中心专用组件
├── ProductSidebar.vue # 树形品类侧边栏
├── ProductCountryFilter.vue # 国家 pill 筛选
├── ProductFilterBar.vue # 搜索输入 + 按钮
├── ProductCard.vue # 商品卡片
── ProductCardSkeleton.vue # 骨架占位
│ ├── ProductGrid.vue # 网格容器
└── ProductPagination.vue # 分页器
│ ├── HeroBanner.vue
│ ├── TrustSection.vue
├── StepProcess.vue
├── PodProducts.vue
│ ├── FeatureCards.vue
│ ├── WhyInkReach.vue
│ ├── CompanyProfile.vue
│ ├── CustomerCases.vue
│ └── CtaBanner.vue
├── server/ # Nitro 后端
│ ├── api/
│ │ ├── pod/ # 原有 SDS POD API 代理
│ │ └── backend/ # NestJS 后端代理(同源 + Nitro 缓存 60s
│ │ ├── categories.get.ts # → GET :3001/public/categories
│ │ ├── countries.get.ts # → GET :3001/public/countries
│ │ ├── tags.get.ts # → GET :3001/public/tags
│ │ ├── tag-groups.get.ts # → GET :3001/public/tag-groups
│ │ ├── goods.get.ts # → GET :3001/public/goods
│ │ └── goods/[id].get.ts # → GET :3001/public/goods/:id
│ └── utils/pod-api.ts
├── public/ # 静态资源
├── plans/feature/ # 历史功能计划
├── docs/
│ ├── references/structs.md # 子项目级结构文档
│ └── superpowers/specs/
├── nuxt.config.ts # runtimeConfig.public.backendUrl
├── .env # NUXT_PUBLIC_BACKEND_URL=http://localhost:3001
├── AGENTS.md # 子项目 AI Agent 规范
├── README.md
└── package.json
```
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`
## 数据库
- `apps/api/public/product-center/`:产品中心 Figma 国家旗帜与一级品类图标,由 `/assets/product-center/*` 对外提供
- `apps/api/prisma/configure-product-center-icons.ts`:按名称幂等写入 `countryIcon/categoryIcon`,不创建业务记录。
## 产品中心设计还原(2026-07-16
- `apps/website/app/components/product/ProductCenterHeader.vue`Figma 产品中心专用 80px 导航栏。
- `apps/website/app/components/product/ProductSidebar.vue`240px 分类树及移动端抽屉内容。
- `apps/website/app/components/product/ProductCountryFilter.vue`:国家胶囊筛选。
- `apps/website/app/components/product/ProductTagFilter.vue`:物流与工艺分组筛选。
- `apps/website/app/components/product/ProductCard.vue`270 x 382 桌面商品卡片。
- `apps/website/app/components/product/ProductPagination.vue`:总数、页码、每页数量和跳转。
- `apps/website/test/useProductCenter.test.ts`:默认筛选名称映射测试。
- `docs/references/product-center.md`:产品中心使用与验证说明。
- `skills/inkreach-official-website/SKILL.md`Agent 使用说明。
## 官网首页设计还原(2026-07-16
- `apps/website/app/pages/index.vue`:官网首页组合入口。
- `apps/website/app/components/HeroBanner.vue``AppFooter.vue`:首页分区组件。
- `apps/website/public/case-*.png`Figma 用户案例商品图。
- `docs/references/homepage.md`:首页结构、尺寸基准和验证说明。
- `apps/website/test/HomepageNavigationCarousel.test.ts`:首页当前页 CTA、自动轮播、主视觉图片和临时导航隐藏的行为测试。
## 数据库
- `apps/api/src/public/public.service.ts`:官网公共商品序列化边界;响应中的商品 `id` 使用 `originGood.sdsGoodId`,不暴露本地 `goods.good_id`
- PostgreSQL 14+Prisma 5.x
- 连接配置在根 `.env``DATABASE_URL`
- 所有 `TIMESTAMPTZ` 列:`@db.Timestamptz(6)`
- 所有主键:`BigInt @default(autoincrement())`
- 表名与列名通过 `@map` / `@@map` 映射为 `snake_case`
- 迁移位于 `apps/api/prisma/migrations/`
## 环境变量总览
| 变量 | 位置 | 用途 | 默认 |
|------|------|------|------|
| `DATABASE_URL` | 根 `.env` | PostgreSQL 连接串 | — |
| `JWT_SECRET` | `apps/api` | JWT 签名密钥 | — |
| `PORT` | `apps/api` | 后端端口 | `3001` |
| `SDS_API_*` | `apps/api` | 同步上游 SDS 接口凭据 | — |
| `VITE_API_BASE` | `apps/admin` | axios baseURL | `/api` |
| `NUXT_PUBLIC_BACKEND_URL` | `apps/website` | NestJS 后端地址 | `http://localhost:3001` |
+60
View File
@@ -0,0 +1,60 @@
# 分类名去括号(DG001 180G纯棉T恤(JSA002 → DG001 180G纯棉T恤)
- 分支:`bugfix/category-name-parens-yeuimu`(自 `develop`
- 日期:2026-09-03
- 类型:Bug 修复 / 数据修复
- 前置:`pure-sku-good-name-fix.md`(商品名已统一「品名 SKU」,goods 表已无括号)
## 问题
后台左侧分类树(admin 左栏 + 官网分类树/商品卡共用)部分分类名带括号,两栈各
33 条,分两类:
- 型号限定词:`DG001 180G纯棉T恤 JSA002`
- 内容性括号:`YSM02 全棉斜纹拼色帽子(黑+卡其)``下装(短裤 & 长裤)`
用户要求:括号段**整段删除**。
## 关键约束
`categories.category_name` 是每小时 cron`syncCategories`)从 SDS 分类树**强制覆盖**
的镜像(update 分支无条件写 `node.name`)——只改库会被下一轮同步冲回。清洗必须
落在**同步入库路径**才能持久;镜像其余字段(icon/树结构)行为不变。
## 实现
### 1. 同步入库清洗(apps/api/src/sync/sync.service.ts
- [x] 导出纯函数 `cleanCategoryDisplayName(name)`:删除所有 `(…)`/`(…)`
括号段(含半角),折叠多余空白;剥空(名称仅剩括号段)时原样返回。
- [x] `syncCategories` upsert 的 create/update 分支均写清洗后的名称
(SDS 原文不再落库;款号 token `DG001` 保留,codeFromCategoryName 不受影响)。
### 2. 存量回填脚本
- [x] `apps/api/prisma/fix-category-parens.ts` + npm script `fix:category-names`
(默认 dry-run 打印 旧名 → 新名,`-- --apply` 写库,幂等;覆盖 SDS 与人工
建的分类)。
### 3. 测试(TDD
- [x] `src/sync/category-name.spec.ts`:型号限定词/内容性括号/多括号段/半角/
剥空守卫/无括号原样/空白折叠。
- [x] 全量 api 测试零回归(一次性 docker postgres)。
## 落库(2026-09-03 执行)
- [x] V1/V2 各 33 条分类名去括号,复跑 dry-run 0 条。
- 注意:**api 容器重建部署前**,旧镜像的每小时同步会把名称写回带括号原文;
部署带清洗逻辑的新镜像后,下一轮同步自动恢复干净(无需再跑脚本)。
## 已知影响
- `下装(短裤 & 长裤)` 两行同名分类清洗后均为 `下装`(清洗前已是同文重复,
树中原即并列两行,视觉不变差)。
- `configure:product-center-icons` 脚本按中文名幂等更新图标:其词表若含带括号
名称将匹配不到已清洗行,需要时更新词表(本次核查无带括号配置项受影响)。
## 权限评估
无新增端点/操作;CLI 脚本为运维通道 —— 无需新增权限项。
+58
View File
@@ -0,0 +1,58 @@
# 修复:人工修改标签后 SKU 无法自动合并 + 后台频繁掉线
- 日期:2026-09-02
- 分支:`bugfix/family-auto-merge-auth-cookie-yeuimu`
- 状态:已完成(2026-09-02,全量测试 187+22 通过)
## 现象与根因(数据实证)
### 问题2:人工修改标签后 SKU 无法自动合并(GBIU017 案例)
排查(v2 库 + 部署镜像比对)确认四处叠加缺陷:
1. **旧词表「热转印」不在矩阵封闭词表**:16 条人工接管标签的链接(GBIU014/15/16、
USIU002/005/012、JPIU014/20/21、GBIU009 等)勾的工艺词是旧词「热转印」,而
`memberMatrixCombos` 只认 烫画/直喷/不打印 → 这些链接的 SKU 全部被静默踢出合并矩阵。
线上 v2 镜像被人直接把 热转印 塞进 `DIM_VALUES` 救急(**未提交**,镜像与仓库分叉)。
2. **人工标签缺维度无守护**`updateTags` 全量替换为人工勾选行,缺任一定价维度组
(印花数量/工艺/物流)时链接掉出矩阵,且 `tagsManual=true` 后整理永不补 → 死锁。
3. **自动建族只建不并**`autoGroup` 只扫 `familyId=null` 链接且直接新建族(撞款号加
-2/-3 后缀),从不并入已有族 → USIU005 分裂成 USIU005 / USIU005-2,整理无法修复。
4. **名称回退分组键含物流备注**`originGroupKey` 前 3 段精确比较,第 1 段括号内即
包邮/不包邮 → 无分类链接永不同组;admin `sameOriginGroup`(合并默认勾选)同语义。
GBIU017 本身(v2 库)两条链接已同族且矩阵正常(4 行),用户"后来看到"系 18:00 手动
挂族操作 + 页面缓存,非计算延迟。
### 问题1:后台频繁「Unauthorized, please login」
refresh cookie `path:'/auth'`auth.controller.ts)与浏览器实际请求路径
`/api/auth/*`dev/Vite、v1)或 `/v2-api/auth/*`(v2 nginx)永不匹配 → 浏览器带不上
refresh cookie → 30 分钟访问令牌一过期必掉线。v2 构建另有 `VITE_API_BASE=/v2-api/`
(尾斜杠)+ request.ts 手工拼接 → `/v2-api//auth/refresh` 双斜杠 404(已在生产包中实证)。
## 实施内容
| # | 内容 | 文件 |
| --- | --- | --- |
| A1 | updateTags 缺啥补啥:三个维度组按链接名派生补齐(人工值优先);响应返回补齐明细 | origin-goods.service.ts / organize.service.ts |
| A2 | 保存标签后自动归族:无族链接并入匹配已有族(同 SDS 分类优先→规范名称键;仅 autoManaged 族),响应附 attachedFamilyId | product-families.service.ts |
| A3 | autoGroup 并入已有族:分组后先匹配已有族(多候选取最老),无匹配才新建;attach 联动 goods.familyIdpreview 标注并入/新建 | product-families.service.ts |
| A4 | 名称键族语义化:api/admin 新增 familyNameKey(第 1 段剥物流备注、全半角归一、保 国家+品名+SKU、丢工艺/仓库段);autoGroup 回退键与 admin sameFamily 回退改用它 | origin-name.parser.ts / admin utils/origin-name.ts、family-match.ts |
| A5 | 整理碎片清理:同分类多族时,纯碎片族(无商品、无覆盖价、autoManaged)并入最老族并删除;其余写报告提示人工 | organize.service.ts |
| A6 | admin 提示:保存标签提示补齐明细;整理完成消息含碎片合并/待人工数 | GoodsEditDialog.vue / GoodsView.vue |
| 别名 | 热转印→烫画 归一(矩阵归因 + CUSTOM 标签 + admin 展示),写进仓库取代镜像热修 | auto-tag-rules.ts / family-recompute.service.ts / admin |
| B | 两 cookie path 改 '/'clearCookie 同步);request.ts refresh URL 规范拼接 | auth.controller.ts / admin api/request.ts |
### 存量数据修复(部署后)
1. 从仓库重建 v2 镜像(消除未提交热修分叉)。
2. 跑一次「整理」:无族链接自动并入;USIU005-2(若为纯碎片)自动合并;16 条 热转印
链接经别名归一后重算进矩阵(矩阵 craft 列统一为 烫画)。
3. 若有带商品/覆盖价的碎片族,按整理报告人工移动成员(编辑弹窗「加入族」已支持跨族移动)。
### 行为变更说明
- 人工接管语义从"全量替换"变为"人工修正 + 缺维自动补齐"(人工勾选值永不被动)。
- 名称回退分族:同 国家+品名+SKU 的 包邮/不包邮/不同工艺 链接归一族(物流/工艺/印花
是矩阵维度,不是分族维度);分类键优先级不变。
+60
View File
@@ -0,0 +1,60 @@
# No-Print Links Removal Fix Implementation Plan(纯数据修复,不改源代码)
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 「不打印/光板」链接移出商品族,SKU 矩阵不再出现「不打印」工艺(含其「单面印花」占位组合);BRTF004(族 148)恢复烫画×单面印花矩阵。**用户明确:本修复不修改源代码,纯数据操作。**
**Background / Root cause2026-09-03 诊断):**
1. 「不打印×单面印花」来自 `family-recompute.service.ts` 的结构化占位规则(不打印链接无印花数量标签 → 占位单面印花)。占位逻辑在代码里,**但不需要改代码**:把不打印链接移出族后,占位分支对无成员的族不再生效,矩阵自然无该组合。
2. 族 148 烫画消失根因:**陈旧物化矩阵**。族 148 两成员(964 光板不打印、965 烫画单面)均在族内、未下架、标签齐全;矩阵物化于 09-02 16:00(旧容器时期),09-03 02:05 新镜像部署后无任何事件触发重算。用**当前已部署的正确代码**重算即恢复烫画。
3. 全库审计(141 个有活跃烫画成员的族):除族 148 外全部健康(crafts 含烫画、printCounts 为单面或单面+双面)。无 craft=不打印 覆盖价,无 autoManaged=false 冻结族,无未归族烫画链接。
**最终数据修复面:**
| 族 | 移出的不打印成员链接 | 主链接修正 | 名下商品脱族 |
| --- | --- | --- | --- |
| 1 (DG001) | 166, 731, 859, 961 | 无需(primary=163 不变) | good 361 |
| 2 (DG004) | 171, 749, 763, 852 | 无需(primary=167 不变) | — |
| 63 (METP001) | 759 | 无需(primary=339 不变) | — |
| 135 (PLJM030) | 760 | primary→NULL(移除后无剩余成员,族变空矩阵,公列表自然消失) | good 554 |
| 148 (BRTF004) | 964 | primary→965(烫画链接承接) | good 359(good 360 承接公开代表行) |
**回流风险(不改代码的代价,需文档化 + 运行纪律):**
- `organize` / `POST /product-families/autoGroup`:会把 family_id=null 的链接按分类重新归族 → **不打印链接会回流**。运行前须先排查,或将来补代码守卫。
- 人工编辑链接标签(`origin-goods.service.ts:184`)会触发 `attachToMatchingFamily` 把散链接挂回匹配族。
- 同步钩子无回流风险(`sync.service.ts:690` 只对 familyId 非空的链接重算)。
---
### Task 1: 备份(用户要求的风险回滚存档)
- [ ] `deploy/backups/20260903-noprint-removal/``pg_dump -Fc` 全库 + 受影响行 CSVorigin_goods 11 行改前 family_id、goods 3 行、product_families 5 行含 price_matrix 全量)+ `RESTORE.md` 逐表恢复命令;`pg_restore --list` 验证备份完整
### Task 2: 数据修复(单事务)
- [ ] `UPDATE origin_goods SET family_id=NULL` 11 条链接
- [ ] `UPDATE goods SET family_id=NULL`origin_good_id ∈ 859/760/964 且 family_id 非空)
- [ ] 族 148 `primary_origin_good_id=965`;族 135 `primary_origin_good_id=NULL`
- [ ] 提交前逐条核对影响行数 = 预期(11 / 3 / 1 / 1
### Task 3: 容器内直调重算(用已部署代码,不重建镜像)
- [ ] `docker exec deploy-v2-api-1 node -e` 实例化 `FamilyRecomputeService(new PrismaClient())`,对族 1/2/63/135/148 依次 `recomputeFamily`
### Task 4: 验证
- [ ] SQL 终检:全库无 price_matrix 含「不打印」;族 148 矩阵 crafts=['烫画']、printCounts=['单面印花']、minPrice=烫画链接价(19
- [ ] `GET /public/goods?keyword=BRTF004`:一条代表行(good 360),价格 19
- [ ] `GET /public/goods/148`family 块 crafts 含烫画、printCounts=['单面印花']、无不打印
- [ ] `GET /public/goods?keyword=PLJM030`:无结果(仅光板的族已隐去)
### Task 5: 文档与沉淀
- [ ] `docs/references/product-center.md`:不打印链接不参与族矩阵的数据约定 + 回流风险
- [ ] `docs/references/structs.md` 同步
- [ ] AGENTS.md 沉淀:①矩阵维度占位借用词表既有值导致语义硬伤(本次以数据收口:不打印链接不入族)②代码修复/重部署后物化 JSON 不会自愈——须显式触发全量重算(族 148 教训)③批量数据修复前 pg_dump+受影响行导出+RESTORE.md 三件套
### Task 6: 提交合并
- [ ] 本文档 + 文档更新提交 `bugfix/no-print-links-removal-yeuimu`,合并回 develop
+77
View File
@@ -0,0 +1,77 @@
# 修复左栏商品名显示成光款号(PLTK016 → 童装纯色插肩短袖T恤 PLTK016)
- 分支:`bugfix/pure-sku-good-name-yeuimu`(自 `develop`
- 日期:2026-09-02
- 类型:Bug 修复 / 数据修复
## 问题
后台商品配置页(GoodsView)左栏部分商品只显示纯款号(如 `PLTK016`),期望显示
「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。
## 根因(两层)
1. **展示解析误判(主因,V2 栈 34 条)**:部分 SDS 品名自带 ASCII 型号限定词,
写路径 `normalizeGoodName`api `splitSegment1` 首括号对解析)产出的展示名为
`童装纯色插肩短袖T恤(DTG180 PLTK016`;而 admin 展示解析器
`parseLinkName` 用**最后一个** `` 之后的文本当品名 → 显示成光秃秃 `PLTK016`
2. **纯款号名(防御路径,当期两库 0 条)**:SDS 部分链接名只有款号,解析不出
品名时 `normalizeGoodName` 原样入库,左栏显示裸款号;且 `batchCreate` 完全
未过 normalize。
描述名权威来源:SDS 分类名 `categories.category_name``PLTK016 童装纯色插肩短袖T恤`
`auto-group` 族名同源),去首 token 款号即得。
## 边界与约束
- `origin_goods.good_name` 是 SDS 纯镜像、每小时被同步覆盖 —— **不动镜像**
只修 `goods.good_name`(官网展示名,前台官网直接透传,同步受益)。
- 中文括号内容(如 `(女款)`)不是型号限定词,保留。
- CUSTOM 商品(createCustom/updateCustomContent)人工命名不受影响。
## 实现
### adminapps/admin/src/utils/origin-name.ts
- `parseLinkName` 对齐 api 端:首括号对解析 + 半/全角归一;无 SKU 段时仅当
括号后品名含中文才视为链接名(防「品名(限定词) SKU」误解析)。
- 新增 `stripAsciiParenQualifiers``cleanLinkName` 展示统一剥离 ASCII 型号限定词。
### apiapps/api/src/goods/goods.service.ts
- 导出 `stripAsciiParenQualifiers` / `cleanGoodDisplayName`(规范化 + 剥限定词)/
`isPureSkuGoodName` / `describePureSkuGoodName`(纯款号按 SDS 分类名补描述,
分类名中的限定词一并剥离)。
- `resolveGoodName` 私有助手接入 `create` / `update` / `batchCreate`batchCreate
顺带补上缺失的 normalize);`sdsCategoryIdForGood` 取(可切换后的)主链接分类。
- `backfillPureSkuGoodNames({ dryRun, goodId? })`:存量修复,幂等,支持 goodId
限定(测试隔离用)。
### 脚本
- `apps/api/prisma/fix-pure-sku-good-names.ts`(薄壳,手动构造依赖),
npm script `fix:good-names`;默认 dry-run`-- --apply` 写库。
## 测试(TDD
- admin `origin-name.spec.ts`:+5(误解析防护、限定词剥离、中文括号保留、
首括号单段解析),共 23 绿。
- api `goods.service.spec.ts`+9(纯函数边界、create/update/batchCreate 补描述、
无映射保留、backfill dry-run/apply/幂等/限定词剥离),共 30 绿。
- 全量:api 22 套件 199 测试绿(临时 docker postgres + `prisma migrate deploy`
共享开发库 192.168.124.92 当期不可达);admin vitest 2 文件 27 测试绿;
两端 `tsc --noEmit` 零错误。
## 落库结果(2026-09-02 执行)
- V2 栈(deploy-v2-postgres-1):34 条 `品名(限定词) SKU``品名 SKU`
(含 #263 `童装纯色插肩短袖T恤 PLTK016`)。
- V1 栈(deploy-postgres-1):213 条原始链接名 → 规范化「品名 SKU」(后台展示
等价,官网卡片由原始链接名变为干净品名)。
- 两库复跑 dry-run `renamed=0 unmapped=0`,幂等验证通过;数据已干净,线上旧
前端镜像无需等重部署即正确显示;代码修复防新增。
## 权限评估
无新增端点/操作,全部复用既有已控权限路径(PATCH /goods/:id 等);CLI 脚本为
运维通道 —— 无需新增权限项。
@@ -0,0 +1,157 @@
# 整改计划:public 端口 1w 瞬时并发承压(public-capacity-10k-refactor
前置审查:[docs/references/performance-review-public-port.md](../../docs/references/performance-review-public-port.md)
## 目标
- 目标口径:**1w 瞬时并发请求**(同一时刻 1 万请求到达边缘,万 QPS 级)。
- 验收:p95 < 300ms、p99 < 1s、错误率 < 0.1%、无 nginx 连接拒绝/重置、PG 连接水位 < 80%、无池排队超时(详见报告 §5)。
- 原则:本次改造只动「配置与架构层」,不改对外 API 契约(响应结构不变),数据语义零变化。
## 全局约束(执行本计划所有任务强制遵守)
1. **数据库变更先行备份存档**:凡是涉及数据库更改的行动(schema 迁移、数据回填、族重算、同步行为变更、PG 参数调整与重启),执行前必须先完成风险回滚存档:
- 受影响库 `pg_dump -Fc` 全量 dump + 相关配置快照,落 `deploy/backups/<时间戳>/` 并写 `RESTORE.md`(沿用 AGENTS.md「生产栈收敛三件套」);
- 变更完成并验证通过前,备份不得清理;验收通过后按 RESTORE.md 归档流程归档。
- 本计划内的触发点:P0-3(PG 参数 + 重启)、P1-1(物化列迁移)、P1-2(重算/同步行为变更)、P2-1(索引/扩展)。
2. **写路径必失效缓存**:凡引入缓存(P0-1),所有数据写路径(admin 侧 CRUD、sync 同步、族重算、上传替换)在写事务提交成功后必须同步失效对应缓存域;「等待 TTL 自然过期」不作为可接受的兜底;每条写路径都必须有「写后缓存已失效」的测试覆盖。
## 执行顺序与依赖
```
P0-3(池参数,防排队超时)→ P0-1(缓存,DB 负载降 95%+)→ P0-2nginx,边缘承压)
→ P1-3(压测基线,验证 P0 是否达标)
→ P1-1(SQL 分页,缓存兜底后放宽量级)→ P1-2(同步隔离)→ P2(收尾项)
```
P0 三项互相独立可并行;P1-3 必须在 P0 全部上线后进行,否则测的是改造前基线(可先测一次作对照)。
---
## P0-1 进程内存缓存层(DB 热路径负载降 95%+,杠杆最高)
**原则**:public 读路径数据变更频率 = 小时级同步 → 分钟级 TTL 缓存几乎无损新鲜度。缓存键必须包含影响结果的全部入参;失效锚点为同步任务(见 P0-1.3)。
### P0-1.1 低熵全量数据缓存(categories / countries / tags / tag-groups / 树序元数据 / 族最低价)
- 涉及:`apps/api/src/public/public.service.ts``apps/api/src/app.module.ts`(若用 `@nestjs/cache-manager`
- 做法:引入进程内存缓存(`@nestjs/cache-manager` 或自研 Map+TTL 包装,单进程部署下内存缓存即全局缓存;不引入 Redis,避免新增运维依赖)。
- `loadTreeOrderMeta()``loadFamilyMinPrices()` 两个私有聚合结果整体缓存(TTL 10min)——它们被列表/首页每次请求复用,是最大的固定成本。
- `getCategoriesTree` / `getCountries` / `getTags` / `getTagGroups``countryId`(若有)作键缓存结果(TTL 10min)。
- 测试:TDD 先写——缓存命中返回等价结构、TTL 过期后重新查询、countryId 不同键不串数据。
- 回归风险:同步窗口内缓存有最多 10min 滞后 → P0-1.3 主动失效可缩短到秒级。
### P0-1.2 商品列表缓存:全量物化 + 内存分页(各 page/筛选组合共享一份缓存)
- 涉及:`apps/api/src/public/public.service.ts``getGoods`
- 做法:**不要**缓存「page 组合」——对筛选结果(countryId/keyword/category/tags/价格区间 + 排序)物化出 `{ rows: PublicGoodDto[](已排序已分组), total }` 整体缓存(TTL 10min,键 = 筛选参数序列化,不含 page/pageSize),`items.slice(start, start+pageSize)` 在缓存命中后执行。默认排序下全部 240 行物化成本一次,所有页共享。
- 注意:价格筛选/价格排序依赖族最低价 → 与 P0-1.1 的族最低价缓存同源;族最低价变化(族重算/同步)后必须连带失效列表缓存。
- 测试:TDD——同筛选不同 page 只触发一次底层查询(spy findMany 计数);筛选参数不同键不同;失效后重建。
- 回归风险:内存占用 = 缓存条目 × 240 DTO(每条 ~1KB)→ 筛选组合多时需设条目上限(LRU,如 64 条)与单条目 TTL。
### P0-1.3 详情缓存
- 涉及:`public.service.ts``getGoodByFamilyId`
- 做法:`goods/:id` 详情按 familyId 缓存(TTL 10min;响应含 11KB price_matrix,缓存收益尤其大)。
- 测试:命中返回等价结构、TTL 过期重建、失效后重建。
- 回归风险:多进程部署时内存缓存不相通 → 当前单进程无此问题;扩容多副本时需换 Redis 或接受短滞后(文档注明)。
### P0-1.4 缓存失效统一机制(全局约束 §2 的落地,必须先于/同步于 P0-1.1~1.3 交付)
- 涉及:新增 `apps/api/src/public/public-cache.service.ts`(或等价),`apps/api/src/sync/sync.service.ts``family-recompute.service.ts`、admin 侧全部写服务
- 做法:
1. **版本域设计**:每个缓存键带版本前缀,分域管理——`v:categories`(分类树/国家/标签组/树序元数据)、`v:goods`(列表/首页/详情)、`v:matrix`(族最低价);读路径取版本号拼键,写路径 `bump(域)` 使整个域失效。域粒度避免「任意写导致全量缓存清空」,实现简单且不漏键(不做逐个 `del` 键级清理,易漏易错)。
2. **统一入口**`PublicCacheService` 只暴露 `get/bump/set` 三个方法;所有写路径收口到 `bump`,禁止在业务代码里散落 cache 键。
3. **写路径挂钩清单**(缓存上线时必须全部挂上,缺一不可):
- `sync.service.ts``syncCategories` 提交后 → `v:categories``syncProducts` 提交后 → `v:goods`;详情同步 `persistProductDetail` 提交后 → `v:goods`
- `family-recompute.service.ts`:族重算 `persist` 提交后 → `v:matrix` + `v:goods`
- admin 侧写服务:goods CRUD/排序/标签挂接、categories、tags/tag-groups、positions、product-familiesoverride)→ 按影响域 `bump`;注意单纯「上传文件」若不改库行则无需 bump,挂钩点是**引用该图的写操作**(改 goods/detail/media 行 → `v:goods`)。
4. **写后即失效**write-through 语义):`bump` 必须在写事务提交**成功之后**执行(事务回滚不得 bump);`bump` 本身用版本号递增(防止「失效瞬间的并发读又把旧值写回缓存」的竞态——读路径读版本号后写入,版本号已变则丢弃写入或重查)。
- 测试(每个写路径至少一条):
- 「写后缓存已失效」:写入数据 → 断言对应缓存域读不到旧值;
- 「TTL 未到期但写已发生 → 前台立即可见新数据」的端到端用例(同步/族重算/admin 修改各一);
- 「事务回滚不 bump」:构造回滚路径断言版本号未变;
- 并发竞态:并发读+写下不出现陈旧值(版本号检查路径)。
- 回归风险:挂接漏写路径 = 前台最长 10min 陈旧(比报错更隐蔽)→ 挂钩清单作为自查 checklist 纳入 PR 模板与代码评审。
---
## P0-2 边缘 nginx 调优(连接数硬顶 4096 → 16384+,压缩与限流补齐)
- 涉及:`deploy/nginx/admin.conf`bind mount,改后 `docker compose up -d --force-recreate admin`,文件型挂载 inode 不随 restart 生效)、**新增** `deploy/nginx/nginx.conf` + `deploy/admin.Dockerfile`(主配置 COPY 进镜像,需 `docker compose build` 重建——只 force-recreate 不生效)、`deploy/nginx/admin.v2.conf`/v2/h5/ /v2/admin/ 静态缓存头落点)、`deploy/docker-compose.yml`admin 服务 ulimits
- 做法(`/etc/nginx/nginx.conf` 主配置无法覆盖时,在 conf.d 里用 `worker_rlimit_nofile` 不行——主配置的 workers 属 http 级不可段内覆盖——需在镜像层覆盖主配置或确认官方镜像默认即可):
1. **连接数**(已核实:`deploy/admin.Dockerfile` 最终镜像只 COPY 了 `conf.d/default.conf`,主 nginx.conf 为官方原版 `worker_connections 1024``events{}` 只存在于主配置,conf.d 无法覆盖):新增 `deploy/nginx/nginx.conf` 并在 Dockerfile 最终阶段加 `COPY deploy/nginx/nginx.conf /etc/nginx/nginx.conf`——内容 `worker_processes auto; worker_rlimit_nofile 65536; events { worker_connections 16384; multi_accept on; }`;同时在 compose `admin` 服务加 `ulimits: { nofile: { soft: 65536, hard: 65536 } }`4 worker × 16384 连接 + 上游 keepalive 连接需落在容器 fd 预算内,Docker 默认值需实测确认,低于预算必须显式设置)。
2. **upstream keepalive**`/v2-api/``/public/``/uploads/``/assets/` 四个 proxy location 统一加 `upstream v2_api { server v2-api:3001; keepalive 64; }` + `proxy_http_version 1.1`(已有)+ `proxy_set_header Connection ""`keepalive 必需)。
3. **gzip on**`gzip on; gzip_types application/json application/javascript text/css image/svg+xml; gzip_min_length 1k;`JSON API + SPA 静态资源,带宽降 5~10x)。
4. **静态资源缓存头**(注意配置落点):`/uploads/``/assets/` 在边缘 admin.conf 加 `expires 30d; add_header Cache-Control "public, immutable";`;但 `/v2/h5/``/v2/admin/` 的静态文件实际由 **v2-admin 容器的 `deploy/nginx/admin.v2.conf`** 提供(边缘只做代理),哈希文件名的缓存头要加在那边,`index.html` 保持 `no-cache`——边缘不要对代理响应统一加 expires,避免覆盖源头的 no-cache 语义。
5. **limit_req 兜底**(定位:只挡瞬时洪峰/扫描,不做业务限流——app 层 120 req/min/IP(≈2 r/s 持续)比它严得多):`limit_req_zone $binary_remote_addr zone=public:10m rate=50r/s;` + `limit_req zone=public burst=200 nodelay;` + 显式 `limit_req_status 429;`(默认 503 会让前端误判服务故障),仅挂 `/public/``/v2-api/` 两个 location。
6. **proxy 超时显式化**`proxy_connect_timeout 5s; proxy_read_timeout 30s; proxy_send_timeout 30s;`(默认 60s 过长,同步长事务窗口内挂死连接)。
7. **client_header_buffer 大请求头**(1 万连接下避免默认 1k 头部缓冲爆),并按 AGENTS.md 先做配置快照(`deploy/backups/<时间戳>/`)。
- 测试/验证:改后 `docker compose config` 校验、`nginx -t`(容器内)、`curl -I` 断言 gzip 头/Cache-Control;压测脚本断言无 connection refused。
- 回归风险:`/uploads/` 长缓存头会让「替换图片同名不同图」被浏览器缓存 → 上传路径需确认图片 URL 含版本参数或降级 `max-age=1d`(此点实施时与业务确认)。
## P0-3 连接池与超时参数(防排队超时与慢查询占池)
- 涉及:`deploy/docker-compose.yml`(生产 `DATABASE_URL`
- 做法:
1. `DATABASE_URL` 追加 `?connection_limit=50&pool_timeout=3`。注意 `pool_timeout` 单位是**秒**(Prisma 文档默认 10s),不要写成毫秒;不要加 `statement_cache_size=0`(那是 pgbouncer 事务模式的做法,直连场景禁用 prepared statement 反而拖慢)。50 < PG 上限,为同步与运维连接留余量。
2. PG 容器 `command: ["postgres", "-c", "statement_timeout=10000", "-c", "shared_buffers=512MB", "-c", "effective_cache_size=1536MB", "-c", "max_connections=200"]`14GB 内存机,shared_buffers 512MB 够工作集;statement_timeout 10s 兜住慢查询不占池)。
3. 同步任务自身查库量大(长事务串行循环)—— statement_timeout 10s 需确认不误伤同步批量 upsert(单条语句都不应超 10s;若误伤,同步侧改批量化见 P1-2)。
4. 变更 PG 参数需重启容器(秒级中断):按全局约束 §1 先做配置快照 + `pg_dump -Fc` 存档,低峰窗口执行,`docker compose up -d --force-recreate v2-postgres` 后立即验证健康与连接水位。
- 测试:入参级别——用一次性 docker postgresAGENTS.md 同款 54329 套路)跑 `prisma migrate deploy` + 全量 jest 确认无超时回归;生产以 `SHOW statement_timeout`/`pg_stat_activity` 验证。
- 回归风险:`pool_timeout=3000` 让洪峰期排队 >3s 快速失败(429/500)而非挂 10s——配合 P0-1 缓存后池压力极小;这是有意的快速失败语义,需在压测时观察错误率符合验收口径。
---
## P1-1 `/public/goods` SQL 分页(量级放宽后的正解)
- 涉及:`apps/api/src/public/public.service.ts``getGoods`
- 背景:P0-1.2 缓存已接住当前百级量级;SQL 分页是为「商品量级上万」预埋(代码注释 L231-233 已留方向)。
- 做法:族表物化视图方向——`product_families` 加「族代表行」物化列(代表 Good 的 goodName/主图/分类/countryId/排序键)与**族最低价物化列**P0-1.1 的 LATERAL 聚合结果届时改为由族重算路径写入该列,内存缓存退化为直读列),列表查询改为 `findMany({ take, skip, include })` + 独立 `count`,去掉内存分组与全量拉取。物化列的写入统一挂在族重算 `recomputeFamily` 与 admin 写路径上(写后 bump 对应缓存域,遵守全局约束 §2)。
- 测试:TDD——分页正确性(边界页、total 与 items 长度)、排序与现内存版全等(用 AGENTS.md「同一排序函数生成期望」防中文/键序手写错误)、打乱输入断言输出全等。
- 风险:改动面大(DTO/排序语义),**必须**与现实现做差分对照(同一数据集新老实现结果全等),放 P1 不与 P0 抢时间窗口;涉及 schema 变更(族表物化列)时按全局约束 §1 先备份存档并交付回滚迁移。
## P1-2 同步任务与 public 隔离
- 涉及:`apps/api/src/sync/sync.service.ts``apps/api/src/product-families/family-recompute.service.ts`
- 做法(按成本升序,实施时选一):
1. **错峰**:整点分类同步窗口(~分钟级长事务)移到低峰(如 04:00 后紧邻详情同步);至少避开业务高峰。
2. **批量化**syncCategories 单事务串行 → 分批事务(每 50 分类一提交);syncProducts 逐商品 upsert → `createMany`/并行化(注意 SDS 依赖逐条校验)。
3. **独立 worker**`SYNC_WORKER=true` 环境开关,第二个 v2-api 容器只跑 schedule`app.listen` 前 return / NestFactory disable listen),与 public 进程池物理隔离——唯一彻底方案,代价是 +1 容器与连接预算(已 P0-3 预留 50/池 ×2)。
- 测试:同步结果幂等与现行为全等(对比 sync_logs 行数与库行数);并发压测与同步同时进行的窗口场景;行为变更(批量化/重算)执行前按全局约束 §1 备份存档。
- 风险:族重算 fire-and-forget 队列在独立 worker 下需确认 enqueue 侧(public 进程也会 trigger?——现状 enqueue 在同步与族变更路径,若拆分需统一入口)。
## P1-3 压测基线(验收依据,必须分布式源)
- 涉及:k6(或 wrk)脚本放 `scripts/loadtest/`;目标环境与压力机要求见步骤 0
- 步骤:
0. **目标环境(硬性)**:1 万并发口径压测**不得直打生产栈**——用最近 `pg_dump` 在一次性镜像栈恢复(AGENTS.md 同款 `docker run … postgres:16-alpine` 起库 + `prisma migrate deploy` + 单独 compose 起 api/nginx 副本,参数与生产一致),`THROTTLE_LIMIT` 只在镜像栈抬高;若最终必须在生产验证,只允许业务书面确认的低峰窗口 + 只读场景 + 限流不动的保守梯度。**压力机必须是独立机器**(与被测机同机跑 10k VU 会互相抢 CPU,测出来的是压测机瓶颈)。
1. SQL 单测基线:dump 恢复后逐条跑 `loadFamilyMinPrices` / `loadTreeOrderMeta` / 列表全量 / 详情 / ILIKE keyword,记录真实耗时(填空报告 §2 的估算值)。
2. 功能压测:`THROTTLE_LIMIT` 临时抬到 1e6(或分布式 ≥ 数十 IP)避免 429 干扰;场景 = 混合读(home-goods / goods / 详情 / 分类树按 3:4:2:1)。
3. 口径压测:逐步加并发至 1 万瞬时请求(如 k6 10000 VU,分布式源),记录 p95/p99/错误率/nginx 连接拒绝/PG 水位,并记录 Node 进程 CPU 与事件循环延迟(`--max-old-space-size` 与 GC 停顿)。
4. **达标失败的扩展阶梯**(按成本升序,逐级尝试后再进下一级):
a. **响应字节级缓存**:P0-1 缓存的是 DTO 对象,命中后每请求仍要 `JSON.stringify`——1 万瞬时 burst 下单进程事件循环的序列化 CPU 可能成为新瓶颈(每响应 ~10-20KB × 1 万次)。若压测显示 stringify 是热点,把缓存值改为**已序列化的最终 JSON 字符串**(含 TransformInterceptor 包裹结构),命中路径直接回写字节,绕过逐请求序列化;
b. **nginx micro-cache**`proxy_cache``/public/` GET 设 10~30s 短 TTL,洪峰完全由边缘吸收。⚠️ 与全局约束 §2 冲突:proxy_cache 无法主动 purge,只能靠短 TTL——需业务确认接受该陈旧窗口,不接受则跳过此项;
c. **Redis + 多副本**:内存缓存换共享存储(版本域失效天然跨进程),加 v2-api 副本(nginx upstream 已 keepalive,扩容仅加容器 + PG max_connections 预算)。
- 交付:压测报告(数字+结论)追加到性能审查报告 §5 验收结果。
---
## P2 收尾项(非阻塞,可后置)
1. **静态资源边缘直出**`/uploads/` 数据卷直挂 edge nginx`deploy/docker-compose.yml``v2-uploads` 卷 + try_files),省一跳代理;注意 upload 与 nginx 读同卷的一致性(nginx 缓存/直读无冲突)。
2. **pg_trgm 索引**`CREATE EXTENSION pg_trgm; CREATE INDEX ... ON goods USING GIN (good_name gin_trgm_ops);`admin 侧 origin_goods.good_name 同)——goods 上万后 keyword 才需要;迁移文件 + 回滚脚本,执行前按全局约束 §1 备份存档(CREATE EXTENSION 为 DDL,回滚与重放需按迁移流程管理)。
3. **监控告警**sync_logs 失败数、`pg_stat_activity` 连接水位、Prisma 池排队(日志关键字 `Timed out fetching a new connection`)→ 告警;nginx access log 结构化落盘(当前无 access 日志,1w 并发下调试无从下手)。
4. **README/docs 更新**`docs/references/structs.md`(部署拓扑段补充性能配置说明)按 AGENTS.md 第 6/7 节执行。
## 全局回归策略
- 每项任务独立分支提交(Conventional Commits`perf(...)`),合并回 `develop`
- 全量 jest 前停 dev serverAGENTS.md 经验);连共享库前 `prisma migrate status`;集成测试夹具自包含(一次性 pg 跑套件)。
- 缓存相关改动重点回归:列表/详情/首页三端点输出与改造前全等(差分测试)、同步后新鲜度、TTL 失效路径。
## 验收(全部完成后)
- 分布式压测 1 万并发瞬时请求:p95 < 300ms、p99 < 1s、错误率 < 0.1%、无连接拒绝、PG 水位 < 80%、无池排队超时。
- 全局约束核查:涉及 DB 变更的任务全部留有 `deploy/backups/<时间戳>/` 存档与 RESTORE.md(可 dry-run 恢复验证);全部写路径(sync/族重算/admin CRUD/上传)的「写后缓存即失效」用例通过(含事务回滚不 bump、并发读不写回旧值)。
- 结果数字回填报告 §5 并关闭本计划。