526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import { Inject, Provide } from '@midwayjs/core';
|
||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { SyncOperationResult, UnifiedPaginationDTO, UnifiedSearchParamsDTO, BatchOperationResult } from '../dto/api.dto';
|
||
import { UnifiedCustomerDTO } from '../dto/site-api.dto';
|
||
import { Customer } from '../entity/customer.entity';
|
||
import { CustomerTag } from '../entity/customer_tag.entity';
|
||
import { Order } from '../entity/order.entity';
|
||
import { SiteApiService } from './site-api.service';
|
||
import { CreateCustomerDTO, CustomerDTO, CustomerStatisticDTO, CustomerStatisticQueryParamsDTO } from '../dto/customer.dto';
|
||
|
||
@Provide()
|
||
export class CustomerService {
|
||
@InjectEntityModel(Order)
|
||
orderModel: Repository<Order>;
|
||
|
||
@InjectEntityModel(CustomerTag)
|
||
customerTagModel: Repository<CustomerTag>;
|
||
|
||
@InjectEntityModel(Customer)
|
||
customerModel: Repository<Customer>;
|
||
|
||
@Inject()
|
||
siteApiService: SiteApiService;
|
||
|
||
/**
|
||
* 根据邮箱查找客户
|
||
*/
|
||
async findCustomerByEmail(email: string): Promise<Customer | null> {
|
||
return await this.customerModel.findOne({ where: { email } });
|
||
}
|
||
|
||
/**
|
||
* 将站点客户数据映射为本地客户实体数据
|
||
* 处理字段映射和数据转换,确保所有字段正确同步
|
||
*/
|
||
private mapSiteCustomerToCustomer(siteCustomer: UnifiedCustomerDTO, siteId: number): Partial<CreateCustomerDTO> {
|
||
return {
|
||
site_id: siteId, // 使用站点ID而不是客户ID
|
||
site_created_at: this.parseDate(siteCustomer.date_created),
|
||
site_updated_at: this.parseDate(siteCustomer.date_modified),
|
||
origin_id: Number(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,
|
||
};
|
||
}
|
||
|
||
|
||
/**
|
||
* 解析日期字符串或时间戳
|
||
*/
|
||
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<BatchOperationResult> {
|
||
const results = {
|
||
total: customersData.length,
|
||
processed: 0,
|
||
created: 0,
|
||
updated: 0,
|
||
errors: []
|
||
};
|
||
|
||
// 批量处理每个客户
|
||
for (const customerData of customersData) {
|
||
try {
|
||
const result = await this.upsertCustomer(customerData);
|
||
|
||
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
|
||
});
|
||
results.processed++;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 从站点同步客户数据
|
||
* 第一步:调用adapter获取站点客户数据
|
||
* 第二步:通过upsertManyCustomers保存这些客户
|
||
*/
|
||
async syncCustomersFromSite(
|
||
siteId: number,
|
||
params?: UnifiedSearchParamsDTO
|
||
): Promise<SyncOperationResult> {
|
||
try {
|
||
// 第一步:获取适配器并从站点获取客户数据
|
||
const adapter = await this.siteApiService.getAdapter(siteId);
|
||
const siteCustomers = await adapter.getAllCustomers(params || {});
|
||
|
||
// 第二步:将站点客户数据转换为客户实体数据
|
||
const customersData = siteCustomers.map(siteCustomer => {
|
||
return this.mapSiteCustomerToCustomer(siteCustomer, siteId);
|
||
});
|
||
|
||
// 第三步:批量upsert客户数据
|
||
const upsertResult = await this.upsertManyCustomers(customersData);
|
||
return {
|
||
total: siteCustomers.length,
|
||
processed: upsertResult.processed,
|
||
synced: upsertResult.processed,
|
||
updated: upsertResult.updated,
|
||
created: upsertResult.created,
|
||
errors: upsertResult.errors
|
||
};
|
||
|
||
} catch (error) {
|
||
// 如果获取适配器或站点数据失败,抛出错误
|
||
throw new Error(`同步客户数据失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取客户统计列表(包含订单统计信息)
|
||
* 支持分页、搜索和排序功能
|
||
* 使用原生SQL查询实现复杂的统计逻辑
|
||
*/
|
||
async getCustomerStatisticList(param: CustomerStatisticQueryParamsDTO): Promise<{
|
||
items: CustomerStatisticDTO[];
|
||
total: number;
|
||
current: number;
|
||
pageSize: number;
|
||
}> {
|
||
const {
|
||
page = 1,
|
||
per_page = 10,
|
||
search,
|
||
where,
|
||
orderBy,
|
||
} = param;
|
||
|
||
// 将page和per_page转换为current和pageSize
|
||
const current = page;
|
||
const pageSize = per_page;
|
||
|
||
const whereConds: string[] = [];
|
||
const havingConds: string[] = [];
|
||
|
||
// 全局搜索关键词
|
||
if (search) {
|
||
whereConds.push(`o.customer_email LIKE '%${search}%'`);
|
||
}
|
||
|
||
// where条件过滤
|
||
if (where) {
|
||
// 邮箱搜索
|
||
if (where.email) {
|
||
whereConds.push(`o.customer_email LIKE '%${where.email}%'`);
|
||
}
|
||
|
||
|
||
// customerId 过滤
|
||
if (where.customerId) {
|
||
whereConds.push(`c.id = ${Number(where.customerId)}`);
|
||
}
|
||
|
||
// rate 过滤
|
||
if (where.rate) {
|
||
whereConds.push(`c.rate = ${Number(where.rate)}`);
|
||
}
|
||
|
||
// tags 过滤
|
||
if (where.tags) {
|
||
const tagList = where.tags
|
||
.split(',')
|
||
.map(tag => `'${tag.trim()}'`)
|
||
.join(',');
|
||
havingConds.push(`
|
||
EXISTS (
|
||
SELECT 1 FROM customer_tag ct
|
||
WHERE ct.email = o.customer_email
|
||
AND ct.tag IN (${tagList})
|
||
)
|
||
`);
|
||
}
|
||
|
||
// 首次购买时间过滤
|
||
if (where.first_purchase_date) {
|
||
havingConds.push(
|
||
`DATE_FORMAT(MIN(o.date_paid), '%Y-%m') = '${where.first_purchase_date}'`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 公用过滤
|
||
const baseQuery = `
|
||
${whereConds.length ? `WHERE ${whereConds.join(' AND ')}` : ''}
|
||
GROUP BY o.customer_email
|
||
${havingConds.length ? `HAVING ${havingConds.join(' AND ')}` : ''}
|
||
`;
|
||
|
||
// 排序处理
|
||
let orderByClause = '';
|
||
if (orderBy) {
|
||
if (typeof orderBy === 'string') {
|
||
const [field, direction] = orderBy.split(':');
|
||
orderByClause = `ORDER BY ${field} ${direction === 'desc' ? 'DESC' : 'ASC'}`;
|
||
} else if (typeof orderBy === 'object') {
|
||
const orderClauses = Object.entries(orderBy).map(([field, direction]) =>
|
||
`${field} ${direction === 'desc' ? 'DESC' : 'ASC'}`
|
||
);
|
||
orderByClause = `ORDER BY ${orderClauses.join(', ')}`;
|
||
}
|
||
} else {
|
||
orderByClause = 'ORDER BY orders ASC, yoone_total DESC';
|
||
}
|
||
|
||
// 主查询
|
||
const sql = `
|
||
SELECT
|
||
o.customer_email AS email,
|
||
MIN(o.date_created) AS date_created,
|
||
MIN(o.date_paid) AS first_purchase_date,
|
||
MAX(o.date_paid) AS last_purchase_date,
|
||
COUNT(DISTINCT o.id) AS orders,
|
||
SUM(o.total) AS total,
|
||
ANY_VALUE(o.shipping) AS shipping,
|
||
ANY_VALUE(o.billing) AS billing,
|
||
(
|
||
SELECT JSON_ARRAYAGG(tag)
|
||
FROM customer_tag ct
|
||
WHERE ct.email = o.customer_email
|
||
) AS tags,
|
||
c.id AS customerId,
|
||
c.rate AS rate,
|
||
yoone_stats.yoone_orders,
|
||
yoone_stats.yoone_total
|
||
FROM \`order\` o
|
||
LEFT JOIN customer c ON o.customer_email = c.email
|
||
LEFT JOIN (
|
||
SELECT
|
||
oo.customer_email,
|
||
COUNT(DISTINCT oi.orderId) AS yoone_orders,
|
||
SUM(oi.total) AS yoone_total
|
||
FROM order_item oi
|
||
JOIN \`order\` oo ON oi.orderId = oo.id
|
||
WHERE oi.name LIKE '%yoone%'
|
||
GROUP BY oo.customer_email
|
||
) yoone_stats ON yoone_stats.customer_email = o.customer_email
|
||
${baseQuery}
|
||
${orderByClause}
|
||
LIMIT ${pageSize} OFFSET ${(current - 1) * pageSize}
|
||
`;
|
||
|
||
// 统计总数
|
||
const countSql = `
|
||
SELECT COUNT(*) AS total FROM (
|
||
SELECT o.customer_email
|
||
FROM \`order\` o
|
||
LEFT JOIN customer c ON o.customer_email = c.email
|
||
${baseQuery}
|
||
) AS sub
|
||
`;
|
||
|
||
const [items, countResult] = await Promise.all([
|
||
this.orderModel.query(sql),
|
||
this.orderModel.query(countSql),
|
||
]);
|
||
|
||
const total = countResult[0]?.total || 0;
|
||
|
||
// 处理tags字段,将JSON字符串转换为数组
|
||
const processedItems = items.map(item => {
|
||
if (item.tags) {
|
||
try {
|
||
item.tags = JSON.parse(item.tags);
|
||
} catch (e) {
|
||
item.tags = [];
|
||
}
|
||
} else {
|
||
item.tags = [];
|
||
}
|
||
return item;
|
||
});
|
||
|
||
return {
|
||
items: processedItems,
|
||
total,
|
||
current,
|
||
pageSize,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取纯粹的客户列表(不包含订单统计信息)
|
||
* 支持基本的分页、搜索和排序功能
|
||
* 使用TypeORM查询构建器实现
|
||
*/
|
||
async getCustomerList(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<CustomerDTO>>{
|
||
const {
|
||
page = 1,
|
||
per_page = 20,
|
||
where ={},
|
||
} = params;
|
||
|
||
// 查询客户列表和总数
|
||
const [customers, total] = await this.customerModel.findAndCount({
|
||
where,
|
||
// order: orderBy,
|
||
skip: (page - 1) * per_page,
|
||
take: per_page,
|
||
});
|
||
|
||
// 获取所有客户的邮箱列表
|
||
const emailList = customers.map(customer => customer.email);
|
||
|
||
// 查询所有客户的标签
|
||
let customerTagsMap: Record<string, string[]> = {};
|
||
if (emailList.length > 0) {
|
||
const customerTags = await this.customerTagModel
|
||
.createQueryBuilder('tag')
|
||
.select('tag.email', 'email')
|
||
.addSelect('tag.tag', 'tag')
|
||
.where('tag.email IN (:...emailList)', { emailList })
|
||
.getRawMany();
|
||
|
||
// 将标签按邮箱分组
|
||
customerTagsMap = customerTags.reduce((acc, item) => {
|
||
if (!acc[item.email]) {
|
||
acc[item.email] = [];
|
||
}
|
||
acc[item.email].push(item.tag);
|
||
return acc;
|
||
}, {} as Record<string, string[]>);
|
||
}
|
||
|
||
// 将标签合并到客户数据中
|
||
const customersWithTags = customers.map(customer => ({
|
||
...customer,
|
||
tags: customerTagsMap[customer.email] || []
|
||
}));
|
||
|
||
return {
|
||
items: customersWithTags,
|
||
total,
|
||
page,
|
||
per_page,
|
||
totalPages: Math.ceil(total / per_page),
|
||
};
|
||
}
|
||
|
||
|
||
async addTag(email: string, tag: string) {
|
||
const isExist = await this.customerTagModel.findOneBy({ email, tag });
|
||
if (isExist) throw new Error(`${tag}已存在`);
|
||
return await this.customerTagModel.save({ email, tag });
|
||
}
|
||
|
||
async delTag(email: string, tag: string) {
|
||
const isExist = await this.customerTagModel.findOneBy({ email, tag });
|
||
if (!isExist) throw new Error(`${tag}不存在`);
|
||
return await this.customerTagModel.delete({ email, tag });
|
||
}
|
||
|
||
async getTags() {
|
||
const tags = await this.customerTagModel
|
||
.createQueryBuilder('tag')
|
||
.select('DISTINCT tag.tag', 'tag')
|
||
.getRawMany();
|
||
return tags.map(t => t.tag);
|
||
}
|
||
|
||
async setRate(params: { id: number; rate: number }) {
|
||
return await this.customerModel.update(params.id, { rate: params.rate });
|
||
}
|
||
|
||
/**
|
||
* 批量更新客户
|
||
* 每个客户可以有独立的更新字段
|
||
* 支持对多个客户进行统一化修改或分别更新
|
||
*/
|
||
async batchUpdateCustomers(
|
||
updateItems: Array<{ id: number; update_data: Partial<Customer> }>
|
||
): Promise<BatchOperationResult> {
|
||
const results = {
|
||
total: updateItems.length,
|
||
processed: 0,
|
||
updated: 0,
|
||
errors: []
|
||
};
|
||
|
||
// 批量处理每个客户的更新
|
||
for (const item of updateItems) {
|
||
try {
|
||
// 检查客户是否存在
|
||
const existingCustomer = await this.customerModel.findOne({ where: { id: item.id } });
|
||
if (!existingCustomer) {
|
||
throw new Error(`客户ID ${item.id} 不存在`);
|
||
}
|
||
|
||
// 更新客户信息
|
||
await this.updateCustomer(item.id, item.update_data);
|
||
results.updated++;
|
||
results.processed++;
|
||
} catch (error) {
|
||
// 记录错误但不中断整个批量操作
|
||
results.errors.push({
|
||
identifier: String(item.id),
|
||
error: error.message
|
||
});
|
||
results.processed++;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 批量删除客户
|
||
* 支持对多个客户进行批量删除操作
|
||
* 返回操作结果,包括成功和失败的数量
|
||
*/
|
||
async batchDeleteCustomers(ids: number[]): Promise<BatchOperationResult> {
|
||
const results = {
|
||
total: ids.length,
|
||
processed: 0,
|
||
updated: 0,
|
||
errors: []
|
||
};
|
||
|
||
// 批量处理每个客户的删除
|
||
for (const id of ids) {
|
||
try {
|
||
// 检查客户是否存在
|
||
const existingCustomer = await this.customerModel.findOne({ where: { id } });
|
||
if (!existingCustomer) {
|
||
throw new Error(`客户ID ${id} 不存在`);
|
||
}
|
||
|
||
// 删除客户
|
||
await this.customerModel.delete(id);
|
||
results.updated++;
|
||
results.processed++;
|
||
} catch (error) {
|
||
// 记录错误但不中断整个批量操作
|
||
results.errors.push({
|
||
identifier: String(id),
|
||
error: error.message
|
||
});
|
||
results.processed++;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
} |