feat(dict): 改进字典项名称格式化为kebab-case并更新唯一约束

重构字典服务以统一格式化名称,将字典项名称中的空格、下划线和点转换为中划线并转为小写
更新字典项实体,移除name字段的唯一约束,改为组合索引(name, dict)
添加根据字典名称获取字典项的API端点
更新数据迁移和种子数据以匹配新的名称格式
This commit is contained in:
tikkhun 2025-12-01 15:07:06 +08:00
parent e4fc195b8d
commit 10b42eca7a
8 changed files with 256 additions and 108 deletions

View File

@ -131,9 +131,13 @@ export class DictController {
* @param dictId ID ()
*/
@Get('/items')
async getDictItems(@Query('dictId') dictId?: number) {
async getDictItems(
@Query('dictId') dictId?: number,
@Query('name') name?: string,
@Query('title') title?: string,
) {
// 调用服务层方法
return this.dictService.getDictItems(dictId);
return this.dictService.getDictItems({ dictId, name, title });
}
/**
@ -168,4 +172,14 @@ export class DictController {
// 调用服务层方法
return this.dictService.deleteDictItem(id);
}
/**
*
* @param name
*/
@Get('/items-by-name')
async getDictItemsByDictName(@Query('name') name: string) {
// 调用服务层方法
return this.dictService.getDictItemsByDictName(name);
}
}

View File

@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateDictItemUniqueConstraint1764569947170 implements MigrationInterface {
name = 'UpdateDictItemUniqueConstraint1764569947170'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`);
}
}

View File

@ -1,103 +1,114 @@
import { Seeder, SeederFactoryManager } from 'typeorm-extension';
import { Seeder } from 'typeorm-extension';
import { DataSource } from 'typeorm';
import { Dict } from '../../entity/dict.entity';
import { DictItem } from '../../entity/dict_item.entity';
export default class DictSeeder implements Seeder {
/**
* kebab-case
* @param name
* @returns
*/
private formatName(name: string): string {
// 将空格、下划线、点统一转换成中划线,并转为小写
return String(name).replace(/[_\s.]+/g, '-').toLowerCase();
}
public async run(
dataSource: DataSource,
factoryManager: SeederFactoryManager
): Promise<any> {
const dictRepository = dataSource.getRepository(Dict);
const dictItemRepository = dataSource.getRepository(DictItem);
const flavorsData = [
{ id: 1, title: 'Bellini Mini', name: 'Bellini Mini' },
{ id: 2, title: 'Max Polarmint', name: 'Max Polarmint' },
{ id: 3, title: 'Blueberry', name: 'Blueberry' },
{ id: 4, title: 'Citrus', name: 'Citrus' },
{ id: 5, title: 'Wintergreen', name: 'Wintergreen' },
{ id: 6, title: 'COOL MINT', name: 'COOL MINT' },
{ id: 7, title: 'JUICY PEACH', name: 'JUICY PEACH' },
{ id: 8, title: 'ORANGE', name: 'ORANGE' },
{ id: 9, title: 'PEPPERMINT', name: 'PEPPERMINT' },
{ id: 10, title: 'SPEARMINT', name: 'SPEARMINT' },
{ id: 11, title: 'STRAWBERRY', name: 'STRAWBERRY' },
{ id: 12, title: 'WATERMELON', name: 'WATERMELON' },
{ id: 13, title: 'COFFEE', name: 'COFFEE' },
{ id: 14, title: 'LEMONADE', name: 'LEMONADE' },
{ id: 15, title: 'apple mint', name: 'apple mint' },
{ id: 16, title: 'PEACH', name: 'PEACH' },
{ id: 17, title: 'Mango', name: 'Mango' },
{ id: 18, title: 'ICE WINTERGREEN', name: 'ICE WINTERGREEN' },
{ id: 19, title: 'Pink Lemonade', name: 'Pink Lemonade' },
{ id: 20, title: 'Blackcherry', name: 'Blackcherry' },
{ id: 21, title: 'fresh mint', name: 'fresh mint' },
{ id: 22, title: 'Strawberry Lychee', name: 'Strawberry Lychee' },
{ id: 23, title: 'Passion Fruit', name: 'Passion Fruit' },
{ id: 24, title: 'Banana lce', name: 'Banana lce' },
{ id: 25, title: 'Bubblegum', name: 'Bubblegum' },
{ id: 26, title: 'Mango lce', name: 'Mango lce' },
{ id: 27, title: 'Grape lce', name: 'Grape lce' },
{ title: 'Bellini', name: 'bellini' },
{ title: 'Max Polarmint', name: 'max-polarmint' },
{ title: 'Blueberry', name: 'blueberry' },
{ title: 'Citrus', name: 'citrus' },
{ title: 'Wintergreen', name: 'wintergreen' },
{ title: 'COOL MINT', name: 'cool-mint' },
{ title: 'JUICY PEACH', name: 'juicy-peach' },
{ title: 'ORANGE', name: 'orange' },
{ title: 'PEPPERMINT', name: 'peppermint' },
{ title: 'SPEARMINT', name: 'spearmint' },
{ title: 'STRAWBERRY', name: 'strawberry' },
{ title: 'WATERMELON', name: 'watermelon' },
{ title: 'COFFEE', name: 'coffee' },
{ title: 'LEMONADE', name: 'lemonade' },
{ title: 'apple mint', name: 'apple-mint' },
{ title: 'PEACH', name: 'peach' },
{ title: 'Mango', name: 'mango' },
{ title: 'ICE WINTERGREEN', name: 'ice-wintergreen' },
{ title: 'Pink Lemonade', name: 'pink-lemonade' },
{ title: 'Blackcherry', name: 'blackcherry' },
{ title: 'fresh mint', name: 'fresh-mint' },
{ title: 'Strawberry Lychee', name: 'strawberry-lychee' },
{ title: 'Passion Fruit', name: 'passion-fruit' },
{ title: 'Banana lce', name: 'banana-lce' },
{ title: 'Bubblegum', name: 'bubblegum' },
{ title: 'Mango lce', name: 'mango-lce' },
{ title: 'Grape lce', name: 'grape-lce' },
{ title: 'apple', name: 'apple' },
{ title: 'grape', name: 'grape' },
{ title: 'cherry', name: 'cherry' },
{ title: 'lemon', name: 'lemon' },
{ title: 'razz', name: 'razz' },
{ title: 'pineapple', name: 'pineapple' },
{ title: 'berry', name: 'berry' },
{ title: 'fruit', name: 'fruit' },
{ title: 'mint', name: 'mint' },
{ title: 'menthol', name: 'menthol' },
];
const brandsData = [
{ id: 1, title: 'Yoone', name: 'YOONE' },
{ id: 2, title: 'White Fox', name: 'WHITE_FOX' },
{ id: 3, title: 'ZYN', name: 'ZYN' },
{ id: 4, title: 'Zonnic', name: 'ZONNIC' },
{ id: 5, title: 'Zolt', name: 'ZOLT' },
{ id: 6, title: 'Velo', name: 'VELO' },
{ id: 7, title: 'Lucy', name: 'LUCY' },
{ id: 8, title: 'EGP', name: 'EGP' },
{ id: 9, title: 'Bridge', name: 'BRIDGE' },
{ id: 10, title: 'ZEX', name: 'ZEX' },
{ id: 11, title: 'Sesh', name: 'Sesh' },
{ id: 12, title: 'Pablo', name: 'Pablo' },
{ title: 'Yoone', name: 'yoone' },
{ title: 'White Fox', name: 'white-fox' },
{ title: 'ZYN', name: 'zyn' },
{ title: 'Zonnic', name: 'zonnic' },
{ title: 'Zolt', name: 'zolt' },
{ title: 'Velo', name: 'velo' },
{ title: 'Lucy', name: 'lucy' },
{ title: 'EGP', name: 'egp' },
{ title: 'Bridge', name: 'bridge' },
{ title: 'ZEX', name: 'zex' },
{ title: 'Sesh', name: 'sesh' },
{ title: 'Pablo', name: 'pablo' },
];
const strengthsData = [
{ id: 1, title: '3MG', name: '3MG' },
{ id: 2, title: '9MG', name: '9MG' },
{ id: 3, title: '2MG', name: '2MG' },
{ id: 4, title: '4MG', name: '4MG' },
{ id: 5, title: '12MG', name: '12MG' },
{ id: 6, title: '18MG', name: '18MG' },
{ id: 7, title: '6MG', name: '6MG' },
{ id: 8, title: '16.5MG', name: '16.5MG' },
{ id: 9, title: '6.5MG', name: '6.5MG' },
{ id: 10, title: '30MG', name: '30MG' },
{ title: '3MG', name: '3mg' },
{ title: '9MG', name: '9mg' },
{ title: '2MG', name: '2mg' },
{ title: '4MG', name: '4mg' },
{ title: '12MG', name: '12mg' },
{ title: '18MG', name: '18mg' },
{ title: '6MG', name: '6mg' },
{ title: '16.5MG', name: '16-5mg' },
{ title: '6.5MG', name: '6-5mg' },
{ title: '30MG', name: '30mg' },
];
// 在插入新数据前,不清空旧数据,改为如果不存在则创建
// await dictItemRepository.query('DELETE FROM `dict_item`');
// await dictRepository.query('DELETE FROM `dict`');
// // 重置自增 ID
// await dictItemRepository.query('ALTER TABLE `dict_item` AUTO_INCREMENT = 1');
// await dictRepository.query('ALTER TABLE `dict` AUTO_INCREMENT = 1');
const nonFlavorTokensData = ['slim', 'pouches', 'pouch', 'mini', 'dry'].map(item => ({ title: item, name: item }));
// 初始化语言字典
const locales = [
{ name: 'zh-CN', title: '简体中文' },
{ name: 'en-US', title: 'English' },
{ name: 'zh-cn', title: '简体中文' },
{ name: 'en-us', title: 'English' },
];
for (const locale of locales) {
let dict = await dictRepository.findOne({ where: { name: locale.name } });
if (!dict) {
dict = await dictRepository.save(locale);
}
await this.createOrFindDict(dictRepository, locale);
}
// 添加示例翻译条目
const zhDict = await dictRepository.findOne({ where: { name: 'zh-CN' } });
const enDict = await dictRepository.findOne({ where: { name: 'en-US' } });
const zhDict = await dictRepository.findOne({ where: { name: 'zh-cn' } });
const enDict = await dictRepository.findOne({ where: { name: 'en-us' } });
const translations = [
{ name: 'common.save', zh: '保存', en: 'Save' },
{ name: 'common.cancel', zh: '取消', en: 'Cancel' },
{ name: 'common.success', zh: '操作成功', en: 'Success' },
{ name: 'common.failure', zh: '操作失败', en: 'Failure' },
{ name: 'common-save', zh: '保存', en: 'Save' },
{ name: 'common-cancel', zh: '取消', en: 'Cancel' },
{ name: 'common-success', zh: '操作成功', en: 'Success' },
{ name: 'common-failure', zh: '操作失败', en: 'Failure' },
];
for (const t of translations) {
@ -114,40 +125,56 @@ export default class DictSeeder implements Seeder {
}
}
const brandDict = await dictRepository.save({ title: '品牌', name: 'brand' });
const flavorDict = await dictRepository.save({ title: '口味', name: 'flavor' });
const strengthDict = await dictRepository.save({ title: '强度', name: 'strength' });
const brandDict = await this.createOrFindDict(dictRepository, { title: '品牌', name: 'brand' });
const flavorDict = await this.createOrFindDict(dictRepository, { title: '口味', name: 'flavor' });
const strengthDict = await this.createOrFindDict(dictRepository, { title: '强度', name: 'strength' });
const nonFlavorTokensDict = await this.createOrFindDict(dictRepository, { title: '非口味关键词', name: 'non-flavor-tokens' });
// 遍历品牌数据
for (const brand of brandsData) {
// 保存字典项,并关联到品牌字典
await dictItemRepository.save({
title: brand.title,
name: brand.name,
dict: brandDict,
});
}
await this.seedDictItems(dictItemRepository, brandDict, brandsData);
// 遍历口味数据
for (const flavor of flavorsData) {
// 保存字典项,并关联到口味字典
await dictItemRepository.save({
title: flavor.title,
name: flavor.name,
dict: flavorDict,
});
}
await this.seedDictItems(dictItemRepository, flavorDict, flavorsData);
// 遍历强度数据
for (const strength of strengthsData) {
// 保存字典项,并关联到强度字典
await dictItemRepository.save({
title: strength.title,
name: strength.name,
dict: strengthDict,
});
await this.seedDictItems(dictItemRepository, strengthDict, strengthsData);
// 遍历非口味关键词数据
await this.seedDictItems(dictItemRepository, nonFlavorTokensDict, nonFlavorTokensData);
}
/**
*
* @param repo DictRepository
* @param dictInfo
* @returns Dict
*/
private async createOrFindDict(repo: any, dictInfo: { title: string; name: string }): Promise<Dict> {
// 格式化 name
const formattedName = this.formatName(dictInfo.name);
let dict = await repo.findOne({ where: { name: formattedName } });
if (!dict) {
// 如果字典不存在,则使用格式化后的 name 创建新字典
dict = await repo.save({ title: dictInfo.title, name: formattedName });
}
return dict;
}
/**
*
* @param repo DictItemRepository
* @param dict
* @param items
*/
private async seedDictItems(repo: any, dict: Dict, items: { title: string; name: string }[]): Promise<void> {
for (const item of items) {
// 格式化 name
const formattedName = this.formatName(item.name);
const existingItem = await repo.findOne({ where: { name: formattedName, dict: { id: dict.id } } });
if (!existingItem) {
// 如果字典项不存在,则使用格式化后的 name 创建新字典项
await repo.save({ ...item, name: formattedName, dict });
}
}
}
}

View File

@ -40,6 +40,23 @@ export class CreateProductDTO {
@ApiProperty({ description: '商品类型', enum: ['simple', 'bundle'], default: 'simple', required: false })
@Rule(RuleType.string().valid('simple', 'bundle').default('simple'))
type?: string;
// 中文注释:仅当 type 为 'bundle' 时,才需要提供 components
@ApiProperty({ description: '产品组成', type: 'array', required: false })
@Rule(
RuleType.array()
.items(
RuleType.object({
sku: RuleType.string().required(),
quantity: RuleType.number().required(),
})
)
.when('type', {
is: 'bundle',
then: RuleType.array().required(),
})
)
components?: { sku: string; quantity: number }[];
}
/**

View File

@ -9,6 +9,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToMany,
ManyToOne,
@ -17,6 +18,7 @@ import {
} from 'typeorm';
@Entity()
@Index(['name', 'dict'], { unique: true })
export class DictItem {
// 主键
@PrimaryGeneratedColumn()
@ -29,7 +31,7 @@ export class DictItem {
@Column({ comment: '字典项中文名称', nullable: true })
titleCN: string;
// 唯一标识
@Column({ unique: true, comment: '字典唯一标识名称' })
@Column({ comment: '字典唯一标识名称' })
name: string;
// 字典项值

View File

@ -64,7 +64,7 @@ export class Product {
@Column({ default: 0 })
stock: number;
@ManyToMany(() => DictItem, {
@ManyToMany(() => DictItem, dictItem => dictItem.products, {
cascade: true,
})
@JoinTable()

View File

@ -16,6 +16,11 @@ export class DictService {
@InjectEntityModel(DictItem)
dictItemModel: Repository<DictItem>;
// 格式化名称为 kebab-case
private formatName(name: string): string {
return String(name).replace(/[_\s.]+/g, '-').toLowerCase();
}
// 生成并返回字典的XLSX模板
getDictXLSXTemplate() {
// 定义表头
@ -43,7 +48,7 @@ export class DictService {
// 创建要保存的字典实体数组
const dicts = data.map((row: any) => {
const dict = new Dict();
dict.name = row.name;
dict.name = this.formatName(row.name);
dict.title = row.title;
return dict;
});
@ -76,7 +81,7 @@ export class DictService {
const items = data.map((row: any) => {
const item = new DictItem();
item.name = row.name;
item.name = this.formatName(row.name);
item.title = row.title;
item.titleCN = row.titleCN; // 保存中文名称
item.value = row.value;
@ -106,13 +111,16 @@ export class DictService {
// 创建新字典
async createDict(createDictDTO: CreateDictDTO) {
const dict = new Dict();
dict.name = createDictDTO.name;
dict.name = this.formatName(createDictDTO.name);
dict.title = createDictDTO.title;
return this.dictModel.save(dict);
}
// 更新字典
async updateDict(id: number, updateDictDTO: UpdateDictDTO) {
if (updateDictDTO.name) {
updateDictDTO.name = this.formatName(updateDictDTO.name);
}
await this.dictModel.update(id, updateDictDTO);
return this.dictModel.findOneBy({ id });
}
@ -127,10 +135,23 @@ export class DictService {
}
// 获取字典项列表,支持按 dictId 过滤
async getDictItems(dictId?: number) {
// 如果提供了 dictId则只返回该字典下的项
async getDictItems(params: { dictId?: number; name?: string; title?: string; }) {
const { dictId, name, title } = params;
const where: any = {};
if (dictId) {
return this.dictItemModel.find({ where: { dict: { id: dictId } } });
where.dict = { id: dictId };
}
if (name) {
where.name = Like(`%${name}%`);
}
if (title) {
where.title = Like(`%${title}%`);
}
// 如果提供了 dictId则只返回该字典下的项
if (params.dictId) {
return this.dictItemModel.find({ where });
}
// 否则,返回所有字典项
return this.dictItemModel.find();
@ -143,7 +164,7 @@ export class DictService {
throw new Error('指定的字典不存在');
}
const item = new DictItem();
item.name = createDictItemDTO.name;
item.name = this.formatName(createDictItemDTO.name);
item.title = createDictItemDTO.title;
item.titleCN = createDictItemDTO.titleCN; // 保存中文名称
item.dict = dict;
@ -152,6 +173,9 @@ export class DictService {
// 更新字典项
async updateDictItem(id: number, updateDictItemDTO: UpdateDictItemDTO) {
if (updateDictItemDTO.name) {
updateDictItemDTO.name = this.formatName(updateDictItemDTO.name);
}
await this.dictItemModel.update(id, updateDictItemDTO);
return this.dictItemModel.findOneBy({ id });
}
@ -161,4 +185,16 @@ export class DictService {
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
// 根据字典名称获取字典项列表
async getDictItemsByDictName(dictName: string) {
// 查找字典
const dict = await this.dictModel.findOne({ where: { name: dictName } });
// 如果字典不存在,则返回空数组
if (!dict) {
return [];
}
// 返回该字典下的所有字典项
return this.dictItemModel.find({ where: { dict: { id: dict.id } } });
}
}

View File

@ -374,8 +374,12 @@ export class WpProductService {
// 数据转换
const items = rawResult.reduce((acc, row) => {
// 在累加器中查找当前产品
let product = acc.find(p => p.id === row.id);
// 如果产品不存在,则创建新产品
if (!product) {
// 从原始产品列表中查找,以获取 'site' 关联数据
const originalProduct = products.find(p => p.id === row.id);
product = {
...Object.keys(row)
.filter(key => !key.startsWith('variation_'))
@ -384,6 +388,8 @@ export class WpProductService {
return obj;
}, {}),
variations: [],
// 附加 'site' 对象
site: originalProduct.site,
};
acc.push(product);
}