From cd7278329108192c5878331cc2d14f44fd46eb9d Mon Sep 17 00:00:00 2001 From: tikkhun Date: Fri, 28 Nov 2025 10:13:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增Area实体及相关DTO、Service、Controller - 在Site和StockPoint实体中添加与Area的多对多关系 - 添加区域数据迁移文件和种子数据 - 修改Site实体字段siteName为name并保持兼容 - 在Product实体中添加promotionPrice和source字段 - 添加模板渲染接口 - 完善模板删除前的校验逻辑 --- src/config/config.default.ts | 4 +- src/controller/area.controller.ts | 110 ++++++++++++++++++++++++ src/controller/template.controller.ts | 23 +++++ src/db/datasource.ts | 4 +- src/db/migrations/1764294088896-Area.ts | 45 ++++++++++ src/db/seeds/area.seeder.ts | 26 ++++++ src/dto/area.dto.ts | 32 +++++++ src/entity/area.entity.ts | 35 ++++++++ src/entity/product.entity.ts | 10 +++ src/entity/site.entity.ts | 12 ++- src/entity/stock_point.entity.ts | 7 ++ src/service/area.service.ts | 90 +++++++++++++++++++ src/service/order.service.ts | 2 +- src/service/site.service.ts | 4 +- src/service/template.service.ts | 10 +++ 15 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 src/controller/area.controller.ts create mode 100644 src/db/migrations/1764294088896-Area.ts create mode 100644 src/db/seeds/area.seeder.ts create mode 100644 src/dto/area.dto.ts create mode 100644 src/entity/area.entity.ts create mode 100644 src/service/area.service.ts diff --git a/src/config/config.default.ts b/src/config/config.default.ts index d15df9f..a5f2c8c 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -34,6 +34,7 @@ import { Site } from '../entity/site.entity'; import { Dict } from '../entity/dict.entity'; import { DictItem } from '../entity/dict_item.entity'; import { Template } from '../entity/template.entity'; +import { Area } from '../entity/area.entity'; import DictSeeder from '../db/seeds/dict.seeder'; export default { @@ -76,7 +77,8 @@ export default { Site, Dict, DictItem, - Template + Template, + Area, ], synchronize: true, logging: false, diff --git a/src/controller/area.controller.ts b/src/controller/area.controller.ts new file mode 100644 index 0000000..ebfa390 --- /dev/null +++ b/src/controller/area.controller.ts @@ -0,0 +1,110 @@ + +import { Inject } from '@midwayjs/core'; +import { + Body, + Controller, + Del, + Get, + Param, + Post, + Put, + Query, +} from '@midwayjs/decorator'; +import { + ApiBearerAuth, + ApiBody, + ApiExtension, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@midwayjs/swagger'; +import { AreaService } from '../service/area.service'; +import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto'; +import { errorResponse, successResponse } from '../utils/response.util'; +import { Area } from '../entity/area.entity'; + +@ApiBearerAuth() +@ApiTags('Area') +@Controller('/api/area') +export class AreaController { + @Inject() + areaService: AreaService; + + @ApiOperation({ summary: '创建区域' }) + @ApiBody({ type: CreateAreaDTO }) + @ApiOkResponse({ type: Area, description: '成功创建的区域' }) + @Post('/') + async createArea(@Body() area: CreateAreaDTO) { + try { + const newArea = await this.areaService.createArea(area); + return successResponse(newArea, '创建成功'); + } catch (error) { + return errorResponse(error.message); + } + } + + @ApiOperation({ summary: '更新区域' }) + @ApiBody({ type: UpdateAreaDTO }) + @ApiOkResponse({ type: Area, description: '成功更新的区域' }) + @Put('/:id') + async updateArea(@Param('id') id: number, @Body() area: UpdateAreaDTO) { + try { + const updatedArea = await this.areaService.updateArea(id, area); + return successResponse(updatedArea, '更新成功'); + } catch (error) { + return errorResponse(error.message); + } + } + + @ApiOperation({ summary: '删除区域' }) + @ApiOkResponse({ description: '删除成功' }) + @Del('/:id') + async deleteArea(@Param('id') id: number) { + try { + await this.areaService.deleteArea(id); + return successResponse(null, '删除成功'); + } catch (error) { + return errorResponse(error.message); + } + } + + @ApiOperation({ summary: '获取区域列表(分页)' }) + @ApiOkResponse({ type: [Area], description: '区域列表' }) + @ApiExtension('x-pagination', { currentPage: 1, pageSize: 10, total: 100 }) + @Get('/') + async getAreaList(@Query() query: QueryAreaDTO) { + try { + const { list, total } = await this.areaService.getAreaList(query); + return successResponse({ list, total }, '查询成功'); + } catch (error) { + return errorResponse(error.message); + } + } + + @ApiOperation({ summary: '获取所有区域' }) + @ApiOkResponse({ type: [Area], description: '所有区域列表' }) + @Get('/all') + async getAllAreas() { + try { + const areas = await this.areaService.getAllAreas(); + return successResponse(areas, '查询成功'); + } catch (error) { + return errorResponse(error.message); + } + } + + @ApiOperation({ summary: '根据ID获取区域详情' }) + @ApiOkResponse({ type: Area, description: '区域详情' }) + @Get('/:id') + async getAreaById(@Param('id') id: number) { + try { + const area = await this.areaService.getAreaById(id); + if (!area) { + return errorResponse('区域不存在'); + } + return successResponse(area, '查询成功'); + } catch (error) { + return errorResponse(error.message); + } + } +} diff --git a/src/controller/template.controller.ts b/src/controller/template.controller.ts index c6eb5dc..80d0867 100644 --- a/src/controller/template.controller.ts +++ b/src/controller/template.controller.ts @@ -105,4 +105,27 @@ export class TemplateController { return errorResponse(error.message); } } + + /** + * @summary 渲染模板 + * @description 根据模板名称和输入的数据渲染最终字符串 + * @param name 模板名称 + * @param data 渲染数据 + */ + @ApiOkResponse({ type: String, description: '成功渲染模板' }) + @Post('/render/:name') + async renderTemplate( + @Param('name') name: string, + @Body() data: Record + ) { + try { + // 调用服务层渲染模板 + const renderedString = await this.templateService.render(name, data); + // 返回成功响应 + return successResponse(renderedString); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } } diff --git a/src/db/datasource.ts b/src/db/datasource.ts index 9be274a..a05ac66 100644 --- a/src/db/datasource.ts +++ b/src/db/datasource.ts @@ -35,6 +35,7 @@ import { Site } from '../entity/site.entity'; import { Dict } from '../entity/dict.entity'; import { DictItem } from '../entity/dict_item.entity'; import { Template } from '../entity/template.entity'; +import { Area } from '../entity/area.entity'; const options: DataSourceOptions & SeederOptions = { type: 'mysql', @@ -81,8 +82,9 @@ const options: DataSourceOptions & SeederOptions = { Dict, DictItem, Template, + Area, ], - migrations: ['src/migration/*.ts'], + migrations: ['src/db/migrations/**/*.ts'], seeds: ['src/db/seeds/**/*.ts'], }; diff --git a/src/db/migrations/1764294088896-Area.ts b/src/db/migrations/1764294088896-Area.ts new file mode 100644 index 0000000..188c35a --- /dev/null +++ b/src/db/migrations/1764294088896-Area.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Area1764294088896 implements MigrationInterface { + name = 'Area1764294088896' + + public async up(queryRunner: QueryRunner): Promise { + // await queryRunner.query(`DROP INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\``); + // await queryRunner.query(`CREATE TABLE \`area\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_644ffaf8fbde4db798cb47712f\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + // await queryRunner.query(`CREATE TABLE \`stock_point_areas_area\` (\`stockPointId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_07d2db2150151e2ef341d2f1de\` (\`stockPointId\`), INDEX \`IDX_92707ea81fc19dc707dba24819\` (\`areaId\`), PRIMARY KEY (\`stockPointId\`, \`areaId\`)) ENGINE=InnoDB`); + // await queryRunner.query(`CREATE TABLE \`site_areas_area\` (\`siteId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_926a14ac4c91f38792831acd2a\` (\`siteId\`), INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` (\`areaId\`), PRIMARY KEY (\`siteId\`, \`areaId\`)) ENGINE=InnoDB`); + // await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`siteName\``); + // await queryRunner.query(`ALTER TABLE `product` ADD `promotionPrice` decimal(10,2) NOT NULL DEFAULT '0.00'`); + // await queryRunner.query(`ALTER TABLE `product` ADD `source` int NOT NULL DEFAULT '0'`); + // await queryRunner.query(`ALTER TABLE \`site\` ADD \`token\` varchar(255) NULL`); + // await queryRunner.query(`ALTER TABLE `site` ADD `name` varchar(255) NOT NULL`); + // await queryRunner.query(`ALTER TABLE \`site\` ADD UNIQUE INDEX \`IDX_9669a09fcc0eb6d2794a658f64\` (\`name\`)`); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` ADD CONSTRAINT \`FK_07d2db2150151e2ef341d2f1de1\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` ADD CONSTRAINT \`FK_92707ea81fc19dc707dba24819c\` FOREIGN KEY (\`areaId\`) REFERENCES \`area\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` ADD CONSTRAINT \`FK_926a14ac4c91f38792831acd2a6\` FOREIGN KEY (\`siteId\`) REFERENCES \`site\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` ADD CONSTRAINT \`FK_7c26c582048e3ecd3cd5938cb9f\` FOREIGN KEY (\`areaId\`) REFERENCES \`area\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_areas_area\` DROP FOREIGN KEY \`FK_7c26c582048e3ecd3cd5938cb9f\``); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` DROP FOREIGN KEY \`FK_926a14ac4c91f38792831acd2a6\``); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` DROP FOREIGN KEY \`FK_92707ea81fc19dc707dba24819c\``); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` DROP FOREIGN KEY \`FK_07d2db2150151e2ef341d2f1de1\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP INDEX \`IDX_9669a09fcc0eb6d2794a658f64\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`name\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`token\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`source\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`promotionPrice\``); + await queryRunner.query(`ALTER TABLE \`site\` ADD \`siteName\` varchar(255) NOT NULL`); + await queryRunner.query(`DROP INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` ON \`site_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_926a14ac4c91f38792831acd2a\` ON \`site_areas_area\``); + await queryRunner.query(`DROP TABLE \`site_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_92707ea81fc19dc707dba24819\` ON \`stock_point_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_07d2db2150151e2ef341d2f1de\` ON \`stock_point_areas_area\``); + await queryRunner.query(`DROP TABLE \`stock_point_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_644ffaf8fbde4db798cb47712f\` ON \`area\``); + await queryRunner.query(`DROP TABLE \`area\``); + await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\` (\`siteName\`)`); + } + +} diff --git a/src/db/seeds/area.seeder.ts b/src/db/seeds/area.seeder.ts new file mode 100644 index 0000000..5aadff9 --- /dev/null +++ b/src/db/seeds/area.seeder.ts @@ -0,0 +1,26 @@ + +import { Seeder, SeederFactoryManager } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Area } from '../../entity/area.entity'; + +export default class AreaSeeder implements Seeder { + public async run( + dataSource: DataSource, + factoryManager: SeederFactoryManager + ): Promise { + const repository = dataSource.getRepository(Area); + + const areas = [ + { name: '加拿大' }, + { name: '澳大利亚' }, + { name: '欧洲' }, + ]; + + for (const area of areas) { + const existing = await repository.findOne({ where: { name: area.name } }); + if (!existing) { + await repository.insert(area); + } + } + } +} diff --git a/src/dto/area.dto.ts b/src/dto/area.dto.ts new file mode 100644 index 0000000..acf0d05 --- /dev/null +++ b/src/dto/area.dto.ts @@ -0,0 +1,32 @@ + +import { ApiProperty } from '@midwayjs/swagger'; +import { Rule, RuleType } from '@midwayjs/validate'; + +// 创建区域的数据传输对象 +export class CreateAreaDTO { + @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) + @Rule(RuleType.string().required()) + name: string; +} + +// 更新区域的数据传输对象 +export class UpdateAreaDTO { + @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) + @Rule(RuleType.string()) + name?: string; +} + +// 查询区域的数据传输对象 +export class QueryAreaDTO { + @ApiProperty({ type: 'number', description: '当前页码', example: 1 }) + @Rule(RuleType.number().min(1).default(1)) + currentPage: number; + + @ApiProperty({ type: 'number', description: '每页数量', example: 10 }) + @Rule(RuleType.number().min(1).max(100).default(10)) + pageSize: number; + + @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) + @Rule(RuleType.string()) + name?: string; +} diff --git a/src/entity/area.entity.ts b/src/entity/area.entity.ts new file mode 100644 index 0000000..51caf17 --- /dev/null +++ b/src/entity/area.entity.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@midwayjs/swagger'; +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('area') +export class Area { + @ApiProperty({ type: 'number' }) + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ type: 'string', description: '区域名称' }) + @Column({ unique: true }) + name: string; + + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '创建时间', + required: true, + }) + @CreateDateColumn() + createdAt: Date; + + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '更新时间', + required: true, + }) + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index a22cf4b..181642f 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -46,6 +46,11 @@ export class Product { @ApiProperty({ description: '价格', example: 99.99 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) price: number; + + // 促销价格 + @ApiProperty({ description: '促销价格', example: 99.99 }) + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + promotionPrice: number; @ManyToMany(() => DictItem, { cascade: true, @@ -53,6 +58,11 @@ export class Product { @JoinTable() attributes: DictItem[]; + // 来源 + @ApiProperty({ description: '来源', example: '1' }) + @Column({ default: 0 }) + source: number; + @ApiProperty({ example: '2022-12-12 11:11:11', description: '创建时间', diff --git a/src/entity/site.entity.ts b/src/entity/site.entity.ts index a4c58e4..87b452d 100644 --- a/src/entity/site.entity.ts +++ b/src/entity/site.entity.ts @@ -1,4 +1,5 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Area } from './area.entity'; @Entity('site') export class Site { @@ -14,8 +15,11 @@ export class Site { @Column({ length: 255, nullable: true }) consumerSecret: string; + @Column({ nullable: true }) + token: string; + @Column({ length: 255, unique: true }) - siteName: string; + name: string; @Column({ length: 32, default: 'woocommerce' }) type: string; // 平台类型:woocommerce | shopyy @@ -25,4 +29,8 @@ export class Site { @Column({ default: false }) isDisabled: boolean; + + @ManyToMany(() => Area) + @JoinTable() + areas: Area[]; } \ No newline at end of file diff --git a/src/entity/stock_point.entity.ts b/src/entity/stock_point.entity.ts index ffb14d6..fd62318 100644 --- a/src/entity/stock_point.entity.ts +++ b/src/entity/stock_point.entity.ts @@ -8,8 +8,11 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, OneToMany, + ManyToMany, + JoinTable, } from 'typeorm'; import { Shipment } from './shipment.entity'; +import { Area } from './area.entity'; @Entity('stock_point') export class StockPoint extends BaseEntity { @@ -72,4 +75,8 @@ export class StockPoint extends BaseEntity { @DeleteDateColumn() deletedAt: Date; // 软删除时间 + + @ManyToMany(() => Area) + @JoinTable() + areas: Area[]; } diff --git a/src/service/area.service.ts b/src/service/area.service.ts new file mode 100644 index 0000000..e9c9120 --- /dev/null +++ b/src/service/area.service.ts @@ -0,0 +1,90 @@ + +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { Area } from '../entity/area.entity'; +import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto'; + +@Provide() +export class AreaService { + @InjectEntityModel(Area) + areaModel: Repository; + + /** + * 创建区域 + * @param params 创建参数 + */ + async createArea(params: CreateAreaDTO) { + // 检查区域名称是否已存在 + const existing = await this.areaModel.findOne({ where: { name: params.name } }); + if (existing) { + throw new Error('区域名称已存在'); + } + const area = new Area(); + area.name = params.name; + return await this.areaModel.save(area); + } + + /** + * 更新区域 + * @param id 区域ID + * @param params 更新参数 + */ + async updateArea(id: number, params: UpdateAreaDTO) { + const area = await this.areaModel.findOneBy({ id }); + if (!area) { + throw new Error('区域不存在'); + } + if (params.name) { + // 检查新的区域名称是否已存在 + const existing = await this.areaModel.findOne({ where: { name: params.name } }); + if (existing && existing.id !== id) { + throw new Error('区域名称已存在'); + } + area.name = params.name; + } + return await this.areaModel.save(area); + } + + /** + * 删除区域 + * @param id 区域ID + */ + async deleteArea(id: number) { + const result = await this.areaModel.delete(id); + return result.affected > 0; + } + + /** + * 获取区域列表(分页) + * @param query 查询参数 + */ + async getAreaList(query: QueryAreaDTO) { + const { currentPage, pageSize, name } = query; + const [list, total] = await this.areaModel.findAndCount({ + where: name ? { name } : {}, + skip: (currentPage - 1) * pageSize, + take: pageSize, + order: { + id: 'DESC', + }, + }); + + return { list, total }; + } + + /** + * 获取所有区域 + */ + async getAllAreas() { + return await this.areaModel.find(); + } + + /** + * 根据ID获取区域详情 + * @param id 区域ID + */ + async getAreaById(id: number) { + return await this.areaModel.findOneBy({ id }); + } +} diff --git a/src/service/order.service.ts b/src/service/order.service.ts index d0872ed..bcd787b 100644 --- a/src/service/order.service.ts +++ b/src/service/order.service.ts @@ -1350,7 +1350,7 @@ export class OrderService { return { ...order, - siteName: site?.siteName, + siteName: site?.name, // Site 实体无邮箱字段,这里返回空字符串保持兼容 email: '', items, diff --git a/src/service/site.service.ts b/src/service/site.service.ts index 0e15a39..0075ccc 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -15,10 +15,10 @@ export class SiteService { // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) for (const siteConfig of sites) { // 按站点名称查询是否已存在记录 - const exist = await this.siteModel.findOne({ where: { siteName: siteConfig.siteName } }); + const exist = await this.siteModel.findOne({ where: { name: siteConfig.siteName } }); // 将 WpSite 字段映射为 Site 实体字段 const payload: Partial = { - siteName: siteConfig.siteName, + name: siteConfig.siteName, apiUrl: (siteConfig as any).wpApiUrl, consumerKey: (siteConfig as any).consumerKey, consumerSecret: (siteConfig as any).consumerSecret, diff --git a/src/service/template.service.ts b/src/service/template.service.ts index de4dff8..aa43771 100644 --- a/src/service/template.service.ts +++ b/src/service/template.service.ts @@ -81,6 +81,16 @@ export class TemplateService { * @returns {Promise} 如果删除成功则返回 true,否则返回 false */ async deleteTemplate(id: number): Promise { + // 首先根据 ID 查找模板 + const template = await this.templateModel.findOneBy({ id }); + // 如果模板不存在,则抛出错误 + if (!template) { + throw new Error(`模板 ID ${id} 不存在`); + } + // 如果模板不可删除,则抛出错误 + if (!template.deletable) { + throw new Error(`模板 ${template.name} 不可删除`); + } // 执行删除操作 const result = await this.templateModel.delete(id); // 如果影响的行数大于 0,则表示删除成功