forked from yoone/API
1
0
Fork 0

fix(product): 修复产品创建和更新时的属性校验问题

调整产品DTO中attributes字段的校验规则,使其在type为'single'时必填,为'bundle'时可选
移除不必要的siteSkus处理逻辑,简化产品创建和更新流程
This commit is contained in:
tikkhun 2026-01-04 20:05:37 +08:00
parent 58ae594d5e
commit 338625c3d2
5 changed files with 87 additions and 44 deletions

View File

@ -64,9 +64,17 @@ export class CreateProductDTO {
siteSkus?: string[]; siteSkus?: string[];
// 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等) // 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
@ApiProperty({ description: '属性列表', type: 'array' }) // 当 type 为 'single' 时必填,当 type 为 'bundle' 时可选
@Rule(RuleType.array().required()) @ApiProperty({ description: '属性列表', type: 'array', required: false })
attributes: AttributeInputDTO[]; @Rule(
RuleType.array()
.when('type', {
is: 'single',
then: RuleType.array().required(),
otherwise: RuleType.array().optional()
})
)
attributes?: AttributeInputDTO[];
// 商品价格 // 商品价格
@ApiProperty({ description: '价格', example: 99.99, required: false }) @ApiProperty({ description: '价格', example: 99.99, required: false })

View File

@ -36,22 +36,39 @@ export class SiteConfig {
} }
export class CreateSiteDTO { export class CreateSiteDTO {
@ApiProperty({ description: '站点 API URL', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
apiUrl?: string; apiUrl?: string;
@ApiProperty({ description: '站点网站 URL', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
websiteUrl?: string; websiteUrl?: string;
@ApiProperty({ description: '站点 REST Key', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerKey?: string; consumerKey?: string;
@ApiProperty({ description: '站点 REST 秘钥', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerSecret?: string; consumerSecret?: string;
@ApiProperty({ description: '访问令牌', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
token?: string; token?: string;
@ApiProperty({ description: '站点名称' })
@Rule(RuleType.string()) @Rule(RuleType.string())
name: string; name: string;
@ApiProperty({ description: '站点描述', required: false })
@Rule(RuleType.string().allow('').optional()) @Rule(RuleType.string().allow('').optional())
description?: string; description?: string;
@ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'], required: false })
@Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional())
type?: string; type?: string;
@ApiProperty({ description: 'SKU 前缀', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
skuPrefix?: string; skuPrefix?: string;
@ -67,22 +84,39 @@ export class CreateSiteDTO {
} }
export class UpdateSiteDTO { export class UpdateSiteDTO {
@ApiProperty({ description: '站点 API URL', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
apiUrl?: string; apiUrl?: string;
@ApiProperty({ description: '站点 REST Key', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerKey?: string; consumerKey?: string;
@ApiProperty({ description: '站点 REST 秘钥', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerSecret?: string; consumerSecret?: string;
@ApiProperty({ description: '访问令牌', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
token?: string; token?: string;
@ApiProperty({ description: '站点名称', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
name?: string; name?: string;
@ApiProperty({ description: '站点描述', required: false })
@Rule(RuleType.string().allow('').optional()) @Rule(RuleType.string().allow('').optional())
description?: string; description?: string;
@ApiProperty({ description: '是否禁用', required: false })
@Rule(RuleType.boolean().optional()) @Rule(RuleType.boolean().optional())
isDisabled?: boolean; isDisabled?: boolean;
@ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'], required: false })
@Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional())
type?: string; type?: string;
@ApiProperty({ description: 'SKU 前缀', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
skuPrefix?: string; skuPrefix?: string;
@ -95,25 +129,36 @@ export class UpdateSiteDTO {
@ApiProperty({ description: '绑定仓库ID列表' }) @ApiProperty({ description: '绑定仓库ID列表' })
@Rule(RuleType.array().items(RuleType.number()).optional()) @Rule(RuleType.array().items(RuleType.number()).optional())
stockPointIds?: number[]; stockPointIds?: number[];
@ApiProperty({ description: '站点网站URL' })
@ApiProperty({ description: '站点网站URL', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
websiteUrl?: string; websiteUrl?: string;
} }
export class QuerySiteDTO { export class QuerySiteDTO {
@ApiProperty({ description: '当前页码', required: false })
@Rule(RuleType.number().optional()) @Rule(RuleType.number().optional())
current?: number; current?: number;
@ApiProperty({ description: '每页数量', required: false })
@Rule(RuleType.number().optional()) @Rule(RuleType.number().optional())
pageSize?: number; pageSize?: number;
@ApiProperty({ description: '搜索关键词', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
keyword?: string; keyword?: string;
@ApiProperty({ description: '是否禁用', required: false })
@Rule(RuleType.boolean().optional()) @Rule(RuleType.boolean().optional())
isDisabled?: boolean; isDisabled?: boolean;
@ApiProperty({ description: '站点ID列表逗号分隔', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
ids?: string; ids?: string;
} }
export class DisableSiteDTO { export class DisableSiteDTO {
@ApiProperty({ description: '是否禁用' })
@Rule(RuleType.boolean()) @Rule(RuleType.boolean())
disabled: boolean; disabled: boolean;
} }

View File

@ -102,7 +102,7 @@ export class OrderService {
async syncOrders(siteId: number, params: Record<string, any> = {}): Promise<SyncOperationResult> { async syncOrders(siteId: number, params: Record<string, any> = {}): Promise<SyncOperationResult> {
// 调用 WooCommerce API 获取订单 // 调用 WooCommerce API 获取订单
const result = await (await this.siteApiService.getAdapter(siteId)).getAllOrders(params); const result = await (await this.siteApiService.getAdapter(siteId)).getAllOrders(params);
const syncResult: SyncOperationResult = { const syncResult: SyncOperationResult = {
total: result.length, total: result.length,
@ -202,7 +202,7 @@ export class OrderService {
// 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换 // 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换
async autoUpdateOrderStatus(siteId: number, order: any) { async autoUpdateOrderStatus(siteId: number, order: any) {
console.log('更新订单状态', order) console.log('更新订单状态', order.status, '=>', this.orderAutoNextStatusMap[order.status])
// 其他状态保持不变 // 其他状态保持不变
const originStatus = order.status; const originStatus = order.status;
// 如果有值就赋值 // 如果有值就赋值

View File

@ -5,7 +5,6 @@ import { In, Like, Not, Repository } from 'typeorm';
import { Product } from '../entity/product.entity'; import { Product } from '../entity/product.entity';
import { PaginationParams } from '../interface'; import { PaginationParams } from '../interface';
import { paginate } from '../utils/paginate.util'; import { paginate } from '../utils/paginate.util';
import { Context } from '@midwayjs/koa'; import { Context } from '@midwayjs/koa';
import { InjectEntityModel } from '@midwayjs/typeorm'; import { InjectEntityModel } from '@midwayjs/typeorm';
import { import {
@ -538,15 +537,14 @@ export class ProductService {
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> { async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const { attributes, sku, categoryId } = createProductDTO; const { attributes, sku, categoryId, type } = createProductDTO;
// 条件判断(校验属性输入) // 条件判断(校验属性输入)
if (!Array.isArray(attributes) || attributes.length === 0) { // 当产品类型为 'bundle' 时attributes 可以为空
// 如果提供了 categoryId 但没有 attributes,初始化为空数组 // 当产品类型为 'single' 时attributes 必须提供且不能为空
if (!attributes && categoryId) { if (type === 'single') {
// 继续执行,下面会处理 categoryId if (!Array.isArray(attributes) || attributes.length === 0) {
} else { throw new Error('单品类型的属性列表不能为空');
throw new Error('属性列表不能为空');
} }
} }
@ -607,23 +605,26 @@ export class ProductService {
} }
// 检查完全相同属性组合是否已存在(避免重复) // 检查完全相同属性组合是否已存在(避免重复)
const qb = this.productModel.createQueryBuilder('product'); // 仅当产品类型为 'single' 且有属性时才检查重复
resolvedAttributes.forEach((attr, index) => { if (type === 'single' && resolvedAttributes.length > 0) {
qb.innerJoin( const qb = this.productModel.createQueryBuilder('product');
'product.attributes', resolvedAttributes.forEach((attr, index) => {
`attr${index}`, qb.innerJoin(
`attr${index}.id = :attrId${index}`, 'product.attributes',
{ [`attrId${index}`]: attr.id } `attr${index}`,
); `attr${index}.id = :attrId${index}`,
}); { [`attrId${index}`]: attr.id }
const isExist = await qb.getOne(); );
if (isExist) throw new Error('相同产品属性的产品已存在'); });
const isExist = await qb.getOne();
if (isExist) throw new Error('相同产品属性的产品已存在');
}
// 创建新产品实例(绑定属性与基础字段) // 创建新产品实例(绑定属性与基础字段)
const product = new Product(); const product = new Product();
// 使用 merge 填充基础字段,排除特殊处理字段 // 使用 merge 填充基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = createProductDTO; const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = createProductDTO;
this.productModel.merge(product, simpleFields); this.productModel.merge(product, simpleFields);
product.attributes = resolvedAttributes; product.attributes = resolvedAttributes;
@ -642,13 +643,6 @@ export class ProductService {
const savedProduct = await this.productModel.save(product); const savedProduct = await this.productModel.save(product);
// 设置站点 SKU 列表(确保始终设置,即使为空数组)
if (createProductDTO.siteSkus !== undefined) {
product.siteSkus = createProductDTO.siteSkus;
// 重新保存产品以更新 siteSkus
await this.productModel.save(product);
}
// 保存组件信息 // 保存组件信息
if (createProductDTO.components && createProductDTO.components.length > 0) { if (createProductDTO.components && createProductDTO.components.length > 0) {
await this.setProductComponents(savedProduct.id, createProductDTO.components); await this.setProductComponents(savedProduct.id, createProductDTO.components);
@ -670,7 +664,7 @@ export class ProductService {
} }
// 使用 merge 更新基础字段,排除特殊处理字段 // 使用 merge 更新基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = updateProductDTO; const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = updateProductDTO;
this.productModel.merge(product, simpleFields); this.productModel.merge(product, simpleFields);
// 处理分类更新 // 处理分类更新
@ -685,11 +679,6 @@ export class ProductService {
} }
} }
// 处理站点 SKU 更新
if (updateProductDTO.siteSkus !== undefined) {
product.siteSkus = updateProductDTO.siteSkus;
}
// 处理 SKU 更新 // 处理 SKU 更新
if (updateProductDTO.sku !== undefined) { if (updateProductDTO.sku !== undefined) {
// 校验 SKU 唯一性(如变更) // 校验 SKU 唯一性(如变更)
@ -786,7 +775,7 @@ export class ProductService {
} }
} else { } else {
// 简单字段,直接批量更新以提高性能 // 简单字段,直接批量更新以提高性能
// UpdateProductDTO 里的简单字段: name, nameCn, description, price, promotionPrice // UpdateProductDTO 里的简单字段: name, nameCn, description, price, promotionPrice, siteSkus
const simpleUpdate: any = {}; const simpleUpdate: any = {};
if (updateData.name !== undefined) simpleUpdate.name = updateData.name; if (updateData.name !== undefined) simpleUpdate.name = updateData.name;
@ -795,6 +784,7 @@ export class ProductService {
if (updateData.shortDescription !== undefined) simpleUpdate.shortDescription = updateData.shortDescription; if (updateData.shortDescription !== undefined) simpleUpdate.shortDescription = updateData.shortDescription;
if (updateData.price !== undefined) simpleUpdate.price = updateData.price; if (updateData.price !== undefined) simpleUpdate.price = updateData.price;
if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice; if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice;
if (updateData.siteSkus !== undefined) simpleUpdate.siteSkus = updateData.siteSkus;
if (Object.keys(simpleUpdate).length > 0) { if (Object.keys(simpleUpdate).length > 0) {
await this.productModel.update({ id: In(ids) }, simpleUpdate); await this.productModel.update({ id: In(ids) }, simpleUpdate);
@ -1588,7 +1578,7 @@ export class ProductService {
async exportProductsCSV(): Promise<string> { async exportProductsCSV(): Promise<string> {
// 查询所有产品及其属性(包含字典关系)和组成 // 查询所有产品及其属性(包含字典关系)和组成
const products = await this.productModel.find({ const products = await this.productModel.find({
relations: ['attributes', 'attributes.dict', 'components', 'siteSkus'], relations: ['attributes', 'attributes.dict', 'components'],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });

View File

@ -21,9 +21,9 @@ export class SiteService {
async create(data: CreateSiteDTO) { async create(data: CreateSiteDTO) {
// 从 DTO 中分离出区域代码和其他站点数据 // 从 DTO 中分离出区域代码和其他站点数据
const { areas: areaCodes, stockPointIds, websiteUrl, ...restData } = data; const { areas: areaCodes, stockPointIds, ...restData } = data;
const newSite = new Site(); const newSite = new Site();
Object.assign(newSite, restData, { websiteUrl }); Object.assign(newSite, restData);
// 如果传入了区域代码,则查询并关联 Area 实体 // 如果传入了区域代码,则查询并关联 Area 实体
if (areaCodes && areaCodes.length > 0) { if (areaCodes && areaCodes.length > 0) {
@ -163,7 +163,7 @@ export class SiteService {
const data = includeSecret const data = includeSecret
? items ? items
: items.map((item: any) => { : items.map((item: any) => {
const { consumerKey, consumerSecret, ...rest } = item; const { consumerKey, consumerSecret, token, ...rest } = item;
return rest; return rest;
}); });
return { items: data, total, current, pageSize }; return { items: data, total, current, pageSize };