From ecd2a6712b773a123d43e9bac4678eda45e07251 Mon Sep 17 00:00:00 2001 From: zhuotianyuan Date: Tue, 6 Jan 2026 18:43:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(webhook):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9shoppy=E5=B9=B3=E5=8F=B0webhook=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在site.entity.ts中添加webhookUrl字段 - 在auth.middleware.ts中添加/shoppy路由到白名单 - 在webhook.controller.ts中实现shoppy平台webhook处理逻辑 --- src/controller/webhook.controller.ts | 90 +++++++++++++++++++++++++++- src/entity/site.entity.ts | 3 + src/middleware/auth.middleware.ts | 1 + 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/controller/webhook.controller.ts b/src/controller/webhook.controller.ts index 69a070d..bf45f32 100644 --- a/src/controller/webhook.controller.ts +++ b/src/controller/webhook.controller.ts @@ -13,9 +13,13 @@ import * as crypto from 'crypto'; import { SiteService } from '../service/site.service'; import { OrderService } from '../service/order.service'; +import { + UnifiedOrderDTO, +} from '../dto/site-api.dto'; + @Controller('/webhook') export class WebhookController { - private secret = 'YOONE24kd$kjcdjflddd'; + private secret = '$kjYOONE24kdcdjflddd'; // 平台服务保留按需注入 @@ -116,4 +120,88 @@ export class WebhookController { console.log(error); } } + + + + @Post('/shoppy') + async handleShoppyWebhook( + @Body() body: any, + @Query('siteId') siteIdStr: string, + @Query('signature') signature: string, + @Headers() header: any + ) { + const topic = header['x-oemsaas-event-type']; + // const source = header['x-oemsaas-shop-domain']; + const siteId = Number(siteIdStr); + const bodys = new UnifiedOrderDTO(); + Object.assign(bodys, body); + // 从数据库获取站点配置 + const site = await this.siteService.get(siteId, true); + + // if (!site || !source?.includes(site.websiteUrl)) { + if (!site) { + console.log('domain not match'); + return { + code: HttpStatus.BAD_REQUEST, + success: false, + message: 'domain not match', + }; + } + + if (!signature) { + return { + code: HttpStatus.BAD_REQUEST, + success: false, + message: 'Signature missing', + }; + } + + //shopyy 无法提供加密字段校验,注释校验逻辑 + // const rawBody = this.ctx.request.rawBody; + // const hash = crypto + // .createHmac('sha256', this.secret) + // .update(rawBody) + // .digest('base64'); + try { + if (this.secret === signature) { + switch (topic) { + case 'product.created': + case 'product.updated': + // 不再写入本地,平台事件仅确认接收 + break; + case 'product.deleted': + // 不再写入本地,平台事件仅确认接收 + break; + case 'orders/create': + case 'orders/update': + await this.orderService.syncSingleOrder(siteId, bodys); + break; + case 'orders/delete': + break; + case 'customer.created': + break; + case 'customer.updated': + break; + case 'customer.deleted': + break; + default: + console.log('Unhandled event:', topic); + } + + return { + code: 200, + success: true, + message: 'Webhook processed successfully', + }; + } else { + return { + code: 403, + success: false, + message: 'Webhook verification failed', + }; + } + } catch (error) { + console.log(error); + } + } } diff --git a/src/entity/site.entity.ts b/src/entity/site.entity.ts index a579a33..08b10a5 100644 --- a/src/entity/site.entity.ts +++ b/src/entity/site.entity.ts @@ -13,6 +13,9 @@ export class Site { @Column({ name: 'website_url', length: 255, nullable: true }) websiteUrl: string; + @Column({ name: 'webhook_url', length: 255, nullable: true }) + webhookUrl: string; + @Column({ length: 255, nullable: true }) consumerKey?: string; diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 69a1495..e0fd0cf 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -21,6 +21,7 @@ export class AuthMiddleware implements IMiddleware { whiteList = [ '/user/login', '/webhook/woocommerce', + '/webhook/shoppy', '/logistics/getTrackingNumber', '/logistics/getListByTrackingId', '/product/categories/all', -- 2.40.1 From 4af69aeb6fa1cc01fc098069482e1a0328b464e4 Mon Sep 17 00:00:00 2001 From: zhuotianyuan Date: Tue, 6 Jan 2026 18:57:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(webhook):=20=E6=9B=B4=E6=96=B0webhook?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=E4=B8=AD=E7=9A=84=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/webhook.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controller/webhook.controller.ts b/src/controller/webhook.controller.ts index bf45f32..41ef422 100644 --- a/src/controller/webhook.controller.ts +++ b/src/controller/webhook.controller.ts @@ -19,7 +19,7 @@ import { @Controller('/webhook') export class WebhookController { - private secret = '$kjYOONE24kdcdjflddd'; + private secret = 'YOONE24kd$kjcdjflddd'; // 平台服务保留按需注入 -- 2.40.1 From f2b1036286a2965c496e6a24f02b20b098e58447 Mon Sep 17 00:00:00 2001 From: zhuotianyuan Date: Tue, 6 Jan 2026 19:00:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(entity):=20=E5=B0=86=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E5=AD=97=E6=AE=B5=E6=98=8E=E7=A1=AE=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=80=89=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entity/site.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/entity/site.entity.ts b/src/entity/site.entity.ts index 08b10a5..56fce60 100644 --- a/src/entity/site.entity.ts +++ b/src/entity/site.entity.ts @@ -11,10 +11,10 @@ export class Site { apiUrl: string; @Column({ name: 'website_url', length: 255, nullable: true }) - websiteUrl: string; + websiteUrl?: string; @Column({ name: 'webhook_url', length: 255, nullable: true }) - webhookUrl: string; + webhookUrl?: string; @Column({ length: 255, nullable: true }) consumerKey?: string; -- 2.40.1 From 837254159a3b3455dbc754ecf45a95cf74f297a1 Mon Sep 17 00:00:00 2001 From: zhuotianyuan Date: Wed, 7 Jan 2026 11:54:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(adapter):=20=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E6=96=B9=E6=B3=95=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8E=A5=E5=8F=A3=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将各适配器中的私有映射方法改为公开,并在接口中定义统一方法签名 修改webhook控制器以使用适配器映射方法处理订单数据 --- src/adapter/shopyy.adapter.ts | 18 ++--- src/adapter/woocommerce.adapter.ts | 22 +++--- src/controller/webhook.controller.ts | 97 +++++++++++-------------- src/interface/site-adapter.interface.ts | 6 ++ 4 files changed, 69 insertions(+), 74 deletions(-) diff --git a/src/adapter/shopyy.adapter.ts b/src/adapter/shopyy.adapter.ts index 33d1e61..10c51fb 100644 --- a/src/adapter/shopyy.adapter.ts +++ b/src/adapter/shopyy.adapter.ts @@ -50,7 +50,7 @@ export class ShopyyAdapter implements ISiteAdapter { // this.mapSubscription = this.mapSubscription.bind(this); } - private mapMedia(item: any): UnifiedMediaDTO { + mapMedia(item: any): UnifiedMediaDTO { // 映射媒体项目 return { id: item.id, @@ -63,7 +63,7 @@ export class ShopyyAdapter implements ISiteAdapter { }; } - private mapMediaSearchParams(params: UnifiedSearchParamsDTO): any { + mapMediaSearchParams(params: UnifiedSearchParamsDTO): any { const { search, page, per_page } = params; const shopyyParams: any = { page: page || 1, @@ -77,7 +77,7 @@ export class ShopyyAdapter implements ISiteAdapter { return shopyyParams; } - private mapProduct(item: ShopyyProduct & { permalink?: string }): UnifiedProductDTO { + mapProduct(item: ShopyyProduct & { permalink?: string }): UnifiedProductDTO { // 映射产品状态 function mapProductStatus(status: number) { return status === 1 ? 'publish' : 'draft'; @@ -129,7 +129,7 @@ export class ShopyyAdapter implements ISiteAdapter { }; } - private mapVariation(variant: ShopyyVariant): UnifiedProductVariationDTO { + mapVariation(variant: ShopyyVariant): UnifiedProductVariationDTO { // 映射变体 return { id: variant.id, @@ -152,7 +152,7 @@ export class ShopyyAdapter implements ISiteAdapter { [180]: OrderStatus.COMPLETED, // 180 已完成(确认收货) 转为 completed [190]: OrderStatus.CANCEL // 190 取消 转为 cancelled } - private mapOrder(item: ShopyyOrder): UnifiedOrderDTO { + mapOrder(item: ShopyyOrder): UnifiedOrderDTO { // 提取账单和送货地址 如果不存在则为空对象 const billing = (item as any).billing_address || {}; const shipping = (item as any).shipping_address || {}; @@ -333,7 +333,7 @@ export class ShopyyAdapter implements ISiteAdapter { // 确认发货 } - private mapCustomer(item: ShopyyCustomer): UnifiedCustomerDTO { + mapCustomer(item: ShopyyCustomer): UnifiedCustomerDTO { // 处理多地址结构 const addresses = item.addresses || []; const defaultAddress = item.default_address || (addresses.length > 0 ? addresses[0] : {}); @@ -726,7 +726,7 @@ export class ShopyyAdapter implements ISiteAdapter { return this.mapReview(review); } - private mapReview(review: any): UnifiedReviewDTO { + mapReview(review: any): UnifiedReviewDTO { // 将ShopYY评论数据映射到统一评论DTO格式 return { id: review.id || review.review_id, @@ -743,7 +743,7 @@ export class ShopyyAdapter implements ISiteAdapter { }; } - private mapReviewSearchParams(params: UnifiedSearchParamsDTO): any { + mapReviewSearchParams(params: UnifiedSearchParamsDTO): any { const { search, page, per_page, where } = params; const shopyyParams: any = { page: page || 1, @@ -780,7 +780,7 @@ export class ShopyyAdapter implements ISiteAdapter { } // Webhook相关方法 - private mapWebhook(item: ShopyyWebhook): UnifiedWebhookDTO { + mapWebhook(item: ShopyyWebhook): UnifiedWebhookDTO { return { id: item.id, name: item.webhook_name || `Webhook-${item.id}`, diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts index badf467..c407780 100644 --- a/src/adapter/woocommerce.adapter.ts +++ b/src/adapter/woocommerce.adapter.ts @@ -42,7 +42,7 @@ export class WooCommerceAdapter implements ISiteAdapter { } // 映射 WooCommerce webhook 到统一格式 - private mapWebhook(webhook: WooWebhook): UnifiedWebhookDTO { + mapWebhook(webhook: WooWebhook): UnifiedWebhookDTO { return { id: webhook.id.toString(), name: webhook.name, @@ -169,7 +169,7 @@ export class WooCommerceAdapter implements ISiteAdapter { - private mapProductSearchParams(params: UnifiedSearchParamsDTO): Partial { + mapProductSearchParams(params: UnifiedSearchParamsDTO): Partial { const page = Number(params.page ?? 1); const per_page = Number(params.per_page ?? 20); const where = params.where && typeof params.where === 'object' ? params.where : {}; @@ -225,7 +225,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return mapped; } - private mapOrderSearchParams(params: UnifiedSearchParamsDTO): Partial { + mapOrderSearchParams(params: UnifiedSearchParamsDTO): Partial { // 计算分页参数 const page = Number(params.page ?? 1); const per_page = Number(params.per_page ?? 20); @@ -293,7 +293,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return mapped; } - private mapCustomerSearchParams(params: UnifiedSearchParamsDTO): Record { + mapCustomerSearchParams(params: UnifiedSearchParamsDTO): Record { const page = Number(params.page ?? 1); const per_page = Number(params.per_page ?? 20); const where = params.where && typeof params.where === 'object' ? params.where : {}; @@ -346,7 +346,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return mapped; } - private mapProduct(item: WooProduct): UnifiedProductDTO { + mapProduct(item: WooProduct): UnifiedProductDTO { // 将 WooCommerce 产品数据映射为统一产品DTO // 保留常用字段与时间信息以便前端统一展示 // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#product-properties @@ -449,7 +449,7 @@ export class WooCommerceAdapter implements ISiteAdapter { addr.phone ].filter(Boolean).join(', '); } - private mapOrder(item: WooOrder): UnifiedOrderDTO { + mapOrder(item: WooOrder): UnifiedOrderDTO { // 将 WooCommerce 订单数据映射为统一订单DTO // 包含账单地址与收货地址以及创建与更新时间 @@ -502,7 +502,7 @@ export class WooCommerceAdapter implements ISiteAdapter { }; } - private mapSubscription(item: WooSubscription): UnifiedSubscriptionDTO { + mapSubscription(item: WooSubscription): UnifiedSubscriptionDTO { // 将 WooCommerce 订阅数据映射为统一订阅DTO // 若缺少创建时间则回退为开始时间 return { @@ -520,7 +520,7 @@ export class WooCommerceAdapter implements ISiteAdapter { }; } - private mapMedia(item: WpMedia): UnifiedMediaDTO { + mapMedia(item: WpMedia): UnifiedMediaDTO { // 将 WordPress 媒体数据映射为统一媒体DTO // 兼容不同字段命名的时间信息 return { @@ -866,7 +866,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return media.map((mediaItem: any) => this.mapMedia(mediaItem)); } - private mapReview(item: any): UnifiedReviewDTO & { raw: any } { + mapReview(item: any): UnifiedReviewDTO & { raw: any } { // 将 WooCommerce 评论数据映射为统一评论DTO return { id: item.id, @@ -939,7 +939,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return result as any; } - private mapCustomer(item: WooCustomer): UnifiedCustomerDTO { + mapCustomer(item: WooCustomer): UnifiedCustomerDTO { // 将 WooCommerce 客户数据映射为统一客户DTO // 包含基础信息地址信息与时间信息 return { @@ -1070,7 +1070,7 @@ export class WooCommerceAdapter implements ISiteAdapter { } // 映射 WooCommerce 变体到统一格式 - private mapVariation(variation: any, productName?: string): UnifiedProductVariationDTO { + mapVariation(variation: any, productName?: string): UnifiedProductVariationDTO { // 将变体属性转换为统一格式 const mappedAttributes = variation.attributes && Array.isArray(variation.attributes) ? variation.attributes.map((attr: any) => ({ diff --git a/src/controller/webhook.controller.ts b/src/controller/webhook.controller.ts index 41ef422..37fcc5a 100644 --- a/src/controller/webhook.controller.ts +++ b/src/controller/webhook.controller.ts @@ -9,13 +9,10 @@ import { } from '@midwayjs/decorator'; import { Context } from '@midwayjs/koa'; import * as crypto from 'crypto'; - + import { SiteService } from '../service/site.service'; import { OrderService } from '../service/order.service'; - -import { - UnifiedOrderDTO, -} from '../dto/site-api.dto'; +import { SiteApiService } from '../service/site-api.service'; @Controller('/webhook') export class WebhookController { @@ -31,9 +28,11 @@ export class WebhookController { @Logger() logger: ILogger; - + @Inject() private readonly siteService: SiteService; + @Inject() + private readonly siteApiService: SiteApiService; // 移除配置中的站点数组,来源统一改为数据库 @@ -49,7 +48,7 @@ export class WebhookController { @Query('siteId') siteIdStr: string, @Headers() header: any ) { - console.log(`webhook woocommerce`, siteIdStr, body,header) + console.log(`webhook woocommerce`, siteIdStr, body, header) const signature = header['x-wc-webhook-signature']; const topic = header['x-wc-webhook-topic']; const source = header['x-wc-webhook-source']; @@ -79,43 +78,44 @@ export class WebhookController { .update(rawBody) .digest('base64'); try { - if (hash === signature) { - switch (topic) { - case 'product.created': - case 'product.updated': - // 不再写入本地,平台事件仅确认接收 - break; - case 'product.deleted': - // 不再写入本地,平台事件仅确认接收 - break; - case 'order.created': - case 'order.updated': - await this.orderService.syncSingleOrder(siteId, body); - break; - case 'order.deleted': - break; - case 'customer.created': - break; - case 'customer.updated': - break; - case 'customer.deleted': - break; - default: - console.log('Unhandled event:', body.event); - } - - return { - code: 200, - success: true, - message: 'Webhook processed successfully', - }; - } else { + if (hash !== signature) { return { code: 403, success: false, message: 'Webhook verification failed', }; } + const adapter = await this.siteApiService.getAdapter(siteId); + switch (topic) { + case 'product.created': + case 'product.updated': + // 不再写入本地,平台事件仅确认接收 + break; + case 'product.deleted': + // 不再写入本地,平台事件仅确认接收 + break; + case 'order.created': + case 'order.updated': + const order = adapter.mapOrder(body) + await this.orderService.syncSingleOrder(siteId, order); + break; + case 'order.deleted': + break; + case 'customer.created': + break; + case 'customer.updated': + break; + case 'customer.deleted': + break; + default: + console.log('Unhandled event:', body.event); + + return { + code: 200, + success: true, + message: 'Webhook processed successfully', + }; + } } catch (error) { console.log(error); } @@ -130,23 +130,10 @@ export class WebhookController { @Query('signature') signature: string, @Headers() header: any ) { + console.log(`webhook shoppy`, siteIdStr, body, header) const topic = header['x-oemsaas-event-type']; - // const source = header['x-oemsaas-shop-domain']; + // const source = header['x-oemsaas-shop-domain']; const siteId = Number(siteIdStr); - const bodys = new UnifiedOrderDTO(); - Object.assign(bodys, body); - // 从数据库获取站点配置 - const site = await this.siteService.get(siteId, true); - - // if (!site || !source?.includes(site.websiteUrl)) { - if (!site) { - console.log('domain not match'); - return { - code: HttpStatus.BAD_REQUEST, - success: false, - message: 'domain not match', - }; - } if (!signature) { return { @@ -162,6 +149,7 @@ export class WebhookController { // .createHmac('sha256', this.secret) // .update(rawBody) // .digest('base64'); + const adapter = await this.siteApiService.getAdapter(siteId); try { if (this.secret === signature) { switch (topic) { @@ -174,7 +162,8 @@ export class WebhookController { break; case 'orders/create': case 'orders/update': - await this.orderService.syncSingleOrder(siteId, bodys); + const order = adapter.mapOrder(body) + await this.orderService.syncSingleOrder(siteId, order); break; case 'orders/delete': break; diff --git a/src/interface/site-adapter.interface.ts b/src/interface/site-adapter.interface.ts index 2e02cc3..9baf0c7 100644 --- a/src/interface/site-adapter.interface.ts +++ b/src/interface/site-adapter.interface.ts @@ -20,6 +20,12 @@ import { UnifiedPaginationDTO, UnifiedSearchParamsDTO } from '../dto/api.dto'; import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto'; export interface ISiteAdapter { + mapOrder(order: any): UnifiedOrderDTO; + mapWebhook(webhook:any):UnifiedWebhookDTO; + mapProduct(product:any): UnifiedProductDTO; + mapReview(data: any): UnifiedReviewDTO; + mapCustomer(data: any): UnifiedCustomerDTO; + mapMedia(data: any): UnifiedMediaDTO; /** * 获取产品列表 */ -- 2.40.1