diff --git a/AGENTS.md b/AGENTS.md index a2161aa..994139e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,3 +110,4 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **两端"同构"解析器必须连测试用例也同构**: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`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。 diff --git a/apps/api/package.json b/apps/api/package.json index 197efa2..40fa435 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,8 @@ "backfill:product-families": "ts-node prisma/backfill-product-families.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts", "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", + "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", + "fix:category-names": "ts-node prisma/fix-category-parens.ts", "organize": "ts-node prisma/backfill-product-families.ts", "recompute:families": "ts-node prisma/recompute-all-families.ts" }, diff --git a/apps/api/prisma/fix-category-parens.ts b/apps/api/prisma/fix-category-parens.ts new file mode 100644 index 0000000..bc00977 --- /dev/null +++ b/apps/api/prisma/fix-category-parens.ts @@ -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); +}); diff --git a/apps/api/src/sync/category-name.spec.ts b/apps/api/src/sync/category-name.spec.ts new file mode 100644 index 0000000..a538ad9 --- /dev/null +++ b/apps/api/src/sync/category-name.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 934cf2c..1a75895 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -24,6 +24,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; @@ -185,7 +203,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 +212,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, }, }); diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 3982898..7c4761b 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -225,6 +225,15 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理 旧名→新名清单,`-- --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)均以分类名为源,自动随之变干净。 + diff --git a/docs/references/structs.md b/docs/references/structs.md index cd238b3..2f5308b 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -48,7 +48,8 @@ apps/api/ │ ├── 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-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) +│ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认) ├── src/ │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── app.module.ts # 根模块,聚合所有业务模块 diff --git a/plans/fix/category-name-parens-fix.md b/plans/fix/category-name-parens-fix.md new file mode 100644 index 0000000..402b2d5 --- /dev/null +++ b/plans/fix/category-name-parens-fix.md @@ -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 脚本为运维通道 —— 无需新增权限项。