forked from yoone/API
1
0
Fork 0

feat(customer): 实现客户数据同步功能并增强客户管理

重构客户服务层,添加客户数据同步功能
扩展客户实体字段以支持完整客户信息存储
优化客户列表查询性能并添加统计功能
移除废弃的WpSite相关代码和配置
This commit is contained in:
tikkhun 2025-12-23 19:33:03 +08:00 committed by 黄珑
parent a02758a926
commit 8e7ec2372d
20 changed files with 855 additions and 111 deletions

View File

@ -752,7 +752,6 @@ export class WooCommerceAdapter implements ISiteAdapter {
raw: item,
};
}
async getCustomers(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedCustomerDTO>> {
const requestParams = this.mapCustomerSearchParams(params);
const { items, total, totalPages, page, per_page } = await this.wpService.fetchResourcePaged<any>(
@ -794,3 +793,4 @@ export class WooCommerceAdapter implements ISiteAdapter {
return true;
}
}

View File

@ -116,17 +116,6 @@ export default {
// secret: 'YOONE2024!@abc',
// expiresIn: '7d',
// },
// wpSite: [
// {
// id: '2',
// wpApiUrl: 'http://localhost:10004',
// consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652',
// consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38',
// name: 'Local',
// email: 'tom@yoonevape.com',
// emailPswd: '',
// },
// ],
swagger: {
auth: {
name: 'authorization',

View File

@ -16,8 +16,10 @@ export default {
dataSource: {
default: {
host: 'localhost',
port: "23306",
username: 'root',
password: '12345678',
database: 'inventory',
},
},
},
@ -25,7 +27,7 @@ export default {
origin: '*', // 允许所有来源跨域请求
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // 允许的 HTTP 方法
allowHeaders: ['Content-Type', 'Authorization'], // 允许的自定义请求头
credentials: true, // 允许携带凭据cookies等
credentials: true, // 允许携带凭据(cookies等)
},
jwt: {
secret: 'YOONE2024!@abc',
@ -33,34 +35,38 @@ export default {
},
wpSite: [
{
id: '-1',
siteName: 'Admin',
email: '2469687281@qq.com',
},
{
id: '2',
wpApiUrl: 'http://t2-shop.local/',
consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742',
consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c',
siteName: 'Local',
email: '2469687281@qq.com',
emailPswd: 'lulin91.',
},
{
id: '3',
wpApiUrl: 'http://t1-shop.local/',
consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742',
consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c',
siteName: 'Local-test-2',
id: '200',
wpApiUrl: "http://simple.local",
consumerKey: 'ck_11b446d0dfd221853830b782049cf9a17553f886',
consumerSecret: 'cs_2b06729269f659dcef675b8cdff542bf3c1da7e8',
name: 'LocalSimple',
email: '2469687281@qq.com',
emailPswd: 'lulin91.',
},
// {
// id: '2',
// wpApiUrl: 'http://t2-shop.local/',
// consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742',
// consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c',
// name: 'Local',
// email: '2469687281@qq.com',
// emailPswd: 'lulin91.',
// },
// {
// id: '3',
// wpApiUrl: 'http://t1-shop.local/',
// consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742',
// consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c',
// name: 'Local-test-2',
// email: '2469687281@qq.com',
// emailPswd: 'lulin91.',
// },
// {
// id: '2',
// wpApiUrl: 'http://localhost:10004',
// consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652',
// consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38',
// siteName: 'Local',
// name: 'Local',
// email: 'tom@yoonevape.com',
// emailPswd: 'lulin91.',
// },

View File

@ -3,6 +3,7 @@ import { successResponse, errorResponse } from '../utils/response.util';
import { CustomerService } from '../service/customer.service';
import { QueryCustomerListDTO, CustomerTagDTO } from '../dto/customer.dto';
import { ApiOkResponse } from '@midwayjs/swagger';
import { UnifiedSearchParamsDTO } from '../dto/site-api.dto';
@Controller('/customer')
export class CustomerController {
@ -13,7 +14,18 @@ export class CustomerController {
@Get('/getcustomerlist')
async getCustomerList(@Query() query: QueryCustomerListDTO) {
try {
const result = await this.customerService.getCustomerList(query as any);
const result = await this.customerService.getCustomerList(query)
return successResponse(result);
} catch (error) {
return errorResponse(error.message);
}
}
@ApiOkResponse({ type: Object })
@Get('/getcustomerstatisticlist')
async getCustomerStatisticList(@Query() query: QueryCustomerListDTO) {
try {
const result = await this.customerService.getCustomerStatisticList(query as any);
return successResponse(result);
} catch (error) {
return errorResponse(error.message);
@ -63,4 +75,24 @@ export class CustomerController {
return errorResponse(error.message);
}
}
}
/**
*
*
* service层controller只负责参数传递和响应
*/
@ApiOkResponse({ type: Object })
@Post('/sync')
async syncCustomers(@Body() body: { siteId: number; params?: UnifiedSearchParamsDTO }) {
try {
const { siteId, params = {} } = body;
// 调用service层的同步方法所有业务逻辑都在service中处理
const syncResult = await this.customerService.syncCustomersFromSite(siteId, params);
return successResponse(syncResult);
} catch (error) {
return errorResponse(error.message);
}
}
}

View File

@ -23,6 +23,7 @@ import {
CancelShipOrderDTO,
BatchShipOrdersDTO,
} from '../dto/site-api.dto';
import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto';
import { SiteApiService } from '../service/site-api.service';
import { errorResponse, successResponse } from '../utils/response.util';
import { ILogger } from '@midwayjs/core';
@ -533,10 +534,10 @@ export class SiteApiController {
}
@Post('/:siteId/products/batch')
@ApiOkResponse({ type: Object })
@ApiOkResponse({ type: BatchOperationResultDTO })
async batchProducts(
@Param('siteId') siteId: number,
@Body() body: { create?: any[]; update?: any[]; delete?: Array<string | number> }
@Body() body: BatchOperationDTO
) {
this.logger.info(`[Site API] 批量处理产品开始, siteId: ${siteId}`);
try {
@ -549,14 +550,18 @@ export class SiteApiController {
const created: any[] = [];
const updated: any[] = [];
const deleted: Array<string | number> = [];
const failed: any[] = [];
const errors: Array<{identifier: string, error: string}> = [];
if (body.create?.length) {
for (const item of body.create) {
try {
const data = await adapter.createProduct(item);
created.push(data);
} catch (e) {
failed.push({ action: 'create', item, error: (e as any).message });
errors.push({
identifier: String(item.id || item.sku || 'unknown'),
error: (e as any).message
});
}
}
}
@ -567,7 +572,10 @@ export class SiteApiController {
const data = await adapter.updateProduct(id, item);
updated.push(data);
} catch (e) {
failed.push({ action: 'update', item, error: (e as any).message });
errors.push({
identifier: String(item.id || 'unknown'),
error: (e as any).message
});
}
}
}
@ -576,14 +584,28 @@ export class SiteApiController {
try {
const ok = await adapter.deleteProduct(id);
if (ok) deleted.push(id);
else failed.push({ action: 'delete', id, error: 'delete failed' });
else errors.push({
identifier: String(id),
error: 'delete failed'
});
} catch (e) {
failed.push({ action: 'delete', id, error: (e as any).message });
errors.push({
identifier: String(id),
error: (e as any).message
});
}
}
}
this.logger.info(`[Site API] 批量处理产品完成, siteId: ${siteId}`);
return successResponse({ created, updated, deleted, failed });
return successResponse({
total: (body.create?.length || 0) + (body.update?.length || 0) + (body.delete?.length || 0),
processed: created.length + updated.length + deleted.length,
created: created.length,
updated: updated.length,
deleted: deleted.length,
errors: errors
});
} catch (error) {
this.logger.error(`[Site API] 批量处理产品失败, siteId: ${siteId}, 错误信息: ${error.message}`);
return errorResponse(error.message);
@ -789,10 +811,10 @@ export class SiteApiController {
}
@Post('/:siteId/orders/batch')
@ApiOkResponse({ type: Object })
@ApiOkResponse({ type: BatchOperationResultDTO })
async batchOrders(
@Param('siteId') siteId: number,
@Body() body: { create?: any[]; update?: any[]; delete?: Array<string | number> }
@Body() body: BatchOperationDTO
) {
this.logger.info(`[Site API] 批量处理订单开始, siteId: ${siteId}`);
try {
@ -800,14 +822,18 @@ export class SiteApiController {
const created: any[] = [];
const updated: any[] = [];
const deleted: Array<string | number> = [];
const failed: any[] = [];
const errors: Array<{identifier: string, error: string}> = [];
if (body.create?.length) {
for (const item of body.create) {
try {
const data = await adapter.createOrder(item);
created.push(data);
} catch (e) {
failed.push({ action: 'create', item, error: (e as any).message });
errors.push({
identifier: String(item.id || item.order_number || 'unknown'),
error: (e as any).message
});
}
}
}
@ -817,9 +843,15 @@ export class SiteApiController {
const id = item.id;
const ok = await adapter.updateOrder(id, item);
if (ok) updated.push(item);
else failed.push({ action: 'update', item, error: 'update failed' });
else errors.push({
identifier: String(item.id || 'unknown'),
error: 'update failed'
});
} catch (e) {
failed.push({ action: 'update', item, error: (e as any).message });
errors.push({
identifier: String(item.id || 'unknown'),
error: (e as any).message
});
}
}
}
@ -828,14 +860,28 @@ export class SiteApiController {
try {
const ok = await adapter.deleteOrder(id);
if (ok) deleted.push(id);
else failed.push({ action: 'delete', id, error: 'delete failed' });
else errors.push({
identifier: String(id),
error: 'delete failed'
});
} catch (e) {
failed.push({ action: 'delete', id, error: (e as any).message });
errors.push({
identifier: String(id),
error: (e as any).message
});
}
}
}
this.logger.info(`[Site API] 批量处理订单完成, siteId: ${siteId}`);
return successResponse({ created, updated, deleted, failed });
return successResponse({
total: (body.create?.length || 0) + (body.update?.length || 0) + (body.delete?.length || 0),
processed: created.length + updated.length + deleted.length,
created: created.length,
updated: updated.length,
deleted: deleted.length,
errors: errors
});
} catch (error) {
this.logger.error(`[Site API] 批量处理订单失败, siteId: ${siteId}, 错误信息: ${error.message}`);
return errorResponse(error.message);

View File

@ -1,6 +1,6 @@
import { Body, Controller, Get, Inject, Param, Put, Post, Query } from '@midwayjs/core';
import { ApiOkResponse } from '@midwayjs/swagger';
import { WpSitesResponse } from '../dto/reponse.dto';
import { SitesResponse } from '../dto/reponse.dto';
import { errorResponse, successResponse } from '../utils/response.util';
import { SiteService } from '../service/site.service';
import { CreateSiteDTO, DisableSiteDTO, QuerySiteDTO, UpdateSiteDTO } from '../dto/site.dto';
@ -10,7 +10,7 @@ export class SiteController {
@Inject()
siteService: SiteService;
@ApiOkResponse({ description: '关联网站', type: WpSitesResponse })
@ApiOkResponse({ description: '关联网站', type: SitesResponse })
@Get('/all')
async all() {
try {

View File

@ -1,4 +1,4 @@
import { HttpStatus, Inject } from '@midwayjs/core';
import { HttpStatus, ILogger, Inject, Logger } from '@midwayjs/core';
import {
Controller,
Post,
@ -25,6 +25,9 @@ export class WebhookController {
@Inject()
ctx: Context;
@Logger()
logger: ILogger;
@Inject()
private readonly siteService: SiteService;
@ -48,7 +51,7 @@ export class WebhookController {
// 从数据库获取站点配置
const site = await this.siteService.get(siteId, true);
if (!site || !source.includes(site.apiUrl)) {
if (!site || !source?.includes(site.apiUrl)) {
console.log('domain not match');
return {
code: HttpStatus.BAD_REQUEST,

210
src/dto/batch.dto.ts Normal file
View File

@ -0,0 +1,210 @@
import { ApiProperty } from '@midwayjs/swagger';
import { Rule, RuleType } from '@midwayjs/validate';
/**
*
*/
export interface BatchErrorItem {
// 错误项标识可以是ID、邮箱等
identifier: string;
// 错误信息
error: string;
}
/**
*
*/
export interface BatchOperationResult {
// 总处理数量
total: number;
// 成功处理数量
processed: number;
// 创建数量
created?: number;
// 更新数量
updated?: number;
// 删除数量
deleted?: number;
// 跳过的数量(如数据已存在或无需处理)
skipped?: number;
// 错误列表
errors: BatchErrorItem[];
}
/**
*
*/
export interface SyncOperationResult extends BatchOperationResult {
// 同步成功数量
synced: number;
}
/**
* DTO
*/
export class BatchErrorItemDTO {
@ApiProperty({ description: '错误项标识如ID、邮箱等', type: String })
@Rule(RuleType.string().required())
identifier: string;
@ApiProperty({ description: '错误信息', type: String })
@Rule(RuleType.string().required())
error: string;
}
/**
* DTO
*/
export class BatchOperationResultDTO {
@ApiProperty({ description: '总处理数量', type: Number })
total: number;
@ApiProperty({ description: '成功处理数量', type: Number })
processed: number;
@ApiProperty({ description: '创建数量', type: Number, required: false })
created?: number;
@ApiProperty({ description: '更新数量', type: Number, required: false })
updated?: number;
@ApiProperty({ description: '删除数量', type: Number, required: false })
deleted?: number;
@ApiProperty({ description: '跳过的数量', type: Number, required: false })
skipped?: number;
@ApiProperty({ description: '错误列表', type: [BatchErrorItemDTO] })
errors: BatchErrorItemDTO[];
}
/**
* DTO
*/
export class SyncOperationResultDTO extends BatchOperationResultDTO {
@ApiProperty({ description: '同步成功数量', type: Number })
synced: number;
}
/**
* DTO
*/
export class BatchCreateDTO<T = any> {
@ApiProperty({ description: '要创建的数据列表', type: Array })
@Rule(RuleType.array().required())
items: T[];
}
/**
* DTO
*/
export class BatchUpdateDTO<T = any> {
@ApiProperty({ description: '要更新的数据列表', type: Array })
@Rule(RuleType.array().required())
items: T[];
}
/**
* DTO
*/
export class BatchDeleteDTO {
@ApiProperty({ description: '要删除的ID列表', type: [String, Number] })
@Rule(RuleType.array().items(RuleType.alternatives().try(RuleType.string(), RuleType.number())).required())
ids: Array<string | number>;
}
/**
* DTO
*/
export class BatchOperationDTO<T = any> {
@ApiProperty({ description: '要创建的数据列表', type: Array, required: false })
@Rule(RuleType.array().optional())
create?: T[];
@ApiProperty({ description: '要更新的数据列表', type: Array, required: false })
@Rule(RuleType.array().optional())
update?: T[];
@ApiProperty({ description: '要删除的ID列表', type: [String, Number], required: false })
@Rule(RuleType.array().items(RuleType.alternatives().try(RuleType.string(), RuleType.number())).optional())
delete?: Array<string | number>;
}
/**
* DTO
*/
export class PaginatedBatchOperationDTO<T = any> {
@ApiProperty({ description: '页码', type: Number, required: false, default: 1 })
@Rule(RuleType.number().integer().min(1).optional())
page?: number = 1;
@ApiProperty({ description: '每页数量', type: Number, required: false, default: 100 })
@Rule(RuleType.number().integer().min(1).max(1000).optional())
pageSize?: number = 100;
@ApiProperty({ description: '要创建的数据列表', type: Array, required: false })
@Rule(RuleType.array().optional())
create?: T[];
@ApiProperty({ description: '要更新的数据列表', type: Array, required: false })
@Rule(RuleType.array().optional())
update?: T[];
@ApiProperty({ description: '要删除的ID列表', type: [String, Number], required: false })
@Rule(RuleType.array().items(RuleType.alternatives().try(RuleType.string(), RuleType.number())).optional())
delete?: Array<string | number>;
}
/**
* DTO
*/
export class SyncParamsDTO {
@ApiProperty({ description: '页码', type: Number, required: false, default: 1 })
@Rule(RuleType.number().integer().min(1).optional())
page?: number = 1;
@ApiProperty({ description: '每页数量', type: Number, required: false, default: 100 })
@Rule(RuleType.number().integer().min(1).max(1000).optional())
pageSize?: number = 100;
@ApiProperty({ description: '开始时间', type: String, required: false })
@Rule(RuleType.string().optional())
startDate?: string;
@ApiProperty({ description: '结束时间', type: String, required: false })
@Rule(RuleType.string().optional())
endDate?: string;
@ApiProperty({ description: '强制同步(忽略缓存)', type: Boolean, required: false, default: false })
@Rule(RuleType.boolean().optional())
force?: boolean = false;
}
/**
* DTO
*/
export class BatchQueryDTO {
@ApiProperty({ description: 'ID列表', type: [String, Number] })
@Rule(RuleType.array().items(RuleType.alternatives().try(RuleType.string(), RuleType.number())).required())
ids: Array<string | number>;
@ApiProperty({ description: '包含关联数据', type: Boolean, required: false, default: false })
@Rule(RuleType.boolean().optional())
includeRelations?: boolean = false;
}
/**
*
*/
export class BatchOperationResultDTOGeneric<T> extends BatchOperationResultDTO {
@ApiProperty({ description: '操作成功的数据列表', type: Array })
data?: T[];
}
/**
*
*/
export class SyncOperationResultDTOGeneric<T> extends SyncOperationResultDTO {
@ApiProperty({ description: '同步成功的数据列表', type: Array })
data?: T[];
}

View File

@ -36,3 +36,27 @@ export class CustomerTagDTO {
@ApiProperty()
tag: string;
}
export class CustomerDto {
@ApiProperty()
id: number;
@ApiProperty()
site_id: number;
@ApiProperty()
email: string;
@ApiProperty()
avatar: string;
@ApiProperty()
tags: string[];
@ApiProperty()
rate: number;
@ApiProperty()
state: string;
}

View File

@ -25,7 +25,7 @@ import { Dict } from '../entity/dict.entity';
export class BooleanRes extends SuccessWrapper(Boolean) {}
//网站配置返回数据
export class WpSitesResponse extends SuccessArrayWrapper(SiteConfig) {}
export class SitesResponse extends SuccessArrayWrapper(SiteConfig) {}
//产品分页数据
export class ProductPaginatedResponse extends PaginatedWrapper(Product) {}
//产品分页返回数据

View File

@ -1,13 +1,58 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('customer')
export class Customer {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: true })
site_id: number;
@Column({ nullable: true })
origin_id: string;
@Column({ unique: true })
email: string;
@Column({ nullable: true })
first_name: string;
@Column({ nullable: true })
last_name: string;
@Column({ nullable: true })
fullname: string;
@Column({ nullable: true })
username: string;
@Column({ nullable: true })
phone: string;
@Column({ nullable: true })
avatar: string;
@Column({ type: 'json', nullable: true })
billing: any;
@Column({ type: 'json', nullable: true })
shipping: any;
@Column({ type: 'json', nullable: true })
raw: any;
@Column({ default: 0})
rate: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@Column({ nullable: true })
site_created_at: Date;
@Column({ nullable: true })
site_updated_at: Date;
}

View File

@ -5,15 +5,6 @@ export interface IUserOptions {
uid: number;
}
export interface WpSite {
id: string;
wpApiUrl: string;
consumerKey: string;
consumerSecret: string;
name: string;
email: string;
emailPswd: string;
}
export interface PaginationParams {
current?: number; // 当前页码

View File

@ -14,6 +14,7 @@ import {
CreateWebhookDTO,
UpdateWebhookDTO,
} from '../dto/site-api.dto';
import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto';
export interface ISiteAdapter {
/**
@ -101,13 +102,13 @@ export interface ISiteAdapter {
*/
deleteProduct(id: string | number): Promise<boolean>;
batchProcessProducts?(data: { create?: any[]; update?: any[]; delete?: Array<string | number> }): Promise<any>;
batchProcessProducts?(data: BatchOperationDTO): Promise<BatchOperationResultDTO>;
createOrder(data: Partial<UnifiedOrderDTO>): Promise<UnifiedOrderDTO>;
updateOrder(id: string | number, data: Partial<UnifiedOrderDTO>): Promise<boolean>;
deleteOrder(id: string | number): Promise<boolean>;
batchProcessOrders?(data: { create?: any[]; update?: any[]; delete?: Array<string | number> }): Promise<any>;
batchProcessOrders?(data: BatchOperationDTO): Promise<BatchOperationResultDTO>;
getCustomers(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedCustomerDTO>>;
getCustomer(id: string | number): Promise<UnifiedCustomerDTO>;
@ -115,7 +116,7 @@ export interface ISiteAdapter {
updateCustomer(id: string | number, data: Partial<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO>;
deleteCustomer(id: string | number): Promise<boolean>;
batchProcessCustomers?(data: { create?: any[]; update?: any[]; delete?: Array<string | number> }): Promise<any>;
batchProcessCustomers?(data: BatchOperationDTO): Promise<BatchOperationResultDTO>;
/**
* webhooks列表

View File

@ -23,6 +23,13 @@ export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
'/webhook/woocommerce',
'/logistics/getTrackingNumber',
'/logistics/getListByTrackingId',
'/product/categories/all',
'/product/category/1/attributes',
'/product/category/2/attributes',
'/product/category/3/attributes',
'/product/category/4/attributes',
'/product/list',
'/dict/items',
];
match(ctx: Context) {

View File

@ -1,9 +1,12 @@
import { Provide } from '@midwayjs/core';
import { Provide, Inject } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Order } from '../entity/order.entity';
import { Repository } from 'typeorm';
import { CustomerTag } from '../entity/customer_tag.entity';
import { Customer } from '../entity/customer.entity';
import { SiteApiService } from './site-api.service';
import { UnifiedCustomerDTO, UnifiedSearchParamsDTO } from '../dto/site-api.dto';
import { SyncOperationResult, BatchErrorItem } from '../dto/batch.dto';
@Provide()
export class CustomerService {
@ -16,7 +19,183 @@ export class CustomerService {
@InjectEntityModel(Customer)
customerModel: Repository<Customer>;
async getCustomerList(param: Record<string, any>) {
@Inject()
siteApiService: SiteApiService;
/**
*
*/
async findCustomerByEmail(email: string): Promise<Customer | null> {
return await this.customerModel.findOne({ where: { email } });
}
/**
*
*
*/
private mapSiteCustomerToCustomer(siteCustomer: UnifiedCustomerDTO, siteId: number): Partial<Customer> {
return {
site_id: siteId, // 使用站点ID而不是客户ID
origin_id: "" + siteCustomer.id,
email: siteCustomer.email,
first_name: siteCustomer.first_name,
last_name: siteCustomer.last_name,
fullname: siteCustomer.fullname || `${siteCustomer.first_name || ''} ${siteCustomer.last_name || ''}`.trim(),
username: siteCustomer.username || '',
phone: siteCustomer.phone || '',
avatar: siteCustomer.avatar,
billing: siteCustomer.billing,
shipping: siteCustomer.shipping,
raw: siteCustomer.raw || siteCustomer,
site_created_at: this.parseDate(siteCustomer.date_created),
site_updated_at: this.parseDate(siteCustomer.date_modified)
};
}
/**
*
*/
private parseDate(dateValue: any): Date | null {
if (!dateValue) return null;
if (dateValue instanceof Date) {
return dateValue;
}
if (typeof dateValue === 'number') {
// 处理Unix时间戳秒或毫秒
return new Date(dateValue > 9999999999 ? dateValue : dateValue * 1000);
}
if (typeof dateValue === 'string') {
const date = new Date(dateValue);
return isNaN(date.getTime()) ? null : date;
}
return null;
}
/**
*
*/
async createCustomer(customerData: Partial<Customer>): Promise<Customer> {
const customer = this.customerModel.create(customerData);
return await this.customerModel.save(customer);
}
/**
*
*/
async updateCustomer(id: number, customerData: Partial<Customer>): Promise<Customer> {
await this.customerModel.update(id, customerData);
return await this.customerModel.findOne({ where: { id } });
}
/**
* upsert
*
*/
async upsertCustomer(
customerData: Partial<Customer>,
): Promise<{ customer: Customer; isCreated: boolean }> {
if(!customerData.email) throw new Error("客户邮箱不能为空");
// 首先尝试根据邮箱查找现有客户
const existingCustomer = await this.findCustomerByEmail(customerData.email);
if (existingCustomer) {
// 如果客户存在,更新客户信息
const updatedCustomer = await this.updateCustomer(existingCustomer.id, customerData);
return { customer: updatedCustomer, isCreated: false };
} else {
// 如果客户不存在,创建新客户
const newCustomer = await this.createCustomer(customerData);
return { customer: newCustomer, isCreated: true };
}
}
/**
*
* 使
*/
async upsertManyCustomers(
customersData: Array<Partial<Customer>>
): Promise<{
customers: Customer[];
created: number;
updated: number;
processed: number;
errors: BatchErrorItem[];
}> {
const results = {
customers: [],
created: 0,
updated: 0,
processed: 0,
errors: []
};
// 批量处理每个客户
for (const customerData of customersData) {
try {
const result = await this.upsertCustomer(customerData);
results.customers.push(result.customer);
if (result.isCreated) {
results.created++;
} else {
results.updated++;
}
results.processed++;
} catch (error) {
// 记录错误但不中断整个批量操作
results.errors.push({
identifier: customerData.email || String(customerData.id) || 'unknown',
error: error.message
});
}
}
return results;
}
/**
*
* adapter获取站点客户数据
* upsertManyCustomers保存这些客户
*/
async syncCustomersFromSite(
siteId: number,
params?: UnifiedSearchParamsDTO
): Promise<SyncOperationResult> {
try {
// 第一步:获取适配器并从站点获取客户数据
const adapter = await this.siteApiService.getAdapter(siteId);
const siteCustomersResult = await adapter.getCustomers(params || {});
// 第二步:将站点客户数据转换为客户实体数据
const customersData = siteCustomersResult.items.map(siteCustomer => {
return this.mapSiteCustomerToCustomer(siteCustomer, siteId);
});
// 第三步批量upsert客户数据
const upsertResult = await this.upsertManyCustomers(customersData);
return {
total: siteCustomersResult.total,
processed: upsertResult.customers.length,
synced: upsertResult.customers.length,
updated: upsertResult.updated,
created: upsertResult.created,
errors: upsertResult.errors
};
} catch (error) {
// 如果获取适配器或站点数据失败,抛出错误
throw new Error(`同步客户数据失败: ${error.message}`);
}
}
async getCustomerStatisticList(param: Record<string, any>) {
const {
current = 1,
pageSize = 10,
@ -148,6 +327,112 @@ export class CustomerService {
};
}
/**
*
*
* 使TypeORM查询构建器实现
*/
async getCustomerList(param: Record<string, any>): Promise<any>{
const {
current = 1,
pageSize = 10,
email,
firstName,
lastName,
phone,
state,
rate,
sorterKey,
sorterValue,
} = param;
// 创建查询构建器
const queryBuilder = this.customerModel
.createQueryBuilder('c')
.leftJoinAndSelect(
'customer_tag',
'ct',
'ct.email = c.email'
)
.select([
'c.id',
'c.email',
'c.first_name',
'c.last_name',
'c.fullname',
'c.username',
'c.phone',
'c.avatar',
'c.billing',
'c.shipping',
'c.rate',
'c.site_id',
'c.created_at',
'c.updated_at',
'c.site_created_at',
'c.site_updated_at',
'GROUP_CONCAT(ct.tag) as tags'
])
.groupBy('c.id');
// 邮箱搜索
if (email) {
queryBuilder.andWhere('c.email LIKE :email', { email: `%${email}%` });
}
// 姓名搜索
if (firstName) {
queryBuilder.andWhere('c.first_name LIKE :firstName', { firstName: `%${firstName}%` });
}
if (lastName) {
queryBuilder.andWhere('c.last_name LIKE :lastName', { lastName: `%${lastName}%` });
}
// 电话搜索
if (phone) {
queryBuilder.andWhere('c.phone LIKE :phone', { phone: `%${phone}%` });
}
// 省份搜索
if (state) {
queryBuilder.andWhere("JSON_UNQUOTE(JSON_EXTRACT(c.billing, '$.state')) = :state", { state });
}
// 评分过滤
if (rate !== undefined && rate !== null) {
queryBuilder.andWhere('c.rate = :rate', { rate: Number(rate) });
}
// 排序处理
if (sorterKey) {
const order = sorterValue === 'descend' ? 'DESC' : 'ASC';
queryBuilder.orderBy(`c.${sorterKey}`, order);
} else {
queryBuilder.orderBy('c.created_at', 'DESC');
}
// 分页
queryBuilder.skip((current - 1) * pageSize).take(pageSize);
// 执行查询
const [items, total] = await queryBuilder.getManyAndCount();
// 处理tags字段将逗号分隔的字符串转换为数组
const processedItems = items.map(item => {
const plainItem = JSON.parse(JSON.stringify(item));
plainItem.tags = plainItem.tags ? plainItem.tags.split(',').filter(tag => tag) : [];
return plainItem;
});
return {
items: processedItems,
total,
current,
pageSize,
};
}
async addTag(email: string, tag: string) {
const isExist = await this.customerTagModel.findOneBy({ email, tag });
@ -172,4 +457,4 @@ export class CustomerService {
async setRate(params: { id: number; rate: number }) {
return await this.customerModel.update(params.id, { rate: params.rate });
}
}
}

View File

@ -1,6 +1,5 @@
import { Inject, Provide } from '@midwayjs/core';
import { WPService } from './wp.service';
import { WpSite } from '../interface';
import { Order } from '../entity/order.entity';
import { In, Like, Repository } from 'typeorm';
import { InjectEntityModel, TypeORMDataSourceManager } from '@midwayjs/typeorm';
@ -1447,8 +1446,7 @@ export class OrderService {
async cancelOrder(id: number) {
const order = await this.orderModel.findOne({ where: { id } });
if (!order) throw new Error(`订单 ${id}不存在`);
const s: any = await this.siteService.get(Number(order.siteId), true);
const site = { id: String(s.id), wpApiUrl: s.apiUrl, consumerKey: s.consumerKey, consumerSecret: s.consumerSecret, name: s.name, email: '', emailPswd: '' } as WpSite;
const site = await this.siteService.get(Number(order.siteId), true);
if (order.status !== OrderStatus.CANCEL) {
await this.wpService.updateOrder(site, order.externalOrderId, {
status: OrderStatus.CANCEL,

View File

@ -6,6 +6,7 @@ import { SiteService } from './site.service';
import { Site } from '../entity/site.entity';
import { UnifiedReviewDTO } from '../dto/site-api.dto';
import { ShopyyReview } from '../dto/shopyy.dto';
import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto';
/**
* ShopYY平台服务实现
@ -533,10 +534,40 @@ export class ShopyyService {
* @param data
* @returns
*/
async batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise<any> {
async batchProcessProducts(site: any, data: BatchOperationDTO): Promise<BatchOperationResultDTO> {
// ShopYY API: POST /products/batch
const response = await this.request(site, 'products/batch', 'POST', data);
return response.data;
const result = response.data;
// 转换 ShopYY 批量操作结果为统一格式
const errors: Array<{identifier: string, error: string}> = [];
// 假设 ShopYY 返回格式与 WooCommerce 类似: { create: [...], update: [...], delete: [...] }
// 错误信息可能在每个项目的 error 字段中
const checkForErrors = (items: any[]) => {
items.forEach(item => {
if (item.error) {
errors.push({
identifier: String(item.id || item.sku || 'unknown'),
error: typeof item.error === 'string' ? item.error : JSON.stringify(item.error)
});
}
});
};
// 检查每个操作类型的结果中的错误
if (result.create) checkForErrors(result.create);
if (result.update) checkForErrors(result.update);
if (result.delete) checkForErrors(result.delete);
return {
total: (data.create?.length || 0) + (data.update?.length || 0) + (data.delete?.length || 0),
processed: (result.create?.length || 0) + (result.update?.length || 0) + (result.delete?.length || 0),
created: result.create?.length || 0,
updated: result.update?.length || 0,
deleted: result.delete?.length || 0,
errors: errors
};
}
/**

View File

@ -2,7 +2,6 @@ import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, Like, In } from 'typeorm';
import { Site } from '../entity/site.entity';
import { WpSite } from '../interface';
import { CreateSiteDTO, UpdateSiteDTO } from '../dto/site.dto';
import { Area } from '../entity/area.entity';
import { StockPoint } from '../entity/stock_point.entity';
@ -19,29 +18,6 @@ export class SiteService {
@InjectEntityModel(StockPoint)
stockPointModel: Repository<StockPoint>;
async syncFromConfig(sites: WpSite[] = []) {
// 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化)
for (const siteConfig of sites) {
// 按站点名称查询是否已存在记录
const exist = await this.siteModel.findOne({
where: { name: siteConfig.name },
});
// 将 WpSite 字段映射为 Site 实体字段
const payload: Partial<Site> = {
name: siteConfig.name,
apiUrl: (siteConfig as any).wpApiUrl,
consumerKey: (siteConfig as any).consumerKey,
consumerSecret: (siteConfig as any).consumerSecret,
type: 'woocommerce',
};
// 存在则更新,不存在则插入新记录
if (exist) {
await this.siteModel.update({ id: exist.id }, payload);
} else {
await this.siteModel.insert(payload as Site);
}
}
}
async create(data: CreateSiteDTO) {
// 从 DTO 中分离出区域代码和其他站点数据

View File

@ -10,9 +10,10 @@ import { Variation } from '../entity/variation.entity';
import { UpdateVariationDTO, UpdateWpProductDTO } from '../dto/wp_product.dto';
import { SiteService } from './site.service';
import { IPlatformService } from '../interface/platform.interface';
import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto';
import * as FormData from 'form-data';
import * as fs from 'fs';
const MAX_PAGE_SIZE = 100;
@Provide()
export class WPService implements IPlatformService {
getCustomer(site: any, id: number): Promise<any> {
@ -79,11 +80,80 @@ export class WPService implements IPlatformService {
/**
* SDK ,
* 使,
* date_created ,
*/
private async sdkGetAll<T>(api: WooCommerceRestApi, resource: string, params: Record<string, any> = {}, maxPages: number = 50): Promise<T[]> {
// 直接传入较大的per_page参数一次性获取所有数据
const { items } = await this.sdkGetPage<T>(api, resource, { ...params, per_page: 100 });
return items;
private async sdkGetAll<T>(api: WooCommerceRestApi, resource: string, params: Record<string, any> = {}, maxPages: number = MAX_PAGE_SIZE): Promise<T[]> {
return this.sdkGetAllConcurrent<T>(api, resource, params, maxPages);
}
/**
* SDK ,使
*
* date_created ,
*/
private async sdkGetAllConcurrent<T>(
api: WooCommerceRestApi,
resource: string,
params: Record<string, any> = {},
maxPages: number = MAX_PAGE_SIZE,
concurrencyLimit: number = 5
): Promise<T[]> {
// 设置默认排序为 date_created 倒序,确保获取最新数据
const defaultParams = {
orderby: 'date_created',
order: 'desc',
per_page: MAX_PAGE_SIZE,
...params
};
// 首先获取第一页数据,同时获取总页数信息
const firstPage = await this.sdkGetPage<T>(api, resource, { ...defaultParams, page: 1 });
const { items: firstPageItems, totalPages } = firstPage;
// 如果只有一页数据,直接返回
if (totalPages <= 1) {
return firstPageItems;
}
// 限制最大页数,避免过多的并发请求
const actualMaxPages = Math.min(totalPages, maxPages);
// 收集所有页面数据,从第二页开始
const allItems = [...firstPageItems];
let currentPage = 2;
// 使用并发限制,避免一次性发起过多请求
while (currentPage <= actualMaxPages) {
const batchPromises: Promise<T[]>[] = [];
const batchSize = Math.min(concurrencyLimit, actualMaxPages - currentPage + 1);
// 创建当前批次的并发请求
for (let i = 0; i < batchSize; i++) {
const page = currentPage + i;
const pagePromise = this.sdkGetPage<T>(api, resource, { ...defaultParams, page })
.then(pageResult => pageResult.items)
.catch(error => {
console.error(`获取第 ${page} 页数据失败:`, error);
return []; // 如果某页获取失败,返回空数组,不影响整体结果
});
batchPromises.push(pagePromise);
}
// 等待当前批次完成
const batchResults = await Promise.all(batchPromises);
// 合并当前批次的数据
for (const pageItems of batchResults) {
allItems.push(...pageItems);
}
// 移动到下一批次
currentPage += batchSize;
}
return allItems;
}
/**
@ -551,12 +621,42 @@ export class WPService implements IPlatformService {
*/
async batchProcessProducts(
site: any,
data: { create?: any[]; update?: any[]; delete?: any[] }
): Promise<any> {
data: BatchOperationDTO
): Promise<BatchOperationResultDTO> {
const api = this.createApi(site, 'wc/v3');
try {
const response = await api.post('products/batch', data);
return response.data;
const result = response.data;
// 转换 WooCommerce 批量操作结果为统一格式
const errors: Array<{identifier: string, error: string}> = [];
// WooCommerce 返回格式: { create: [...], update: [...], delete: [...] }
// 错误信息可能在每个项目的 error 字段中
const checkForErrors = (items: any[]) => {
items.forEach(item => {
if (item.error) {
errors.push({
identifier: String(item.id || item.sku || 'unknown'),
error: typeof item.error === 'string' ? item.error : JSON.stringify(item.error)
});
}
});
};
// 检查每个操作类型的结果中的错误
if (result.create) checkForErrors(result.create);
if (result.update) checkForErrors(result.update);
if (result.delete) checkForErrors(result.delete);
return {
total: (data.create?.length || 0) + (data.update?.length || 0) + (data.delete?.length || 0),
processed: (result.create?.length || 0) + (result.update?.length || 0) + (result.delete?.length || 0),
created: result.create?.length || 0,
updated: result.update?.length || 0,
deleted: result.delete?.length || 0,
errors: errors
};
} catch (error) {
console.error('批量处理产品失败:', error.response?.data || error.message);
throw error;

View File

@ -555,7 +555,7 @@ export class WpProductService {
// 同步一个网站
async syncSite(siteId: number) {
try {
// 通过数据库获取站点并转换为 WpSite,用于后续 WooCommerce 同步
// 通过数据库获取站点并转换为 Site,用于后续 WooCommerce 同步
const site = await this.siteService.get(siteId, true);
const externalProductIds = this.wpProductModel.createQueryBuilder('wp_product')
.select([