merge: bugfix/category-name-parens-yeuimu into develop (分类名去括号)

This commit is contained in:
yeuimu
2026-09-03 01:20:17 +08:00
8 changed files with 190 additions and 3 deletions
+1
View File
@@ -101,3 +101,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`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
+1
View File
@@ -25,6 +25,7 @@
"import:product-detail": "ts-node prisma/import-product-detail.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"
},
"dependencies": {
+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);
});
+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);
});
});
+20 -2
View File
@@ -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,
},
});
+9
View File
@@ -241,6 +241,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)均以分类名为源,自动随之变干净。
+2 -1
View File
@@ -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 # 根模块,聚合所有业务模块
+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 脚本为运维通道 —— 无需新增权限项。