diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts index f716c1c..9b200cf 100644 --- a/src/adapter/woocommerce.adapter.ts +++ b/src/adapter/woocommerce.adapter.ts @@ -752,7 +752,6 @@ export class WooCommerceAdapter implements ISiteAdapter { raw: item, }; } - async getCustomers(params: UnifiedSearchParamsDTO): Promise> { const requestParams = this.mapCustomerSearchParams(params); const { items, total, totalPages, page, per_page } = await this.wpService.fetchResourcePaged( @@ -794,3 +793,4 @@ export class WooCommerceAdapter implements ISiteAdapter { return true; } } + diff --git a/src/config/config.default.ts b/src/config/config.default.ts index 3314dfe..e386aa6 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -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', diff --git a/src/config/config.local.ts b/src/config/config.local.ts index a05235b..0c962ce 100644 --- a/src/config/config.local.ts +++ b/src/config/config.local.ts @@ -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.', // }, diff --git a/src/controller/customer.controller.ts b/src/controller/customer.controller.ts index 9225aa5..96c96ff 100644 --- a/src/controller/customer.controller.ts +++ b/src/controller/customer.controller.ts @@ -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); + } + } +} \ No newline at end of file diff --git a/src/controller/site-api.controller.ts b/src/controller/site-api.controller.ts index 660e589..0cfa695 100644 --- a/src/controller/site-api.controller.ts +++ b/src/controller/site-api.controller.ts @@ -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 } + @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 = []; - 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 } + @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 = []; - 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); diff --git a/src/controller/site.controller.ts b/src/controller/site.controller.ts index 02cf312..3427a33 100644 --- a/src/controller/site.controller.ts +++ b/src/controller/site.controller.ts @@ -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 { diff --git a/src/controller/webhook.controller.ts b/src/controller/webhook.controller.ts index c4398b0..c4ad821 100644 --- a/src/controller/webhook.controller.ts +++ b/src/controller/webhook.controller.ts @@ -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, diff --git a/src/dto/batch.dto.ts b/src/dto/batch.dto.ts new file mode 100644 index 0000000..67b11cd --- /dev/null +++ b/src/dto/batch.dto.ts @@ -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 { + @ApiProperty({ description: '要创建的数据列表', type: Array }) + @Rule(RuleType.array().required()) + items: T[]; +} + +/** + * 批量更新DTO + */ +export class BatchUpdateDTO { + @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; +} + +/** + * 批量操作请求DTO(包含增删改) + */ +export class BatchOperationDTO { + @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; +} + +/** + * 分页批量操作DTO + */ +export class PaginatedBatchOperationDTO { + @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; +} + +/** + * 同步参数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; + + @ApiProperty({ description: '包含关联数据', type: Boolean, required: false, default: false }) + @Rule(RuleType.boolean().optional()) + includeRelations?: boolean = false; +} + +/** + * 批量操作结果类(泛型支持) + */ +export class BatchOperationResultDTOGeneric extends BatchOperationResultDTO { + @ApiProperty({ description: '操作成功的数据列表', type: Array }) + data?: T[]; +} + +/** + * 同步操作结果类(泛型支持) + */ +export class SyncOperationResultDTOGeneric extends SyncOperationResultDTO { + @ApiProperty({ description: '同步成功的数据列表', type: Array }) + data?: T[]; +} \ No newline at end of file diff --git a/src/dto/customer.dto.ts b/src/dto/customer.dto.ts index 9fa62cd..8f56b5a 100644 --- a/src/dto/customer.dto.ts +++ b/src/dto/customer.dto.ts @@ -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; + +} \ No newline at end of file diff --git a/src/dto/reponse.dto.ts b/src/dto/reponse.dto.ts index 6180703..0db709f 100644 --- a/src/dto/reponse.dto.ts +++ b/src/dto/reponse.dto.ts @@ -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) {} //产品分页返回数据 diff --git a/src/entity/customer.entity.ts b/src/entity/customer.entity.ts index 0dcbd18..ae4fa04 100644 --- a/src/entity/customer.entity.ts +++ b/src/entity/customer.entity.ts @@ -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; } \ No newline at end of file diff --git a/src/interface.ts b/src/interface.ts index e000403..a133f62 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -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; // 当前页码 diff --git a/src/interface/site-adapter.interface.ts b/src/interface/site-adapter.interface.ts index 8872e3f..0ae0179 100644 --- a/src/interface/site-adapter.interface.ts +++ b/src/interface/site-adapter.interface.ts @@ -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; - batchProcessProducts?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + batchProcessProducts?(data: BatchOperationDTO): Promise; createOrder(data: Partial): Promise; updateOrder(id: string | number, data: Partial): Promise; deleteOrder(id: string | number): Promise; - batchProcessOrders?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + batchProcessOrders?(data: BatchOperationDTO): Promise; getCustomers(params: UnifiedSearchParamsDTO): Promise>; getCustomer(id: string | number): Promise; @@ -115,7 +116,7 @@ export interface ISiteAdapter { updateCustomer(id: string | number, data: Partial): Promise; deleteCustomer(id: string | number): Promise; - batchProcessCustomers?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + batchProcessCustomers?(data: BatchOperationDTO): Promise; /** * 获取webhooks列表 diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 12675e0..69a1495 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -23,6 +23,13 @@ export class AuthMiddleware implements IMiddleware { '/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) { diff --git a/src/service/customer.service.ts b/src/service/customer.service.ts index b039ecb..76bb4fb 100644 --- a/src/service/customer.service.ts +++ b/src/service/customer.service.ts @@ -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; - async getCustomerList(param: Record) { + @Inject() + siteApiService: SiteApiService; + + /** + * 根据邮箱查找客户 + */ + async findCustomerByEmail(email: string): Promise { + return await this.customerModel.findOne({ where: { email } }); + } + + /** + * 将站点客户数据映射为本地客户实体数据 + * 处理字段映射和数据转换,确保所有字段正确同步 + */ + private mapSiteCustomerToCustomer(siteCustomer: UnifiedCustomerDTO, siteId: number): Partial { + 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): Promise { + const customer = this.customerModel.create(customerData); + return await this.customerModel.save(customer); + } + + /** + * 更新客户信息 + */ + async updateCustomer(id: number, customerData: Partial): Promise { + await this.customerModel.update(id, customerData); + return await this.customerModel.findOne({ where: { id } }); + } + + /** + * 创建或更新客户(upsert) + * 如果客户存在则更新,不存在则创建 + */ + async upsertCustomer( + customerData: Partial, + ): 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> + ): 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 { + 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) { const { current = 1, pageSize = 10, @@ -148,6 +327,112 @@ export class CustomerService { }; } + /** + * 获取纯粹的客户列表(不包含订单统计信息) + * 支持基本的分页、搜索和排序功能 + * 使用TypeORM查询构建器实现 + */ + async getCustomerList(param: Record): Promise{ + 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 }); } -} +} \ No newline at end of file diff --git a/src/service/order.service.ts b/src/service/order.service.ts index 838969d..466e714 100644 --- a/src/service/order.service.ts +++ b/src/service/order.service.ts @@ -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, diff --git a/src/service/shopyy.service.ts b/src/service/shopyy.service.ts index d6eaa4b..900ba23 100644 --- a/src/service/shopyy.service.ts +++ b/src/service/shopyy.service.ts @@ -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 { + async batchProcessProducts(site: any, data: BatchOperationDTO): Promise { // 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 + }; } /** diff --git a/src/service/site.service.ts b/src/service/site.service.ts index 4585bf0..a8b8fe4 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -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; - 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 = { - 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 中分离出区域代码和其他站点数据 diff --git a/src/service/wp.service.ts b/src/service/wp.service.ts index 175d842..0c22b58 100644 --- a/src/service/wp.service.ts +++ b/src/service/wp.service.ts @@ -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 { @@ -79,11 +80,80 @@ export class WPService implements IPlatformService { /** * 通过 SDK 聚合分页数据,返回全部数据 + * 使用并发方式获取所有分页数据,提高性能 + * 默认按 date_created 倒序排列,确保获取最新的数据 */ - private async sdkGetAll(api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = 50): Promise { - // 直接传入较大的per_page参数,一次性获取所有数据 - const { items } = await this.sdkGetPage(api, resource, { ...params, per_page: 100 }); - return items; + private async sdkGetAll(api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = MAX_PAGE_SIZE): Promise { + return this.sdkGetAllConcurrent(api, resource, params, maxPages); + } + + /** + * 通过 SDK 聚合分页数据,使用并发方式获取所有分页数据 + * 支持自定义并发数和最大页数限制 + * 默认按 date_created 倒序排列,确保获取最新的数据 + */ + private async sdkGetAllConcurrent( + api: WooCommerceRestApi, + resource: string, + params: Record = {}, + maxPages: number = MAX_PAGE_SIZE, + concurrencyLimit: number = 5 + ): Promise { + // 设置默认排序为 date_created 倒序,确保获取最新数据 + const defaultParams = { + orderby: 'date_created', + order: 'desc', + per_page: MAX_PAGE_SIZE, + ...params + }; + + // 首先获取第一页数据,同时获取总页数信息 + const firstPage = await this.sdkGetPage(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[] = []; + const batchSize = Math.min(concurrencyLimit, actualMaxPages - currentPage + 1); + + // 创建当前批次的并发请求 + for (let i = 0; i < batchSize; i++) { + const page = currentPage + i; + const pagePromise = this.sdkGetPage(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 { + data: BatchOperationDTO + ): Promise { 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; diff --git a/src/service/wp_product.service.ts b/src/service/wp_product.service.ts index 24fbaea..6b900e2 100644 --- a/src/service/wp_product.service.ts +++ b/src/service/wp_product.service.ts @@ -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([