Compare commits

...

3 Commits

Author SHA1 Message Date
zhuotianyuan 7d1671a513 Merge pull request 'feat: 添加少许特性' (#50) from zksu/API:main into main
Reviewed-on: #50
2026-01-14 11:45:14 +00:00
tikkhun fbbb86ae37 feat: 添加产品图片字段并优化字典导入功能
添加产品图片URL字段到产品相关实体和DTO
重命名字典导入方法并优化导入逻辑
新增站点商品实体和ShopYY商品更新接口
优化Excel处理以支持UTF-8编码
2026-01-14 19:16:30 +08:00
tikkhun 56deb447b3 feat(dto): 新增订单支付状态枚举类型
feat(controller): 重命名产品导入方法为importProductsFromTable

feat(service): 使用xlsx替换csv解析器处理产品导入

refactor(adapter): 完善订单数据结构定义和类型映射

docs(dto): 补充Shopyy订单和产品接口的详细注释
2026-01-13 16:26:30 +08:00
14 changed files with 1320 additions and 303 deletions

View File

@ -21,13 +21,16 @@ import {
CreateReviewDTO, CreateReviewDTO,
CreateVariationDTO, CreateVariationDTO,
UpdateReviewDTO, UpdateReviewDTO,
OrderPaymentStatus,
} from '../dto/site-api.dto'; } from '../dto/site-api.dto';
import { UnifiedPaginationDTO, UnifiedSearchParamsDTO, } from '../dto/api.dto'; import { UnifiedPaginationDTO, UnifiedSearchParamsDTO, } from '../dto/api.dto';
import { import {
ShopyyAllProductQuery, ShopyyAllProductQuery,
ShopyyCustomer, ShopyyCustomer,
ShopyyOrder, ShopyyOrder,
ShopyyOrderCreateParams,
ShopyyOrderQuery, ShopyyOrderQuery,
ShopyyOrderUpdateParams,
ShopyyProduct, ShopyyProduct,
ShopyyProductQuery, ShopyyProductQuery,
ShopyyVariant, ShopyyVariant,
@ -230,8 +233,8 @@ export class ShopyyAdapter implements ISiteAdapter {
// console.log(item) // console.log(item)
if (!item) throw new Error('订单数据不能为空') if (!item) throw new Error('订单数据不能为空')
// 提取账单和送货地址 如果不存在则为空对象 // 提取账单和送货地址 如果不存在则为空对象
const billing = (item).bill_address || {}; const billing = item.billing_address || {};
const shipping = (item as any).shipping_address || {}; const shipping = item.shipping_address || {};
// 构建账单地址对象 // 构建账单地址对象
const billingObj: UnifiedAddressDTO = { const billingObj: UnifiedAddressDTO = {
@ -309,14 +312,14 @@ export class ShopyyAdapter implements ISiteAdapter {
}; };
const lineItems: UnifiedOrderLineItemDTO[] = (item.products || []).map( const lineItems: UnifiedOrderLineItemDTO[] = (item.products || []).map(
(p: any) => ({ (product) => ({
id: p.id, id: product.id,
name: p.product_title || p.name, name: product.product_title || product.name,
product_id: p.product_id, product_id: product.product_id,
quantity: p.quantity, quantity: product.quantity,
total: String(p.price ?? ''), total: String(product.price ?? ''),
sku: p.sku_code || '', sku: product.sku || product.sku_code || '',
price: String(p.price ?? ''), price: String(product.price ?? ''),
}) })
); );
// 货币符号 // 货币符号
@ -339,7 +342,7 @@ export class ShopyyAdapter implements ISiteAdapter {
const status = this.shopyyOrderStatusMap[item.status ?? item.order_status] || OrderStatus.PENDING; const status = this.shopyyOrderStatusMap[item.status ?? item.order_status] || OrderStatus.PENDING;
const finalcial_status = this.shopyyFinancialStatusMap[item.financial_status] const finalcial_status = this.shopyyFinancialStatusMap[item.financial_status]
// 发货状态 // 发货状态
const fulfillment_status = this.shopyyFulfillmentStatusMap[item.fulfillment_status]; const fulfillment_status = this.fulfillmentStatusMap[item.fulfillment_status];
return { return {
id: item.id || item.order_id, id: item.id || item.order_id,
number: item.order_number || item.order_sn, number: item.order_number || item.order_sn,
@ -402,11 +405,11 @@ export class ShopyyAdapter implements ISiteAdapter {
return data return data
} }
mapCreateOrderParams(data: Partial<UnifiedOrderDTO>): any { mapCreateOrderParams(data: Partial<UnifiedOrderDTO>): ShopyyOrderCreateParams {
return data return data
} }
mapUpdateOrderParams(data: Partial<UnifiedOrderDTO>): any { mapUpdateOrderParams(data: Partial<UnifiedOrderDTO>): ShopyyOrderUpdateParams {
// 构建 ShopYY 订单更新参数(仅包含传入的字段) // 构建 ShopYY 订单更新参数(仅包含传入的字段)
const params: any = {}; const params: any = {};
@ -537,8 +540,16 @@ export class ShopyyAdapter implements ISiteAdapter {
} }
async getOrder(where: { id: string | number }): Promise<UnifiedOrderDTO> { async getOrder(where: { id: string | number }): Promise<UnifiedOrderDTO> {
const data = await this.shopyyService.getOrder(this.site.id, String(where.id)); const data = await this.getOrders({
return this.mapPlatformToUnifiedOrder(data); where: {
id: where.id,
},
page: 1,
per_page: 1,
})
return data.items[0] || null
// const data = await this.shopyyService.getOrder(this.site.id, String(where.id));
// return this.mapPlatformToUnifiedOrder(data);
} }
async getOrders( async getOrders(
@ -699,7 +710,7 @@ export class ShopyyAdapter implements ISiteAdapter {
name: item.name || item.title, name: item.name || item.title,
type: String(item.product_type ?? ''), type: String(item.product_type ?? ''),
status: mapProductStatus(item.status), status: mapProductStatus(item.status),
sku: item.variant?.sku || '', sku: item.variant?.sku || item.variant?.sku_code || '',
regular_price: String(item.variant?.price ?? ''), regular_price: String(item.variant?.price ?? ''),
sale_price: String(item.special_price ?? ''), sale_price: String(item.special_price ?? ''),
price: String(item.price ?? ''), price: String(item.price ?? ''),
@ -1101,10 +1112,11 @@ export class ShopyyAdapter implements ISiteAdapter {
// ========== 产品变体映射方法 ========== // ========== 产品变体映射方法 ==========
mapPlatformToUnifiedVariation(variant: ShopyyVariant): UnifiedProductVariationDTO { mapPlatformToUnifiedVariation(variant: ShopyyVariant): UnifiedProductVariationDTO {
// 映射变体 // 映射变体
console.log('ivarianttem', variant)
return { return {
id: variant.id, id: variant.id,
name: variant.sku || '', name: variant.title || '',
sku: variant.sku || '', sku: variant.sku || variant.sku_code || '',
regular_price: String(variant.price ?? ''), regular_price: String(variant.price ?? ''),
sale_price: String(variant.special_price ?? ''), sale_price: String(variant.special_price ?? ''),
price: String(variant.price ?? ''), price: String(variant.price ?? ''),
@ -1302,8 +1314,8 @@ export class ShopyyAdapter implements ISiteAdapter {
[180]: OrderStatus.COMPLETED, // 180 已完成(确认收货) 转为 completed [180]: OrderStatus.COMPLETED, // 180 已完成(确认收货) 转为 completed
[190]: OrderStatus.CANCEL // 190 取消 转为 cancelled [190]: OrderStatus.CANCEL // 190 取消 转为 cancelled
} }
// 物流状态 300 未发货310 部分发货320 已发货330(确认收货)
shopyyFulfillmentStatusMap = { fulfillmentStatusMap = {
// 未发货 // 未发货
'300': OrderFulfillmentStatus.PENDING, '300': OrderFulfillmentStatus.PENDING,
// 部分发货 // 部分发货
@ -1314,4 +1326,23 @@ export class ShopyyAdapter implements ISiteAdapter {
'330': OrderFulfillmentStatus.CANCELLED, '330': OrderFulfillmentStatus.CANCELLED,
// 确认发货 // 确认发货
} }
// 支付状态 200 待支付210 支付中220 部分支付230 已支付240 支付失败250 部分退款260 已退款 290 已取消;
financialStatusMap = {
// 待支付
'200': OrderPaymentStatus.PENDING,
// 支付中
'210': OrderPaymentStatus.PAYING,
// 部分支付
'220': OrderPaymentStatus.PARTIALLY_PAID,
// 已支付
'230': OrderPaymentStatus.PAID,
// 支付失败
'240': OrderPaymentStatus.FAILED,
// 部分退款
'250': OrderPaymentStatus.PARTIALLY_REFUNDED,
// 已退款
'260': OrderPaymentStatus.REFUNDED,
// 已取消
'290': OrderPaymentStatus.CANCELLED,
}
} }

View File

@ -118,8 +118,7 @@ export class MainConfiguration {
}); });
try { try {
this.logger.info('正在检查数据库是否存在...'); this.logger.info(`正在检查数据库是否存在...`+ JSON.stringify(typeormConfig));
// 初始化临时数据源 // 初始化临时数据源
await tempDataSource.initialize(); await tempDataSource.initialize();

View File

@ -30,7 +30,7 @@ export class DictController {
// 从上传的文件列表中获取第一个文件 // 从上传的文件列表中获取第一个文件
const file = files[0]; const file = files[0];
// 调用服务层方法处理XLSX文件 // 调用服务层方法处理XLSX文件
const result = await this.dictService.importDictsFromXLSX(file.data); const result = await this.dictService.importDictsFromTable(file.data);
// 返回导入结果 // 返回导入结果
return result; return result;
} }

View File

@ -117,7 +117,7 @@ export class ProductController {
const file = files?.[0]; const file = files?.[0];
if (!file) return errorResponse('未接收到上传文件'); if (!file) return errorResponse('未接收到上传文件');
const result = await this.productService.importProductsCSV(file); const result = await this.productService.importProductsFromTable(file);
return successResponse(result); return successResponse(result);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);

View File

@ -86,7 +86,10 @@ export class CreateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
promotionPrice?: number; promotionPrice?: number;
// 产品图片URL
@ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false })
@Rule(RuleType.string().optional())
image?: string;
// 商品类型(默认 single; bundle 需手动设置组成) // 商品类型(默认 single; bundle 需手动设置组成)
@ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], default: 'single', required: false }) @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], default: 'single', required: false })
@ -153,7 +156,10 @@ export class UpdateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
promotionPrice?: number; promotionPrice?: number;
// 产品图片URL
@ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false })
@Rule(RuleType.string().optional())
image?: string;
// 属性更新(可选, 支持增量替换指定字典的属性项) // 属性更新(可选, 支持增量替换指定字典的属性项)
@ApiProperty({ description: '属性列表', type: 'array', required: false }) @ApiProperty({ description: '属性列表', type: 'array', required: false })
@ -228,6 +234,10 @@ export class BatchUpdateProductDTO {
@Rule(RuleType.number().optional()) @Rule(RuleType.number().optional())
promotionPrice?: number; promotionPrice?: number;
@ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false })
@Rule(RuleType.string().optional())
image?: string;
@ApiProperty({ description: '属性列表', type: 'array', required: false }) @ApiProperty({ description: '属性列表', type: 'array', required: false })
@Rule(RuleType.array().optional()) @Rule(RuleType.array().optional())
attributes?: AttributeInputDTO[]; attributes?: AttributeInputDTO[];

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,24 @@ export enum OrderFulfillmentStatus {
// 确认发货 // 确认发货
CONFIRMED, CONFIRMED,
} }
export enum OrderPaymentStatus {
// 待支付
PENDING,
// 支付中
PAYING,
// 部分支付
PARTIALLY_PAID,
// 已支付
PAID,
// 支付失败
FAILED,
// 部分退款
PARTIALLY_REFUNDED,
// 已退款
REFUNDED,
// 已取消
CANCELLED,
}
// //
export class UnifiedProductWhere { export class UnifiedProductWhere {
sku?: string; sku?: string;

View File

@ -62,6 +62,11 @@ export class OrderSale {
@Expose() @Expose()
isPackage: boolean; isPackage: boolean;
@ApiProperty({ description: '商品品类', type: 'string',nullable: true})
@Expose()
@Column({ nullable: true })
category?: string;
// TODO 这个其实还是直接保存 product 比较好
@ApiProperty({ description: '品牌', type: 'string',nullable: true}) @ApiProperty({ description: '品牌', type: 'string',nullable: true})
@Expose() @Expose()
@Column({ nullable: true }) @Column({ nullable: true })

View File

@ -55,6 +55,9 @@ export class Product {
@Column({ nullable: true }) @Column({ nullable: true })
description?: string; description?: string;
@ApiProperty({ example: '图片URL', description: '产品图片URL' })
@Column({ nullable: true })
image?: string;
// 商品价格 // 商品价格
@ApiProperty({ description: '价格', example: 99.99 }) @ApiProperty({ description: '价格', example: 99.99 })
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })

View File

@ -0,0 +1,86 @@
import {
Column,
CreateDateColumn,
UpdateDateColumn,
Entity,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ApiProperty } from '@midwayjs/swagger';
import { Site } from './site.entity';
import { Product } from './product.entity';
@Entity('site_product')
export class SiteProduct {
@ApiProperty({
example: '12345',
description: '站点商品ID',
type: 'string',
required: true,
})
@Column({ primary: true })
id: string;
@ApiProperty({ description: '站点ID' })
@Column()
siteId: number;
@ApiProperty({ description: '商品ID' })
@Column({ nullable: true })
productId: number;
@ApiProperty({ description: 'sku'})
@Column()
sku: string;
@ApiProperty({ description: '类型' })
@Column({ length: 16, default: 'single' })
type: string;
@ApiProperty({
description: '产品名称',
type: 'string',
required: true,
})
@Column()
name: string;
@ApiProperty({ description: '产品图片' })
@Column({ default: '' })
image: string;
@ApiProperty({ description: '父商品ID', example: '12345' })
@Column({ nullable: true })
parentId: string;
// 站点关联
@ManyToOne(() => Site, site => site.id)
@JoinColumn({ name: 'siteId' })
site: Site;
// 商品关联
@ManyToOne(() => Product, product => product.id)
@JoinColumn({ name: 'productId' })
product: Product;
// 父商品关联
@ManyToOne(() => SiteProduct, siteProduct => siteProduct.id)
@JoinColumn({ name: 'parentId' })
parent: SiteProduct;
@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;
}

View File

@ -50,7 +50,7 @@ export class DictService {
} }
// 从XLSX文件导入字典 // 从XLSX文件导入字典
async importDictsFromXLSX(bufferOrPath: Buffer | string) { async importDictsFromTable(bufferOrPath: Buffer | string) {
// 判断传入的是 Buffer 还是文件路径字符串 // 判断传入的是 Buffer 还是文件路径字符串
let buffer: Buffer; let buffer: Buffer;
if (typeof bufferOrPath === 'string') { if (typeof bufferOrPath === 'string') {
@ -216,10 +216,10 @@ export class DictService {
// 如果提供了 dictId,则只返回该字典下的项 // 如果提供了 dictId,则只返回该字典下的项
if (params.dictId) { if (params.dictId) {
return this.dictItemModel.find({ where }); return this.dictItemModel.find({ where, relations: ['dict'] });
} }
// 否则,返回所有字典项 // 否则,返回所有字典项
return this.dictItemModel.find(); return this.dictItemModel.find({ relations: ['dict'] });
} }
// 创建新字典项 // 创建新字典项

View File

@ -1,6 +1,6 @@
import { Inject, Provide } from '@midwayjs/core'; import { Inject, Provide } from '@midwayjs/core';
import { parse } from 'csv-parse';
import * as fs from 'fs'; import * as fs from 'fs';
import * as xlsx from 'xlsx';
import { In, Like, Not, Repository } from 'typeorm'; 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';
@ -774,7 +774,7 @@ export class ProductService {
} }
} else { } else {
// 简单字段,直接批量更新以提高性能 // 简单字段,直接批量更新以提高性能
// UpdateProductDTO 里的简单字段: name, nameCn, description, price, promotionPrice, siteSkus // UpdateProductDTO 里的简单字段: name, nameCn, description, shortDescription, price, promotionPrice, image, siteSkus
const simpleUpdate: any = {}; const simpleUpdate: any = {};
if (updateData.name !== undefined) simpleUpdate.name = updateData.name; if (updateData.name !== undefined) simpleUpdate.name = updateData.name;
@ -783,6 +783,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.image !== undefined) simpleUpdate.image = updateData.image;
if (updateData.siteSkus !== undefined) simpleUpdate.siteSkus = updateData.siteSkus; if (updateData.siteSkus !== undefined) simpleUpdate.siteSkus = updateData.siteSkus;
if (Object.keys(simpleUpdate).length > 0) { if (Object.keys(simpleUpdate).length > 0) {
@ -1663,15 +1664,20 @@ export class ProductService {
rows.push(rowData.join(',')); rows.push(rowData.join(','));
} }
return rows.join('\n'); // 添加UTF-8 BOM以确保中文在Excel中正确显示
} return '\ufeff' + rows.join('\n');
// 从 CSV 导入产品;存在则更新,不存在则创建 }
async importProductsCSV(file: any): Promise<BatchOperationResult> { async getRecordsFromTable(file: any) {
// 解析文件(使用 xlsx 包自动识别文件类型并解析)
try {
let buffer: Buffer; let buffer: Buffer;
// 处理文件输入,获取 buffer
if (Buffer.isBuffer(file)) { if (Buffer.isBuffer(file)) {
buffer = file; buffer = file;
} else if (file?.data) { }
else if (file?.data) {
if (typeof file.data === 'string') { if (typeof file.data === 'string') {
buffer = fs.readFileSync(file.data); buffer = fs.readFileSync(file.data);
} else { } else {
@ -1681,35 +1687,31 @@ export class ProductService {
throw new Error('无效的文件输入'); throw new Error('无效的文件输入');
} }
// 解析 CSV(使用 csv-parse/sync 按表头解析) let records: any[] = []
let records: any[] = []; // xlsx 包会自动根据文件内容识别文件类型(CSV 或 XLSX)
try { // 添加codepage: 65001以确保正确处理UTF-8编码的中文
records = await new Promise((resolve, reject) => { const workbook = xlsx.read(buffer, { type: 'buffer', codepage: 65001 });
parse(buffer, { // 获取第一个工作表
columns: true, const worksheet = workbook.Sheets[workbook.SheetNames[0]];
skip_empty_lines: true, // 将工作表转换为 JSON 数组
trim: true, records = xlsx.utils.sheet_to_json(worksheet);
bom: true,
}, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
})
console.log('Parsed records count:', records.length); console.log('Parsed records count:', records.length);
if (records.length > 0) { if (records.length > 0) {
console.log('First record keys:', Object.keys(records[0])); console.log('First record keys:', Object.keys(records[0]));
} }
return records;
} catch (e: any) { } catch (e: any) {
throw new Error(`CSV 解析失败:${e?.message || e}`) throw new Error(`文件解析失败:${e?.message || e}`);
}
} }
// 从 CSV 导入产品;存在则更新,不存在则创建
async importProductsFromTable(file: any): Promise<BatchOperationResult> {
let created = 0; let created = 0;
let updated = 0; let updated = 0;
const errors: BatchErrorItem[] = []; const errors: BatchErrorItem[] = [];
const records = await this.getRecordsFromTable(file);
// 逐条处理记录 // 逐条处理记录
for (const rec of records) { for (const rec of records) {
try { try {

View File

@ -8,7 +8,7 @@ import * as FormData from 'form-data';
import { SiteService } from './site.service'; import { SiteService } from './site.service';
import { Site } from '../entity/site.entity'; import { Site } from '../entity/site.entity';
import { UnifiedReviewDTO } from '../dto/site-api.dto'; import { UnifiedReviewDTO } from '../dto/site-api.dto';
import { ShopyyReview } from '../dto/shopyy.dto'; import { ShopyyGetOneOrderResult, ShopyyReview } from '../dto/shopyy.dto';
import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto'; import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto';
import { UnifiedSearchParamsDTO } from '../dto/api.dto'; import { UnifiedSearchParamsDTO } from '../dto/api.dto';
/** /**
@ -366,7 +366,7 @@ export class ShopyyService {
* @param orderId ID * @param orderId ID
* @returns * @returns
*/ */
async getOrder(siteId: string, orderId: string): Promise<any> { async getOrder(siteId: string, orderId: string): Promise<ShopyyGetOneOrderResult> {
const site = await this.siteService.get(Number(siteId)); const site = await this.siteService.get(Number(siteId));
// ShopYY API: GET /orders/{id} // ShopYY API: GET /orders/{id}