From e9ddf2d9242ac0b757243cb89bb7c939876b919d Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Sun, 5 Jul 2026 23:55:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(lakala):=20=E6=8B=89=E5=8D=A1=E6=8B=89?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=95=86=E9=80=9A=E9=81=93=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?(=E8=BF=9B=E4=BB=B6/=E6=94=AF=E4=BB=98/=E9=80=80=E6=AC=BE/?= =?UTF-8?q?=E5=90=8C=E6=AD=A5/=E5=9B=9E=E8=B0=83)=20+=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=A1=A8=20+=20=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完整服务商(ISV)模式: 密钥配置/通道商户绑定/进件 - 支付能力: 下单/关单/退款/退款同步/订单同步, 通过声明式HTTP客户端转发子应用 - 回调验签解析, 敏感字段加密存储 - LakalaProductStrategy isTerminal 调整为 false(服务商模式无独立终端) - channel/error 中英文补充拉卡拉错误消息 - update-datas 新增拉卡拉服务商配置菜单(40506) --- _config/sql/update-datas.sql | 64 ++---- .../lakala/client/LakalaChannelClient.java | 45 +++++ .../credential/LakalaSdkCredential.java | 26 +++ .../client/enums/LakalaPayBodyType.java | 13 ++ .../lakala/client/enums/LakalaPayMethod.java | 9 + .../lakala/client/req/LakalaCloseReq.java | 20 ++ .../lakala/client/req/LakalaPayReq.java | 55 ++++++ .../lakala/client/req/LakalaRefundReq.java | 29 +++ .../client/req/LakalaRefundSyncReq.java | 17 ++ .../lakala/client/req/LakalaSyncReq.java | 17 ++ .../lakala/client/resp/LakalaCloseResp.java | 8 + .../lakala/client/resp/LakalaPayResp.java | 21 ++ .../lakala/client/resp/LakalaRefundResp.java | 15 ++ .../client/resp/LakalaRefundSyncResp.java | 17 ++ .../lakala/client/resp/LakalaSyncResp.java | 19 ++ .../lakala/config/LakalaClientConfig.java | 35 ++++ .../callback/LakalaCallbackController.java | 34 ++++ .../LakalaIsvChannelMerchantController.java | 47 +++++ .../isv/LakalaIsvKeyConfigController.java | 48 +++++ .../isv/LakalaIsvChannelMerchantConvert.java | 16 ++ .../isv/LakalaIsvKeyConfigConvert.java | 28 +++ .../isv/LakalaIsvChannelMerchantManager.java | 28 +++ .../isv/LakalaIsvChannelMerchantMapper.java | 11 ++ .../dao/isv/LakalaIsvKeyConfigManager.java | 22 +++ .../dao/isv/LakalaIsvKeyConfigMapper.java | 11 ++ .../entity/isv/LakalaIsvChannelMerchant.java | 41 ++++ .../lakala/entity/isv/LakalaIsvKeyConfig.java | 57 ++++++ .../LakalaIsvChannelMerchantCreateParam.java | 38 ++++ .../param/isv/LakalaIsvKeyConfigParam.java | 37 ++++ .../isv/LakalaIsvChannelMerchantResult.java | 28 +++ .../result/isv/LakalaIsvKeyConfigResult.java | 41 ++++ .../callback/LakalaPayCallbackService.java | 185 ++++++++++++++++++ .../isv/LakalaIsvChannelMerchantService.java | 71 +++++++ .../service/isv/LakalaIsvConfigAssembler.java | 59 ++++++ .../isv/LakalaIsvKeyConfigService.java | 61 ++++++ .../payment/isv/LakalaIsvCloseService.java | 46 +++++ .../payment/isv/LakalaIsvPayService.java | 141 +++++++++++++ .../payment/isv/LakalaIsvRefundService.java | 57 ++++++ .../isv/LakalaIsvRefundSyncService.java | 62 ++++++ .../payment/isv/LakalaIsvSyncService.java | 72 +++++++ .../strategy/LakalaProductStrategy.java | 2 +- .../strategy/isv/LakalaIsvCloseStrategy.java | 49 +++++ .../strategy/isv/LakalaIsvPayStrategy.java | 50 +++++ .../strategy/isv/LakalaIsvRefundStrategy.java | 38 ++++ .../isv/LakalaIsvSyncRefundStrategy.java | 38 ++++ .../strategy/isv/LakalaIsvSyncStrategy.java | 45 +++++ .../resources/i18n/en-US/channel/error.json | 9 +- .../resources/i18n/zh-CN/channel/error.json | 9 +- 48 files changed, 1840 insertions(+), 51 deletions(-) create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/credential/LakalaSdkCredential.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayBodyType.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayMethod.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCloseReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaPayReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundSyncReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaSyncReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCloseResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaPayResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundSyncResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaSyncResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/config/LakalaClientConfig.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvChannelMerchantController.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvKeyConfigController.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvChannelMerchantConvert.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvKeyConfigConvert.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantManager.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantMapper.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigManager.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigMapper.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvChannelMerchant.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvKeyConfig.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvChannelMerchantCreateParam.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvKeyConfigParam.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvChannelMerchantResult.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvKeyConfigResult.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvChannelMerchantService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvConfigAssembler.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvKeyConfigService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvCloseService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvPayService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundSyncService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvSyncService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvCloseStrategy.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvPayStrategy.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvRefundStrategy.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncRefundStrategy.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncStrategy.java diff --git a/_config/sql/update-datas.sql b/_config/sql/update-datas.sql index a2a2438e1..4c168ce10 100644 --- a/_config/sql/update-datas.sql +++ b/_config/sql/update-datas.sql @@ -1,51 +1,19 @@ --- 商户应用事件通知配置: 菜单(权限码 merchant:notify_config) -INSERT INTO "public"."iam_perm_menu" VALUES (4040118, 40401, 'merchant:notify_config', 'admin', 'MchAppNotifyConfig', '通知配置', 'Notify Config', 'menu.payment.merchant.app.notifyConfig', NULL, 't', 'f', '/payment/merchant/app/MchAppNotifyConfig', '/payment/merchant/app/notify-config', NULL, 11, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-02 16:00:00+00', '2026-07-02 16:00:00+00'); - --- 退款订单: 菜单(权限码 payment:refund) -INSERT INTO "public"."iam_perm_menu" VALUES (603, 6, 'payment:refund', 'admin', 'RefundOrderList', '退款订单', 'Refund Orders', 'menu.trade.refundOrder', 'lucide:rotate-ccw', 'f', 'f', '/payment/order/RefundOrderList', '/trade/refund-order', NULL, 3, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-03 16:00:00+00', '2026-07-03 16:00:00+00'); - --- 微信服务商通道商户应用(子商户应用): 菜单(权限码 channel:wechat:app) -INSERT INTO "public"."iam_perm_menu" VALUES (4040119, 40401, 'channel:wechat:app', 'admin', 'WechatIsvMchAppManage', '微信服务商通道商户应用', 'WeChat ISV Channel Merchant App', 'menu.payment.merchant.channelMerchant.wechatIsvMchApp', NULL, 't', 'f', '/payment/channel/wechat/manage/mch/isv-app/WechatIsvMchAppManage', '/payment/merchant/channel-merchant/wechat-isv-mch-app-manage', NULL, 14, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 00:00:00+00', '2026-07-05 00:00:00+00'); - -- ============================================================ --- 菜单标题与 i18n_key 统一重命名 +-- 拉卡拉服务商配置菜单 + 权限挂载点 -- ============================================================ --- 支付产品详情 → 产品配置详情 -UPDATE "public"."iam_perm_menu" SET title_cn='产品配置详情', title_en='Product Config Detail', - i18n_key='menu.payment.config.detail' WHERE id=40501; --- 支付宝服务商应用: i18n_key 归入 config.* -UPDATE "public"."iam_perm_menu" SET i18n_key='menu.payment.config.alipayApp' WHERE id=40502; --- 微信服务商应用: i18n_key 归入 config.* -UPDATE "public"."iam_perm_menu" SET i18n_key='menu.payment.config.wechatApp' WHERE id=40503; - --- 商户信息(列表入口) → 商户列表 -UPDATE "public"."iam_perm_menu" SET title_cn='商户列表', title_en='Merchant List', - i18n_key='menu.payment.merchant.list' WHERE id=40401; --- 商户管理(工作台) → 商户工作台 -UPDATE "public"."iam_perm_menu" SET title_cn='商户工作台', title_en='Merchant Workbench', - i18n_key='menu.payment.merchant.workbench' WHERE id=4040101; --- 对接配置: i18n_key 去掉中间 manage 层 -UPDATE "public"."iam_perm_menu" SET i18n_key='menu.payment.merchant.credential' WHERE id=4040102; --- 商户信息(详情) → 商户资料 -UPDATE "public"."iam_perm_menu" SET title_cn='商户资料', title_en='Merchant Profile', - i18n_key='menu.payment.merchant.profile' WHERE id=4040103; --- 通道路由: i18n_key 从 app.* 移出(实为商户级配置) -UPDATE "public"."iam_perm_menu" SET i18n_key='menu.payment.merchant.payRoute' WHERE id=4040111; --- 通道商户详情 → 通道商户管理(静态通用名) -UPDATE "public"."iam_perm_menu" SET title_cn='通道商户管理', title_en='Channel Merchant Management' - WHERE id=4040112; --- 微信通道商户应用 → 微信应用 -UPDATE "public"."iam_perm_menu" SET title_cn='微信应用', title_en='WeChat App' WHERE id=4040113; --- 支付宝通道商户应用 → 支付宝应用 -UPDATE "public"."iam_perm_menu" SET title_cn='支付宝应用', title_en='Alipay App' WHERE id=4040114; --- 抖音通道商户应用 → 抖音应用 -UPDATE "public"."iam_perm_menu" SET title_cn='抖音应用', title_en='Douyin App' WHERE id=4040115; --- 微信服务商通道商户应用 → 微信服务商子商户应用 -UPDATE "public"."iam_perm_menu" SET title_cn='微信服务商子商户应用', title_en='WeChat ISV Sub-merchant App', - i18n_key='menu.payment.merchant.channelMerchant.wechatIsvApp' WHERE id=4040119; - --- 移动端应用管理(平台级, 商户端/管理端/收银台应用配置) -INSERT INTO "public"."iam_perm_menu" VALUES (40504, 405, 'payment:config:mobile_app', 'admin', 'MobileAppConfig', '移动端应用管理', 'Mobile App Management', 'menu.payment.config.mobileApp', 'lucide:smartphone', 'f', 'f', '/payment/config/mobileApp/MobileAppConfig', '/payment/config/mobile-app', NULL, 5, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 00:00:00+00', '2026-07-05 00:00:00+00'); --- 移动端应用详情(按端类型配置各平台, 隐藏子页) -INSERT INTO "public"."iam_perm_menu" VALUES (40505, 40504, 'payment:config:mobile_app_detail', 'admin', 'MobileAppDetail', '移动端应用详情', 'Mobile App Detail', 'menu.payment.config.mobileAppDetail', NULL, 't', 'f', '/payment/config/mobileApp/detail/MobileAppDetail', '/payment/config/mobile-app/detail/:appType', NULL, 1, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 00:00:00+00', '2026-07-05 00:00:00+00'); +-- 拉卡拉服务商配置入口(通过产品配置列表点击"拉卡拉"跳转 ProductDetailDispatch 分发到 LakalaManage) +-- 同时作为 @PermCode(menuCode="payment:lakala:isv") 的权限挂载点 +-- parent_id=40105(支付产品管理), 与微信服务商(40503)/支付宝服务商同级 +INSERT INTO "public"."iam_perm_menu" VALUES ( + 40506, 40105, + 'payment:lakala:isv', 'admin', + 'LakalaManage', + '拉卡拉服务商配置', 'Lakala ISV Configuration', + 'menu.payment.lakala.config', + NULL, 't', 'f', + '/payment/channel/lakala/manage/LakalaManage', '/payment/config/product/lakala-manage', + NULL, 5, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', + NULL, NULL, NULL, NULL, NULL, NULL, + '2026-07-05 00:00:00+00', '2026-07-05 00:00:00+00' +) ON CONFLICT (id) DO NOTHING; diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java new file mode 100644 index 000000000..bb0ce0d08 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java @@ -0,0 +1,45 @@ +package cn.daxpay.open.channel.lakala.client; + +import cn.daxpay.open.channel.lakala.client.req.LakalaCloseReq; +import cn.daxpay.open.channel.lakala.client.req.LakalaPayReq; +import cn.daxpay.open.channel.lakala.client.req.LakalaRefundReq; +import cn.daxpay.open.channel.lakala.client.req.LakalaRefundSyncReq; +import cn.daxpay.open.channel.lakala.client.req.LakalaSyncReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaCloseResp; +import cn.daxpay.open.channel.lakala.client.resp.LakalaPayResp; +import cn.daxpay.open.channel.lakala.client.resp.LakalaRefundResp; +import cn.daxpay.open.channel.lakala.client.resp.LakalaRefundSyncResp; +import cn.daxpay.open.channel.lakala.client.resp.LakalaSyncResp; +import cn.daxpay.open.payment.common.result.DaxResult; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +/// # 拉卡拉通道客户端 +/// +/// 声明式 HTTP 接口, 调用子应用 dax-pay-channel-one 的拉卡拉通道接口。 +/// 拉卡拉为聚合服务商模式, 不区分直连/服务商, 路径前缀 `/channel/lakala`。 +@HttpExchange(accept = MediaType.APPLICATION_JSON_VALUE, contentType = MediaType.APPLICATION_JSON_VALUE) +public interface LakalaChannelClient { + + /// 支付下单 + @PostExchange("/channel/lakala/pay") + DaxResult pay(@RequestBody LakalaPayReq req); + + /// 支付同步(查询拉卡拉订单状态) + @PostExchange("/channel/lakala/sync") + DaxResult sync(@RequestBody LakalaSyncReq req); + + /// 关闭订单 + @PostExchange("/channel/lakala/close") + DaxResult close(@RequestBody LakalaCloseReq req); + + /// 退款 + @PostExchange("/channel/lakala/refund") + DaxResult refund(@RequestBody LakalaRefundReq req); + + /// 退款同步(查询退款状态) + @PostExchange("/channel/lakala/refund-sync") + DaxResult refundSync(@RequestBody LakalaRefundSyncReq req); +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/credential/LakalaSdkCredential.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/credential/LakalaSdkCredential.java new file mode 100644 index 000000000..003f23041 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/credential/LakalaSdkCredential.java @@ -0,0 +1,26 @@ +package cn.daxpay.open.channel.lakala.client.credential; + +import lombok.Data; + +/// # 拉卡拉 SDK 凭证(主应用侧) +/// +/// 与子应用 dax-pay-channel-one 的 `LakalaSdkCredential` 镜像, 跨 HTTP 传输时字段对齐。 +/// 主应用从服务商密钥配置(LakalaIsvKeyConfig) + 通道商户绑定(LakalaIsvChannelMerchant) 组装, +/// 下发给子应用发起拉卡拉 API 调用。 +@Data +public class LakalaSdkCredential { + /// 拉卡拉应用编号(lkl_app_id) + private String lklAppId; + /// 商户证书序列号 + private String mchSerialNo; + /// 商户RSA私钥(PEM 格式 PKCS#8 字符串) + private String privateKey; + /// 拉卡拉RSA公钥证书(PEM 格式, 用于响应验签) + private String publicKey; + /// 拉卡拉商户编号(merchantNo) + private String lakalaMchNo; + /// 终端号(termNo) + private String termNo; + /// 是否沙箱环境 + private Boolean sandbox; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayBodyType.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayBodyType.java new file mode 100644 index 000000000..bf3aef08e --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayBodyType.java @@ -0,0 +1,13 @@ +package cn.daxpay.open.channel.lakala.client.enums; + +/// # 拉卡拉支付内容类型(主应用侧, 与子应用镜像) +public enum LakalaPayBodyType { + /// 跳转链接 + LINK, + /// 二维码内容 + QR_CODE, + /// JSAPI/小程序调起参数 JSON + JSAPI, + /// 通用标识码 + IDENTIFIER; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayMethod.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayMethod.java new file mode 100644 index 000000000..871edc4cc --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/enums/LakalaPayMethod.java @@ -0,0 +1,9 @@ +package cn.daxpay.open.channel.lakala.client.enums; + +/// # 拉卡拉支付方式(主应用侧, 与子应用镜像) +public enum LakalaPayMethod { + /// 条码支付(付款码被扫) + MICROPAY, + /// 预下单(扫码/JSAPI/APP/小程序) + PREORDER; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCloseReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCloseReq.java new file mode 100644 index 000000000..f59e0b731 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCloseReq.java @@ -0,0 +1,20 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import lombok.Data; + +/// # 拉卡拉通道关单请求(主应用侧, 与子应用镜像) +@Data +public class LakalaCloseReq { + + private LakalaSdkCredential credential; + + /// 原商户订单号(与 originTradeNo 二选一) + private String originOutTradeNo; + + /// 原拉卡拉交易号(与 originOutTradeNo 二选一) + private String originTradeNo; + + /// 客户端IP + private String clientIp; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaPayReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaPayReq.java new file mode 100644 index 000000000..a410dc7da --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaPayReq.java @@ -0,0 +1,55 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.enums.LakalaPayBodyType; +import cn.daxpay.open.channel.lakala.client.enums.LakalaPayMethod; +import lombok.Data; + +import java.time.OffsetDateTime; + +/// # 拉卡拉通道支付请求(主应用侧, 与子应用镜像) +@Data +public class LakalaPayReq { + + /// 通道调用凭证 + private LakalaSdkCredential credential; + + /// 商户订单号(主应用支付交易号) + private String outTradeNo; + + /// 订单金额(单位: 分) + private Long amount; + + /// 商品标题 + private String title; + + /// 商品描述 + private String description; + + /// 支付方式(MICROPAY / PREORDER) + private LakalaPayMethod method; + + /// 账户类型(PREORDER 必填: WECHAT / ALIPAY / UQRCODEPAY) + private String accountType; + + /// 交易类型(PREORDER 必填: 41扫码 / 51 JSAPI / 61 APP / 71 小程序) + private String transType; + + /// 用户标识(JSAPI/MINI 必填) + private String openId; + + /// 付款码(MICROPAY 必填) + private String authCode; + + /// 客户端IP + private String clientIp; + + /// 异步通知地址 + private String notifyUrl; + + /// 订单过期时间 + private OffsetDateTime expireTime; + + /// 支付内容类型(主应用预先计算, 子应用透传到响应) + private LakalaPayBodyType payBodyType; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundReq.java new file mode 100644 index 000000000..68c9225bb --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundReq.java @@ -0,0 +1,29 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import lombok.Data; + +/// # 拉卡拉通道退款请求(主应用侧, 与子应用镜像) +@Data +public class LakalaRefundReq { + + private LakalaSdkCredential credential; + + /// 商户退款单号 + private String outRefundNo; + + /// 原商户订单号 + private String originOutTradeNo; + + /// 原拉卡拉交易号 + private String originTradeNo; + + /// 退款金额(单位: 分) + private Long amount; + + /// 退款原因 + private String reason; + + /// 客户端IP + private String clientIp; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundSyncReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundSyncReq.java new file mode 100644 index 000000000..9a8f508be --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaRefundSyncReq.java @@ -0,0 +1,17 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import lombok.Data; + +/// # 拉卡拉通道退款查询请求(主应用侧, 与子应用镜像) +@Data +public class LakalaRefundSyncReq { + + private LakalaSdkCredential credential; + + /// 商户退款单号(与 originTradeNo 二选一) + private String outRefundNo; + + /// 原拉卡拉退款交易号(与 outRefundNo 二选一) + private String originTradeNo; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaSyncReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaSyncReq.java new file mode 100644 index 000000000..590a93a76 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaSyncReq.java @@ -0,0 +1,17 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import lombok.Data; + +/// # 拉卡拉通道订单查询请求(主应用侧, 与子应用镜像) +@Data +public class LakalaSyncReq { + + private LakalaSdkCredential credential; + + /// 商户订单号(与 tradeNo 二选一) + private String outTradeNo; + + /// 拉卡拉交易号(与 outTradeNo 二选一) + private String tradeNo; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCloseResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCloseResp.java new file mode 100644 index 000000000..6a62e8c67 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCloseResp.java @@ -0,0 +1,8 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import lombok.Data; + +/// # 拉卡拉通道关单响应(主应用侧, 与子应用镜像) +@Data +public class LakalaCloseResp { +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaPayResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaPayResp.java new file mode 100644 index 000000000..86600c911 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaPayResp.java @@ -0,0 +1,21 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import cn.daxpay.open.channel.lakala.client.enums.LakalaPayBodyType; +import lombok.Data; + +import java.time.OffsetDateTime; + +/// # 拉卡拉通道支付响应(主应用侧, 与子应用镜像) +@Data +public class LakalaPayResp { + + private String outTradeNo; + private String tradeNo; + private String payBody; + private LakalaPayBodyType payBodyType; + private Boolean complete; + private Long totalAmount; + private Long payerAmount; + private OffsetDateTime finishTime; + private String buyerId; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundResp.java new file mode 100644 index 000000000..df8a21729 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundResp.java @@ -0,0 +1,15 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/// # 拉卡拉通道退款响应(主应用侧, 与子应用镜像) +@Data +public class LakalaRefundResp { + + private String outRefundNo; + private String tradeNo; + private Boolean complete; + private OffsetDateTime finishTime; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundSyncResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundSyncResp.java new file mode 100644 index 000000000..0f4b6dec5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaRefundSyncResp.java @@ -0,0 +1,17 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/// # 拉卡拉通道退款查询响应(主应用侧, 与子应用镜像) +@Data +public class LakalaRefundSyncResp { + + private String outRefundNo; + private String tradeNo; + /// 退款状态: SUCCESS / FAIL / PROCESSING + private String refundStatus; + private OffsetDateTime finishTime; + private String syncData; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaSyncResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaSyncResp.java new file mode 100644 index 000000000..04c115319 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaSyncResp.java @@ -0,0 +1,19 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/// # 拉卡拉通道订单查询响应(主应用侧, 与子应用镜像) +@Data +public class LakalaSyncResp { + + private String outTradeNo; + private String tradeNo; + /// 交易状态: SUCCESS / FAIL / CLOSED / 其他(处理中) + private String tradeState; + private Long totalAmount; + private String buyerId; + private OffsetDateTime finishTime; + private String syncData; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/config/LakalaClientConfig.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/config/LakalaClientConfig.java new file mode 100644 index 000000000..a04fd45f4 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/config/LakalaClientConfig.java @@ -0,0 +1,35 @@ +package cn.daxpay.open.channel.lakala.config; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/// # 拉卡拉通道客户端配置 +/// +/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装: +/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池), +/// 经 `mutate()` 派生出绑定 channel-one baseUrl 的实例。 +/// +/// baseUrl 从 [DaxpayChannelProperties] 统一读取。 +@Configuration +public class LakalaClientConfig { + + @Bean + public LakalaChannelClient lakalaChannelClient( + RestClient restClient, + DaxpayChannelProperties channelProperties) { + // mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl + RestClient channelClient = restClient + .mutate() + .baseUrl(channelProperties.getOne().getBaseUrl()) + .build(); + return HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(channelClient)) + .build() + .createClient(LakalaChannelClient.class); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java new file mode 100644 index 000000000..047ad085c --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java @@ -0,0 +1,34 @@ +package cn.daxpay.open.channel.lakala.controller.callback; + +import cn.daxpay.open.channel.lakala.service.callback.LakalaPayCallbackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// # 拉卡拉支付回调通知控制器 +/// +/// 拉卡拉异步通知入口(支付), 不走 Sa-Token 认证(由安全配置放行 `/unipay/callback/**`)。 +/// 拉卡拉回调验签只需全局服务商公钥(从 LakalaIsvKeyConfig 读取), 不需 channelMchNo, +/// 因此路径不带 channelMchNo(与抖音不同), 凭 out_trade_no 反查 PayTrade。 +@Tag(name = "拉卡拉支付回调通知控制器") +@RestController +@RequestMapping("/unipay/callback/{mchNo}/{appId}/lakala") +@RequiredArgsConstructor +public class LakalaCallbackController { + + private final LakalaPayCallbackService lakalaPayCallbackService; + + /// 拉卡拉支付回调 + @Operation(summary = "拉卡拉支付回调") + @PostMapping("/pay") + public String payNotify(@PathVariable("mchNo") String mchNo, + @PathVariable("appId") String appId, + HttpServletRequest request) { + return lakalaPayCallbackService.payHandle(request); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvChannelMerchantController.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvChannelMerchantController.java new file mode 100644 index 000000000..c08d211b0 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvChannelMerchantController.java @@ -0,0 +1,47 @@ +package cn.daxpay.open.channel.lakala.controller.isv; + +import cn.daxpay.open.channel.lakala.param.isv.LakalaIsvChannelMerchantCreateParam; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvChannelMerchantResult; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvChannelMerchantService; +import cn.daxpay.open.platform.core.annotation.PermCode; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// # 拉卡拉通道商户管理 +/// +@PermCode(menuCode = "channel:merchant") +@Validated +@Tag(name = "拉卡拉通道商户管理") +@RestController +@RequestMapping("/admin/lakala/isv-channel-merchant") +@RequiredArgsConstructor +public class LakalaIsvChannelMerchantController { + + private final LakalaIsvChannelMerchantService lakalaIsvChannelMerchantService; + + @PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View") + @Operation(summary = "根据通道商户号查询拉卡拉通道商户配置") + @GetMapping("/find-by-channel-mch-no") + public Result findByChannelMchNo( + @NotBlank(message = "{validation.field.channelMchNo.notBlank}") String channelMchNo) { + return Res.ok(lakalaIsvChannelMerchantService.findByChannelMchNo(channelMchNo)); + } + + @PermCode(code = "manage", nameCn = "通道商户管理", nameEn = "Channel Merchant Manage") + @Operation(summary = "创建拉卡拉通道商户") + @PostMapping("/create") + public Result create(@RequestBody @Validated LakalaIsvChannelMerchantCreateParam param) { + lakalaIsvChannelMerchantService.create(param); + return Res.ok(); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvKeyConfigController.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvKeyConfigController.java new file mode 100644 index 000000000..34d58f84f --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/isv/LakalaIsvKeyConfigController.java @@ -0,0 +1,48 @@ +package cn.daxpay.open.channel.lakala.controller.isv; + +import cn.daxpay.open.channel.lakala.convert.isv.LakalaIsvKeyConfigConvert; +import cn.daxpay.open.channel.lakala.param.isv.LakalaIsvKeyConfigParam; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvKeyConfigResult; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvKeyConfigService; +import cn.daxpay.open.platform.core.annotation.PermCode; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// # 拉卡拉服务商密钥配置 +/// +@PermCode(menuCode = "payment:lakala:isv") +@Validated +@Tag(name = "拉卡拉服务商密钥配置") +@RestController +@RequestMapping("/admin/lakala/isv-key-config") +@RequiredArgsConstructor +public class LakalaIsvKeyConfigController { + + private final LakalaIsvKeyConfigService lakalaIsvKeyConfigService; + + @PermCode(code = "view", nameCn = "拉卡拉服务商查看", nameEn = "Lakala ISV View") + @Operation(summary = "查询拉卡拉服务商密钥配置") + @GetMapping("/find-config") + public Result findConfig( + @NotBlank(message = "{validation.field.product.notBlank}") String product) { + return Res.ok(LakalaIsvKeyConfigConvert.CONVERT.toResult(lakalaIsvKeyConfigService.findByProduct(product))); + } + + @PermCode(code = "manage", nameCn = "拉卡拉服务商管理", nameEn = "Lakala ISV Manage") + @Operation(summary = "保存拉卡拉服务商密钥配置") + @PostMapping("/save-config") + public Result saveConfig(@RequestBody @Validated LakalaIsvKeyConfigParam param) { + lakalaIsvKeyConfigService.saveConfig(param); + return Res.ok(); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvChannelMerchantConvert.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvChannelMerchantConvert.java new file mode 100644 index 000000000..17d11ac97 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvChannelMerchantConvert.java @@ -0,0 +1,16 @@ +package cn.daxpay.open.channel.lakala.convert.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvChannelMerchant; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvChannelMerchantResult; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +/// # 拉卡拉通道商户绑定转换 +/// +@Mapper +public interface LakalaIsvChannelMerchantConvert { + + LakalaIsvChannelMerchantConvert CONVERT = Mappers.getMapper(LakalaIsvChannelMerchantConvert.class); + + LakalaIsvChannelMerchantResult toResult(LakalaIsvChannelMerchant entity); +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvKeyConfigConvert.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvKeyConfigConvert.java new file mode 100644 index 000000000..d4bca1768 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/convert/isv/LakalaIsvKeyConfigConvert.java @@ -0,0 +1,28 @@ +package cn.daxpay.open.channel.lakala.convert.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.channel.lakala.param.isv.LakalaIsvKeyConfigParam; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvKeyConfigResult; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.mapstruct.factory.Mappers; + +/// # 拉卡拉服务商密钥配置转换 +/// +@Mapper +public interface LakalaIsvKeyConfigConvert { + + LakalaIsvKeyConfigConvert CONVERT = Mappers.getMapper(LakalaIsvKeyConfigConvert.class); + + /// 转换为返回对象 + LakalaIsvKeyConfigResult toResult(LakalaIsvKeyConfig entity); + + /// 转换为实体 + LakalaIsvKeyConfig toEntity(LakalaIsvKeyConfigParam param); + + /// 更新源数据到实体(空值不覆盖, 密钥/证书为空时保留原值) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void copy(LakalaIsvKeyConfigParam param, @MappingTarget LakalaIsvKeyConfig entity); +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantManager.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantManager.java new file mode 100644 index 000000000..6f1ced43d --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantManager.java @@ -0,0 +1,28 @@ +package cn.daxpay.open.channel.lakala.dao.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvChannelMerchant; +import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/// # 拉卡拉通道商户绑定 +/// +@Repository +public class LakalaIsvChannelMerchantManager extends BaseManager { + + /// 校验同一商户下拉卡拉商户号不重复 + public boolean existsByMchNoAndLakalaMchNo(String mchNo, String lakalaMchNo) { + return lambdaQuery() + .eq(LakalaIsvChannelMerchant::getMchNo, mchNo) + .eq(LakalaIsvChannelMerchant::getLakalaMchNo, lakalaMchNo) + .exists(); + } + + /// 根据通道商户号查询 + public Optional findByChannelMchNo(String channelMchNo) { + return lambdaQuery() + .eq(LakalaIsvChannelMerchant::getChannelMchNo, channelMchNo) + .oneOpt(); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantMapper.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantMapper.java new file mode 100644 index 000000000..5b91c17f1 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvChannelMerchantMapper.java @@ -0,0 +1,11 @@ +package cn.daxpay.open.channel.lakala.dao.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvChannelMerchant; +import com.github.yulichang.base.MPJBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/// # 拉卡拉通道商户绑定 +/// +@Mapper +public interface LakalaIsvChannelMerchantMapper extends MPJBaseMapper { +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigManager.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigManager.java new file mode 100644 index 000000000..6e891e834 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigManager.java @@ -0,0 +1,22 @@ +package cn.daxpay.open.channel.lakala.dao.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/// # 拉卡拉服务商密钥配置 +/// +@Slf4j +@Service +public class LakalaIsvKeyConfigManager extends BaseManager { + + /// 根据产品编码查询(平台为唯一服务商, 密钥全局唯一) + public Optional findByProduct(String product) { + return lambdaQuery() + .eq(LakalaIsvKeyConfig::getProduct, product) + .oneOpt(); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigMapper.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigMapper.java new file mode 100644 index 000000000..5904a386b --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/dao/isv/LakalaIsvKeyConfigMapper.java @@ -0,0 +1,11 @@ +package cn.daxpay.open.channel.lakala.dao.isv; + +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import com.github.yulichang.base.MPJBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/// # 拉卡拉服务商密钥配置 +/// +@Mapper +public interface LakalaIsvKeyConfigMapper extends MPJBaseMapper { +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvChannelMerchant.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvChannelMerchant.java new file mode 100644 index 000000000..7deef97f5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvChannelMerchant.java @@ -0,0 +1,41 @@ +package cn.daxpay.open.channel.lakala.entity.isv; + +import cn.daxpay.open.channel.lakala.convert.isv.LakalaIsvChannelMerchantConvert; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvChannelMerchantResult; +import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity; +import cn.daxpay.open.platform.common.mybatisplus.function.ToResult; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/// # 拉卡拉通道商户绑定 +/// +/// 拉卡拉服务商模式下, 子商户只需商户号(merchantNo) + 终端号(termNo)即可发起支付, +/// 密钥/证书由服务商全局配置([LakalaIsvKeyConfig])提供。 +/// 同一商户下拉卡拉商户号不重复。 +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +@TableName(value = "lakala_isv_channel_merchant", autoResultMap = true) +public class LakalaIsvChannelMerchant extends MchBaseEntity implements ToResult { + + /// 通道商户号(LAKALA+雪花) + private String channelMchNo; + + /// 所属支付产品 + /// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum + private String product; + + /// 拉卡拉商户编号(merchantNo) + private String lakalaMchNo; + + /// 终端号(termNo) + private String termNo; + + /// 转换 + @Override + public LakalaIsvChannelMerchantResult toResult() { + return LakalaIsvChannelMerchantConvert.CONVERT.toResult(this); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvKeyConfig.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvKeyConfig.java new file mode 100644 index 000000000..fde34cda6 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/entity/isv/LakalaIsvKeyConfig.java @@ -0,0 +1,57 @@ +package cn.daxpay.open.channel.lakala.entity.isv; + +import cn.daxpay.open.channel.lakala.convert.isv.LakalaIsvKeyConfigConvert; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvKeyConfigResult; +import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity; +import cn.daxpay.open.platform.common.mybatisplus.function.ToResult; +import cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/// # 拉卡拉服务商密钥配置 +/// +/// 拉卡拉为收单机构服务商模式, 服务商密钥全局唯一(按 product 查询), +/// 子商户仅需商户号(merchantNo) + 终端号(termNo), 见 [LakalaIsvChannelMerchant]。 +/// +/// 签名算法: RSA2(SHA256withRSA), 私钥签名 / 公钥验签。 +/// 敏感字段(私钥/公钥/sm4Key)通过 [DataEncryptTypeHandler] 加密入库。 +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +@TableName(value = "lakala_isv_key_config", autoResultMap = true) +public class LakalaIsvKeyConfig extends MpBaseEntity implements ToResult { + + /// 产品编码 + /// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum + private String product; + + /// 拉卡拉应用编号(lkl_app_id) + private String lklAppId; + + /// 商户证书序列号 + private String mchSerialNo; + + /// 商户RSA私钥(PEM, 加密存储) + @TableField(typeHandler = DataEncryptTypeHandler.class) + private String privateKey; + + /// 拉卡拉RSA公钥(PEM, 加密存储) + @TableField(typeHandler = DataEncryptTypeHandler.class) + private String publicKey; + + /// SM4对称密钥(进件敏感字段加密用, 加密存储) + @TableField(typeHandler = DataEncryptTypeHandler.class) + private String sm4Key; + + /// 是否沙箱环境 + private Boolean sandbox; + + /// 转换 + @Override + public LakalaIsvKeyConfigResult toResult() { + return LakalaIsvKeyConfigConvert.CONVERT.toResult(this); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvChannelMerchantCreateParam.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvChannelMerchantCreateParam.java new file mode 100644 index 000000000..021831b32 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvChannelMerchantCreateParam.java @@ -0,0 +1,38 @@ +package cn.daxpay.open.channel.lakala.param.isv; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 拉卡拉通道商户创建参数 +/// +@Data +@Accessors(chain = true) +@Schema(title = "拉卡拉通道商户创建参数") +public class LakalaIsvChannelMerchantCreateParam { + + /// 商户号 + @Schema(description = "商户号") + @NotBlank(message = "{validation.field.mchNo.notBlank}") + private String mchNo; + + /// 通道商户名称 + @Schema(description = "通道商户名称") + @NotBlank(message = "{validation.field.channelMerchantName.notBlank}") + private String channelMerchantName; + + /// 所属支付产品 + @Schema(description = "所属支付产品") + @NotBlank(message = "{validation.field.product.notBlank}") + private String product; + + /// 拉卡拉商户编号 + @Schema(description = "拉卡拉商户编号") + @NotBlank(message = "{validation.field.lakalaMchNo.notBlank}") + private String lakalaMchNo; + + /// 终端号 + @Schema(description = "终端号") + private String termNo; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvKeyConfigParam.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvKeyConfigParam.java new file mode 100644 index 000000000..c8fe7e095 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/param/isv/LakalaIsvKeyConfigParam.java @@ -0,0 +1,37 @@ +package cn.daxpay.open.channel.lakala.param.isv; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 拉卡拉服务商密钥配置保存参数 +/// +@Data +@Accessors(chain = true) +@Schema(title = "拉卡拉服务商密钥配置保存参数") +public class LakalaIsvKeyConfigParam { + + @NotBlank(message = "{validation.field.product.notBlank}") + @Schema(description = "产品编码") + private String product; + + @NotBlank(message = "{validation.field.lklAppId.notBlank}") + @Schema(description = "拉卡拉应用编号") + private String lklAppId; + + @Schema(description = "商户证书序列号") + private String mchSerialNo; + + @Schema(description = "商户RSA私钥(加密存储)") + private String privateKey; + + @Schema(description = "拉卡拉RSA公钥(加密存储)") + private String publicKey; + + @Schema(description = "SM4密钥(加密存储)") + private String sm4Key; + + @Schema(description = "是否沙箱环境") + private Boolean sandbox; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvChannelMerchantResult.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvChannelMerchantResult.java new file mode 100644 index 000000000..b96b9530d --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvChannelMerchantResult.java @@ -0,0 +1,28 @@ +package cn.daxpay.open.channel.lakala.result.isv; + +import cn.daxpay.open.payment.common.result.MchTradeBaseResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/// # 拉卡拉通道商户绑定结果 +/// +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +@Schema(title = "拉卡拉通道商户绑定结果") +public class LakalaIsvChannelMerchantResult extends MchTradeBaseResult { + + @Schema(description = "通道商户号") + private String channelMchNo; + + @Schema(description = "所属支付产品") + private String product; + + @Schema(description = "拉卡拉商户编号") + private String lakalaMchNo; + + @Schema(description = "终端号") + private String termNo; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvKeyConfigResult.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvKeyConfigResult.java new file mode 100644 index 000000000..704f50e31 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/result/isv/LakalaIsvKeyConfigResult.java @@ -0,0 +1,41 @@ +package cn.daxpay.open.channel.lakala.result.isv; + +import cn.daxpay.open.platform.common.json.sensitive.SensitiveInfo; +import cn.daxpay.open.platform.core.result.BaseResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/// # 拉卡拉服务商密钥配置 +/// +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +@Schema(title = "拉卡拉服务商密钥配置") +public class LakalaIsvKeyConfigResult extends BaseResult { + + @Schema(description = "产品编码") + private String product; + + @Schema(description = "拉卡拉应用编号") + private String lklAppId; + + @Schema(description = "商户证书序列号") + private String mchSerialNo; + + @SensitiveInfo(front = 12, end = 12) + @Schema(description = "商户RSA私钥(加密存储)") + private String privateKey; + + @SensitiveInfo(front = 12, end = 12) + @Schema(description = "拉卡拉RSA公钥(加密存储)") + private String publicKey; + + @SensitiveInfo(front = 12, end = 12) + @Schema(description = "SM4密钥(加密存储)") + private String sm4Key; + + @Schema(description = "是否沙箱环境") + private Boolean sandbox; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java new file mode 100644 index 000000000..d5f9d81a5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java @@ -0,0 +1,185 @@ +package cn.daxpay.open.channel.lakala.service.callback; + +import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvKeyConfigManager; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.CallbackData; +import cn.daxpay.open.payment.core.trade.service.PayCallbackService; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; +import cn.hutool.extra.servlet.JakartaServletUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; + +/// # 拉卡拉支付回调处理服务 +/// +/// 拉卡拉异步通知 → 主应用接收原始 header + body → 用全局服务商公钥 RSA2 验签 → +/// 构建 [CallbackData] 交由 [PayCallbackService] 更新订单状态。 +/// +/// 拉卡拉回调签名机制(安全统一通知规范): +/// - Authorization header: `LKLAPI-SHA256withRSA timestamp="...",nonce_str="...",signature="..."` +/// - 验签原文: `timestamp + "\n" + nonce_str + "\n" + body + "\n"` +/// - 公钥为 X509 证书(PEM), 与响应验签共用 +/// +/// 主应用直接验签(签名算法简单, 无需转发子应用)。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaPayCallbackService { + + private static final String NOTIFY_SUCCESS = "success"; + private static final String NOTIFY_FAIL = "fail"; + private static final DateTimeFormatter LKL_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final LakalaIsvKeyConfigManager lakalaIsvKeyConfigManager; + private final PayCallbackService payCallbackService; + + /// 支付回调处理 + public String payHandle(HttpServletRequest request) { + // 1. 提取回调原始数据 + String body = JakartaServletUtil.getBody(request); + Map headerMap = JakartaServletUtil.getHeaderMap(request); + + // 2. 获取全局服务商公钥(只读查询, 不创建记录) + LakalaIsvKeyConfig keyConfig = lakalaIsvKeyConfigManager.findByProduct(ProductEnum.LAKALA_PAY.getCode()) + .orElse(null); + if (keyConfig == null || keyConfig.getPublicKey() == null) { + log.error("拉卡拉支付回调: 服务商密钥未配置, 无法验签"); + return NOTIFY_FAIL; + } + + // 3. 验签 + if (!verifyCallbackSign(headerMap, body, keyConfig.getPublicKey())) { + log.error("拉卡拉支付回调验签失败"); + return NOTIFY_FAIL; + } + + // 4. 解析回调数据并构建 CallbackData + CallbackData callbackData = parseCallback(body); + if (callbackData == null) { + log.error("拉卡拉支付回调解析失败: body={}", body); + return NOTIFY_FAIL; + } + try { + // 5. 交由框架更新订单状态 + payCallbackService.payCallback(callbackData); + } catch (Exception e) { + log.error("拉卡拉支付回调业务处理失败: tradeNo={}", callbackData.getTradeNo(), e); + return NOTIFY_FAIL; + } + return NOTIFY_SUCCESS; + } + + /// 验证回调签名(从 Authorization header 解析 timestamp/nonce_str/signature) + @SuppressWarnings("unchecked") + private boolean verifyCallbackSign(Map headerMap, String body, String publicKey) { + // Authorization 可能是任意大小写 header 名 + String authorization = headerMap.getOrDefault("Authorization", + headerMap.getOrDefault("authorization", "")); + if (authorization.isEmpty()) { + return false; + } + // 解析 authorization: LKLAPI-SHA256withRSA timestamp="xxx",nonce_str="xxx",signature="xxx" + Map authMap = parseAuthorization(authorization); + String timestamp = authMap.get("timestamp"); + String nonceStr = authMap.get("nonce_str"); + String signature = authMap.get("signature"); + if (signature == null || timestamp == null || nonceStr == null) { + return false; + } + // 验签原文: timestamp\n nonce_str\n body\n + String source = timestamp + "\n" + nonceStr + "\n" + body + "\n"; + try { + X509Certificate cert = loadCertificate(publicKey); + Signature sign = Signature.getInstance("SHA256withRSA"); + sign.initVerify(cert); + sign.update(source.getBytes(StandardCharsets.UTF_8)); + return sign.verify(Base64.getDecoder().decode(signature)); + } catch (Exception e) { + log.error("拉卡拉回调验签异常", e); + return false; + } + } + + /// 解析 Authorization header 为 key-value Map + private Map parseAuthorization(String authorization) { + // 去掉方案前缀 LKLAPI-SHA256withRSA + int spaceIdx = authorization.indexOf(' '); + String kvPart = spaceIdx > 0 ? authorization.substring(spaceIdx + 1) : authorization; + Map map = new java.util.HashMap<>(); + for (String pair : kvPart.split(",")) { + int eqIdx = pair.indexOf('='); + if (eqIdx > 0) { + String key = pair.substring(0, eqIdx).trim(); + // 去掉值的引号 + String value = pair.substring(eqIdx + 1).trim(); + if (value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + map.put(key, value); + } + } + return map; + } + + /// 加载 X509 公钥证书(PEM) + private X509Certificate loadCertificate(String publicKey) throws Exception { + String key = publicKey + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); + byte[] decoded = Base64.getDecoder().decode(key); + CertificateFactory cf = CertificateFactory.getInstance("X509"); + return (X509Certificate) cf.generateCertificate(new java.io.ByteArrayInputStream(decoded)); + } + + /// 解析拉卡拉回调 JSON 为 CallbackData + private CallbackData parseCallback(String body) { + try { + cn.hutool.json.JSONObject json = cn.hutool.json.JSONUtil.parseObj(body); + // 拉卡拉回调外层为 resp_data 包裹(与响应一致), 兼容直接业务数据 + cn.hutool.json.JSONObject data = json.getJSONObject("resp_data"); + if (data == null) { + data = json; + } + CallbackData callbackData = new CallbackData(); + // out_trade_no = 下单时传入的平台 tradeNo + callbackData.setTradeNo(data.getStr("out_trade_no")); + // trade_no = 拉卡拉交易号 + callbackData.setOutTradeNo(data.getStr("trade_no")); + // 交易状态映射 + String tradeState = data.getStr("trade_state"); + if (Objects.equals(tradeState, "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeStatus(tradeState); + callbackData.setCallbackErrorMsg("拉卡拉回调状态非成功: " + tradeState); + } + // 完成时间(yyyyMMddHHmmss → OffsetDateTime) + String tradeTime = data.getStr("trade_time"); + if (tradeTime != null && !tradeTime.isBlank()) { + try { + callbackData.setFinishTime(OffsetDateTime.parse(tradeTime, LKL_TIME_FORMATTER)); + } catch (Exception e) { + log.warn("拉卡拉回调时间解析失败: tradeTime={}", tradeTime); + } + } + return callbackData; + } catch (Exception e) { + log.error("拉卡拉回调 JSON 解析失败", e); + return null; + } + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvChannelMerchantService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvChannelMerchantService.java new file mode 100644 index 000000000..4dde2d043 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvChannelMerchantService.java @@ -0,0 +1,71 @@ +package cn.daxpay.open.channel.lakala.service.isv; + +import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvChannelMerchantManager; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvChannelMerchant; +import cn.daxpay.open.channel.lakala.param.isv.LakalaIsvChannelMerchantCreateParam; +import cn.daxpay.open.channel.lakala.result.isv.LakalaIsvChannelMerchantResult; +import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager; +import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.enums.channel.ChannelMerchantSourceEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.daxpay.open.platform.core.exception.DataNotExistException; +import cn.hutool.core.util.IdUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/// # 拉卡拉通道商户管理 +/// +/// 拉卡拉服务商模式下, 子商户关联到服务商本身(密钥全局唯一), 不挂靠具体应用。 +/// 一个商户下同一拉卡拉商户号(lakalaMchNo)只允许绑定一次。 +/// +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvChannelMerchantService { + + private final ChannelMerchantManager channelMerchantManager; + private final LakalaIsvChannelMerchantManager lakalaIsvChannelMerchantManager; + + /// 创建拉卡拉通道商户 + @Transactional(rollbackFor = Exception.class) + public void create(LakalaIsvChannelMerchantCreateParam param) { + // 校验同一商户下拉卡拉商户号不重复 + if (lakalaIsvChannelMerchantManager.existsByMchNoAndLakalaMchNo( + param.getMchNo(), param.getLakalaMchNo())) { + // 拉卡拉: 该商户下已存在此拉卡拉商户号 + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.lakala.merchantDuplicate"); + } + // 生成通道商户号: 前缀 LAKALA + 雪花ID(无分隔符, 符合 TradeNoGenerateUtil 约定) + String channelMchNo = "LAKALA" + IdUtil.getSnowflakeNextId(); + // 写通用通道商户主表 + ChannelMerchant channelMerchant = new ChannelMerchant(); + channelMerchant.setMchNo(param.getMchNo()); + channelMerchant.setChannelMchNo(channelMchNo); + channelMerchant.setChannelMerchantName(param.getChannelMerchantName()); + channelMerchant.setProduct(param.getProduct()); + channelMerchant.setSource(ChannelMerchantSourceEnum.MANUAL.getCode()); + channelMerchant.setEnable(true); + channelMerchantManager.save(channelMerchant); + // 写拉卡拉绑定表 + var entity = new LakalaIsvChannelMerchant(); + entity.setMchNo(param.getMchNo()); + entity.setChannelMchNo(channelMchNo); + entity.setProduct(param.getProduct()); + entity.setLakalaMchNo(param.getLakalaMchNo()); + entity.setTermNo(param.getTermNo()); + lakalaIsvChannelMerchantManager.save(entity); + } + + /// 根据通道商户号查询拉卡拉通道商户配置 + public LakalaIsvChannelMerchantResult findByChannelMchNo(String channelMchNo) { + return lakalaIsvChannelMerchantManager.lambdaQuery() + .eq(LakalaIsvChannelMerchant::getChannelMchNo, channelMchNo) + .oneOpt() + .map(LakalaIsvChannelMerchant::toResult) + // 拉卡拉: 通道商户配置不存在 + .orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist")); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvConfigAssembler.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvConfigAssembler.java new file mode 100644 index 000000000..ba046d59d --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvConfigAssembler.java @@ -0,0 +1,59 @@ +package cn.daxpay.open.channel.lakala.service.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvChannelMerchantManager; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvChannelMerchant; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.exception.DataNotExistException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商通道凭证组装器 +/// +/// 从服务商密钥配置([LakalaIsvKeyConfig]) + 通道商户绑定([LakalaIsvChannelMerchant]) 组装通道调用凭证, +/// 下发给子应用 dax-pay-channel-one 发起拉卡拉 API 调用。 +/// +/// 字段映射(对齐拉卡拉 V3 接口): +/// - lkl_app_id ← [LakalaIsvKeyConfig.lklAppId] (拉卡拉应用编号, 全局唯一) +/// - merchant_no ← [LakalaIsvChannelMerchant.lakalaMchNo] (拉卡拉商户号) +/// - term_no ← [LakalaIsvChannelMerchant.termNo] (终端号) +/// - 私钥/公钥/证书序列号 ← [LakalaIsvKeyConfig] (服务商级, 全局唯一) +/// +/// 供支付策略([cn.daxpay.open.channel.lakala.strategy.isv.*])组装通道调用凭证。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvConfigAssembler { + + private final LakalaIsvChannelMerchantManager lakalaIsvChannelMerchantManager; + private final LakalaIsvKeyConfigService lakalaIsvKeyConfigService; + + /// 组装拉卡拉通道调用凭证(下发给子应用) + /// + /// @param mchNo 商户号(保留参数对齐签名, 服务商密钥全局唯一不依赖此字段) + /// @param channelMchNo 通道商户号(拉卡拉商户绑定主键) + /// @param capability 支付能力编码(拉卡拉聚合通道不按能力路由应用, 保留参数) + /// @return 拉卡拉 SDK 凭证(含服务商密钥 + 商户号/终端号) + public LakalaSdkCredential buildConfig(String mchNo, String channelMchNo, String capability) { + // 服务商密钥(全局唯一, 含 lkl_app_id + 私钥/公钥; 缺失或关键字段为空时 fail-fast) + LakalaIsvKeyConfig keyConfig = lakalaIsvKeyConfigService.getByProductForPay(ProductEnum.LAKALA_PAY.getCode()); + // 通道商户绑定(取 merchantNo + termNo) + LakalaIsvChannelMerchant channelMerchant = lakalaIsvChannelMerchantManager.findByChannelMchNo(channelMchNo) + // 拉卡拉: 通道商户配置不存在 + .orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist")); + + LakalaSdkCredential credential = new LakalaSdkCredential(); + // 服务商身份与密钥 + credential.setLklAppId(keyConfig.getLklAppId()); + credential.setMchSerialNo(keyConfig.getMchSerialNo()); + credential.setPrivateKey(keyConfig.getPrivateKey()); + credential.setPublicKey(keyConfig.getPublicKey()); + credential.setSandbox(keyConfig.getSandbox()); + // 子商户身份 + credential.setLakalaMchNo(channelMerchant.getLakalaMchNo()); + credential.setTermNo(channelMerchant.getTermNo()); + return credential; + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvKeyConfigService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvKeyConfigService.java new file mode 100644 index 000000000..174352f9d --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/isv/LakalaIsvKeyConfigService.java @@ -0,0 +1,61 @@ +package cn.daxpay.open.channel.lakala.service.isv; + +import cn.daxpay.open.channel.lakala.convert.isv.LakalaIsvKeyConfigConvert; +import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvKeyConfigManager; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.channel.lakala.param.isv.LakalaIsvKeyConfigParam; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/// # 拉卡拉服务商密钥配置 +/// +/// 管理服务商密钥配置, 查询时不存在则创建默认记录(平台为唯一服务商, 密钥全局唯一)。 +/// +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvKeyConfigService { + + private final LakalaIsvKeyConfigManager lakalaIsvKeyConfigManager; + + /// 根据产品编码查询密钥配置, 不存在则创建默认记录 + @Transactional(rollbackFor = Exception.class) + public LakalaIsvKeyConfig findByProduct(String product) { + var existing = lakalaIsvKeyConfigManager.findByProduct(product); + if (existing.isPresent()) { + return existing.get(); + } + var config = new LakalaIsvKeyConfig() + .setProduct(product) + .setSandbox(false); + lakalaIsvKeyConfigManager.save(config); + return config; + } + + /// 支付场景查询服务商密钥(必填校验, 不创建记录) + /// + /// 与 [findByProduct] 的 upsert 语义不同, 此方法只读不写: + /// 记录不存在或关键字段(lklAppId/privateKey/publicKey)任一为空时 fail-fast, + /// 避免空凭证下发到子应用后子应用才发现问题。 + public LakalaIsvKeyConfig getByProductForPay(String product) { + LakalaIsvKeyConfig config = lakalaIsvKeyConfigManager.findByProduct(product) + // 拉卡拉: 服务商密钥未配置 + .orElseThrow(() -> new BizInfoException("error.channel.lakala.isvKeyNotConfigured")); + if (StrUtil.hasBlank(config.getLklAppId(), config.getPrivateKey(), config.getPublicKey())) { + throw new BizInfoException("error.channel.lakala.isvKeyNotConfigured"); + } + return config; + } + + /// 保存服务商密钥配置 + @Transactional(rollbackFor = Exception.class) + public void saveConfig(LakalaIsvKeyConfigParam param) { + var config = this.findByProduct(param.getProduct()); + LakalaIsvKeyConfigConvert.CONVERT.copy(param, config); + lakalaIsvKeyConfigManager.updateById(config); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvCloseService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvCloseService.java new file mode 100644 index 000000000..b008b4923 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvCloseService.java @@ -0,0 +1,46 @@ +package cn.daxpay.open.channel.lakala.service.payment.isv; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.req.LakalaCloseReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaCloseResp; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import cn.daxpay.open.platform.core.code.DaxPayErrorCode; +import cn.daxpay.open.platform.core.enums.pay.pay.CloseTypeEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商关单业务服务 +/// +/// 通过 [LakalaChannelClient] 调用子应用关闭拉卡拉订单。 +/// 拉卡拉仅提供关单接口(`/v3/labs/relation/close`), 无撤销接口, useCancel 参数忽略。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvCloseService { + + private final LakalaChannelClient lakalaChannelClient; + + /// 关闭订单 + /// + /// @param order 支付订单 + /// @param credential 通道凭证 + /// @param useCancel 是否撤销(拉卡拉不支持撤销, 忽略此参数) + /// @return 关闭类型(恒为 CLOSE) + public CloseTypeEnum close(PayTrade order, LakalaSdkCredential credential, boolean useCancel) { + LakalaCloseReq req = new LakalaCloseReq(); + req.setCredential(credential); + req.setOriginOutTradeNo(order.getTradeNo()); + req.setOriginTradeNo(order.getOutOrderNo()); + + DaxResult result = lakalaChannelClient.close(req); + if (result.getCode() != 0) { + throw new BizInfoException(DaxPayErrorCode.TRADE_FAIL, "channel.error.lakalaCloseFailed", result.getMsg()); + } + // 拉卡拉只有关单, 返回 CLOSE + return CloseTypeEnum.CLOSE; + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvPayService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvPayService.java new file mode 100644 index 000000000..97ded9be9 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvPayService.java @@ -0,0 +1,141 @@ +package cn.daxpay.open.channel.lakala.service.payment.isv; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.enums.LakalaPayBodyType; +import cn.daxpay.open.channel.lakala.client.enums.LakalaPayMethod; +import cn.daxpay.open.channel.lakala.client.req.LakalaPayReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaPayResp; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.bo.PayTradeResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.code.DaxPayErrorCode; +import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum; +import cn.daxpay.open.platform.core.enums.unipay.PayBodyTypeEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商支付执行业务服务 +/// +/// 通过 [LakalaChannelClient] 调用子应用 dax-pay-channel-one 完成拉卡拉支付下单。 +/// 请求构建、响应解析全部在本类中完成。 +/// +/// 拉卡拉聚合通道: 一个接口承载微信/支付宝/银联三种底层渠道, 由 accountType + transType 决定路由。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvPayService { + + private final LakalaChannelClient lakalaChannelClient; + private final PlatformUrlConfigService platformUrlConfigService; + + /// 执行拉卡拉支付 + /// + /// @param order 支付订单(tradeNo 作为 out_trade_no) + /// @param payParam 支付参数 + /// @param credential 通道调用凭证(服务商密钥 + 商户号/终端号) + /// @return 支付结果 + public PayTradeResultBo pay(PayTrade order, NormalPayParam payParam, LakalaSdkCredential credential) { + // 平台支付方式 → 拉卡拉三要素(method + accountType + transType + bodyType) + MethodMapping mapping = mapMethod(payParam.getMethod()); + + // 构建请求 + LakalaPayReq req = new LakalaPayReq(); + // 使用支付交易号作为商户订单号透传给拉卡拉, 回调时凭此反查 PayTrade + req.setOutTradeNo(order.getTradeNo()); + req.setAmount(payParam.getAmount()); + req.setTitle(payParam.getTitle()); + req.setDescription(payParam.getDescription()); + req.setMethod(mapping.method); + req.setAccountType(mapping.accountType); + req.setTransType(mapping.transType); + req.setPayBodyType(mapping.bodyType); + // 通道专属参数透传 + req.setOpenId(payParam.getOpenId()); + req.setAuthCode(payParam.getAuthCode()); + req.setClientIp(payParam.getClientIp()); + req.setNotifyUrl(this.buildNotifyUrl(order)); + req.setExpireTime(order.getExpiredTime()); + req.setCredential(credential); + + // 调用子应用 + DaxResult result = lakalaChannelClient.pay(req); + if (result.getCode() != 0) { + throw new BizInfoException(DaxPayErrorCode.TRADE_FAIL, "channel.error.lakalaPayFailed", result.getMsg()); + } + + return toPayResult(result.getData()); + } + + /// 生成拉卡拉支付异步通知地址(拉卡拉→平台) + /// + /// 路径约定: `{backendBaseUrl}/unipay/callback/{mchNo}/{appId}/lakala/pay` + private String buildNotifyUrl(PayTrade order) { + String base = platformUrlConfigService.getUrlConfig().getBackendBaseUrl(); + if (StrUtil.isBlank(base)) { + throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.backendBaseUrlNotConfigured"); + } + return StrUtil.format("{}/unipay/callback/{}/{}/lakala/pay", + base, order.getMchNo(), order.getAppId()); + } + + /// 平台支付方式([PayMethodEnum] code) → 拉卡拉三要素 + private static MethodMapping mapMethod(String methodCode) { + PayMethodEnum m = PayMethodEnum.findByCode(methodCode); + return switch (m) { + // 条码支付(微信/支付宝/银联付款码, 走 micropay, accountType 由拉卡拉自动识别) + case WECHAT_BARCODE, ALIPAY_BARCODE, UNION_PAY_BARCODE -> + new MethodMapping(LakalaPayMethod.MICROPAY, null, null, null); + // 微信预下单 + case WECHAT_JSAPI -> new MethodMapping(LakalaPayMethod.PREORDER, "WECHAT", "51", LakalaPayBodyType.JSAPI); + case WECHAT_MINI -> new MethodMapping(LakalaPayMethod.PREORDER, "WECHAT", "71", LakalaPayBodyType.JSAPI); + case WECHAT_APP -> new MethodMapping(LakalaPayMethod.PREORDER, "WECHAT", "61", LakalaPayBodyType.JSAPI); + // 支付宝预下单 + case ALIPAY_QR -> new MethodMapping(LakalaPayMethod.PREORDER, "ALIPAY", "41", LakalaPayBodyType.QR_CODE); + case ALIPAY_JSAPI, ALIPAY_MINI -> new MethodMapping(LakalaPayMethod.PREORDER, "ALIPAY", "51", LakalaPayBodyType.IDENTIFIER); + // 银联预下单 + case UNION_QR -> new MethodMapping(LakalaPayMethod.PREORDER, "UQRCODEPAY", "41", LakalaPayBodyType.QR_CODE); + case UNION_JSAPI -> new MethodMapping(LakalaPayMethod.PREORDER, "UQRCODEPAY", "51", LakalaPayBodyType.LINK); + default -> throw new BizInfoException(CommonErrorCode.UN_SUPPORTED_OPERATE, + "channel.error.lakalaUnsupportedPayMethod", methodCode); + }; + } + + /// 解析子应用响应为支付结果 BO + private PayTradeResultBo toPayResult(LakalaPayResp resp) { + PayTradeResultBo bo = new PayTradeResultBo() + .setOutOrderNo(resp.getTradeNo()) + .setComplete(Boolean.TRUE.equals(resp.getComplete())) + .setPayBody(resp.getPayBody()); + + // 支付内容类型映射(子应用 LakalaPayBodyType → 平台 PayBodyTypeEnum) + if (resp.getPayBodyType() != null) { + switch (resp.getPayBodyType()) { + case LINK -> bo.setPayBodyType(PayBodyTypeEnum.LINK); + case QR_CODE -> bo.setPayBodyType(PayBodyTypeEnum.QR_CODE); + case JSAPI -> bo.setPayBodyType(PayBodyTypeEnum.JSAPI); + case IDENTIFIER -> bo.setPayBodyType(PayBodyTypeEnum.IDENTIFIER); + } + } + + // 完成时间与金额(条码同步成功时返回) + bo.setFinishTime(resp.getFinishTime()); + bo.setTotalAmount(resp.getTotalAmount()); + bo.setRealAmount(resp.getPayerAmount()); + bo.setBuyerPayAmount(resp.getPayerAmount()); + // 用户标识 + bo.setBuyerId(resp.getBuyerId()); + return bo; + } + + /// 支付方式映射内部封装 + private record MethodMapping(LakalaPayMethod method, String accountType, String transType, + LakalaPayBodyType bodyType) { + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundService.java new file mode 100644 index 000000000..b86e909f1 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundService.java @@ -0,0 +1,57 @@ +package cn.daxpay.open.channel.lakala.service.payment.isv; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.req.LakalaRefundReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaRefundResp; +import cn.daxpay.open.payment.common.enums.RefundOrderStatusEnum; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.bo.RefundResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import cn.daxpay.open.platform.core.code.DaxPayErrorCode; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商退款执行业务服务 +/// +/// 通过 [LakalaChannelClient] 调用子应用完成拉卡拉退款。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvRefundService { + + private final LakalaChannelClient lakalaChannelClient; + + /// 执行退款 + public RefundResultBo refund(PayRefundOrder refundOrder, LakalaSdkCredential credential) { + LakalaRefundReq req = new LakalaRefundReq(); + req.setCredential(credential); + // 退款单号(作为拉卡拉 out_trade_no) + req.setOutRefundNo(refundOrder.getRefundNo()); + // 原支付订单号(平台 tradeNo 作为原 out_trade_no) + req.setOriginOutTradeNo(refundOrder.getOrderNo()); + // 原通道交易号(退款同步查询用, 首次退款时可能为空) + req.setOriginTradeNo(refundOrder.getOutOrderNo()); + req.setAmount(refundOrder.getAmount()); + req.setReason(refundOrder.getReason()); + req.setClientIp(refundOrder.getClientIp()); + + DaxResult result = lakalaChannelClient.refund(req); + if (result.getCode() != 0) { + throw new BizInfoException(DaxPayErrorCode.TRADE_FAIL, "channel.error.lakalaRefundFailed", result.getMsg()); + } + + LakalaRefundResp resp = result.getData(); + RefundResultBo bo = new RefundResultBo() + .setOutRefundNo(resp.getTradeNo()) + .setFinishTime(resp.getFinishTime()) + .setComplete(Boolean.TRUE.equals(resp.getComplete())); + // 退款状态: 有完成时间视为成功, 否则处理中 + bo.setStatus(Boolean.TRUE.equals(resp.getComplete()) + ? RefundOrderStatusEnum.SUCCESS + : RefundOrderStatusEnum.PROGRESS); + return bo; + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundSyncService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundSyncService.java new file mode 100644 index 000000000..1cd8c4106 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvRefundSyncService.java @@ -0,0 +1,62 @@ +package cn.daxpay.open.channel.lakala.service.payment.isv; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.req.LakalaRefundSyncReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaRefundSyncResp; +import cn.daxpay.open.payment.common.enums.RefundOrderStatusEnum; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.bo.RefundResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/// # 拉卡拉服务商退款同步业务服务 +/// +/// 通过 [LakalaChannelClient] 调用子应用查询拉卡拉退款最终状态。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvRefundSyncService { + + private final LakalaChannelClient lakalaChannelClient; + + /// 同步退款状态 + public RefundResultBo sync(PayRefundOrder refundOrder, LakalaSdkCredential credential) { + LakalaRefundSyncReq req = new LakalaRefundSyncReq(); + req.setCredential(credential); + req.setOutRefundNo(refundOrder.getRefundNo()); + req.setOriginTradeNo(refundOrder.getOutRefundNo()); + + DaxResult result = lakalaChannelClient.refundSync(req); + RefundResultBo bo = new RefundResultBo(); + if (result.getCode() != 0) { + bo.setSyncSuccess(false); + bo.setSyncErrorMsg(result.getMsg()); + return bo; + } + + LakalaRefundSyncResp resp = result.getData(); + bo.setSyncSuccess(true); + bo.setOutRefundNo(resp.getTradeNo()); + bo.setFinishTime(resp.getFinishTime()); + bo.setStatus(mapRefundStatus(resp.getRefundStatus())); + bo.setComplete(Objects.equals(resp.getRefundStatus(), "SUCCESS")); + return bo; + } + + /// 拉卡拉退款状态 → 平台退款状态 + private RefundOrderStatusEnum mapRefundStatus(String refundStatus) { + if (Objects.equals(refundStatus, "SUCCESS")) { + return RefundOrderStatusEnum.SUCCESS; + } + if (Objects.equals(refundStatus, "FAIL")) { + return RefundOrderStatusEnum.FAIL; + } + // 处理中 + return RefundOrderStatusEnum.PROGRESS; + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvSyncService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvSyncService.java new file mode 100644 index 000000000..3dd2d6c39 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/payment/isv/LakalaIsvSyncService.java @@ -0,0 +1,72 @@ +package cn.daxpay.open.channel.lakala.service.payment.isv; + +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.req.LakalaSyncReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaSyncResp; +import cn.daxpay.open.payment.common.enums.PayFundStatusEnum; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.bo.PaySyncResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/// # 拉卡拉服务商订单同步业务服务 +/// +/// 通过 [LakalaChannelClient] 调用子应用查询拉卡拉订单状态。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvSyncService { + + private final LakalaChannelClient lakalaChannelClient; + + /// 同步订单状态 + public PaySyncResultBo sync(PayTrade order, LakalaSdkCredential credential) { + LakalaSyncReq req = new LakalaSyncReq(); + req.setCredential(credential); + req.setOutTradeNo(order.getTradeNo()); + req.setTradeNo(order.getOutOrderNo()); + + DaxResult result = lakalaChannelClient.sync(req); + PaySyncResultBo bo = new PaySyncResultBo(); + if (result.getCode() != 0) { + // 同步失败(不抛异常, 由核心层决定重试) + bo.setSyncSuccess(false); + bo.setSyncErrorMsg(result.getMsg()); + return bo; + } + + LakalaSyncResp resp = result.getData(); + bo.setSyncSuccess(true); + bo.setSyncData(resp.getSyncData()); + bo.setOutOrderNo(resp.getTradeNo()); + bo.setPayStatus(mapTradeState(resp.getTradeState())); + // 成功时补充金额/时间/买家 + if (Objects.equals(resp.getTradeState(), "SUCCESS")) { + bo.setAmount(resp.getTotalAmount()); + bo.setRealAmount(resp.getTotalAmount()); + bo.setFinishTime(resp.getFinishTime()); + bo.setBuyerId(resp.getBuyerId()); + } + return bo; + } + + /// 拉卡拉 trade_state → 平台资金状态 + private PayFundStatusEnum mapTradeState(String tradeState) { + if (Objects.equals(tradeState, "SUCCESS")) { + return PayFundStatusEnum.SUCCESS; + } + if (Objects.equals(tradeState, "FAIL")) { + return PayFundStatusEnum.FAIL; + } + if (Objects.equals(tradeState, "CLOSED")) { + return PayFundStatusEnum.CLOSE; + } + // 其他(处理中) + return PayFundStatusEnum.PROCESSING; + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/LakalaProductStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/LakalaProductStrategy.java index 519b6ceec..d84156528 100644 --- a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/LakalaProductStrategy.java +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/LakalaProductStrategy.java @@ -47,7 +47,7 @@ public class LakalaProductStrategy extends AbsProductStrategy { public boolean isIsv() { return true; } @Override - public boolean isTerminal() { return true; } + public boolean isTerminal() { return false; } @Override public boolean isSandbox() { return true; } diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvCloseStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvCloseStrategy.java new file mode 100644 index 000000000..1c1caac1f --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvCloseStrategy.java @@ -0,0 +1,49 @@ +package cn.daxpay.open.channel.lakala.strategy.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvConfigAssembler; +import cn.daxpay.open.channel.lakala.service.payment.isv.LakalaIsvCloseService; +import cn.daxpay.open.payment.core.strategy.pay.AbsPayCloseStrategy; +import cn.daxpay.open.payment.core.strategy.pay.PayStrategyContext; +import cn.daxpay.open.payment.core.trade.entity.NormalPayOrder; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.pay.CloseTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商支付关闭策略 +/// +/// 从上下文容器读取通道路由参数(channelMchNo / capability), +/// 组装通道凭证(委托 [LakalaIsvConfigAssembler]), 关闭执行委托给 [LakalaIsvCloseService]。 +/// +/// 注意: 拉卡拉仅提供关单接口, 无撤销接口(useCancel 参数由 service 层忽略)。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvCloseStrategy extends AbsPayCloseStrategy { + + private final LakalaIsvCloseService lakalaIsvCloseService; + private final LakalaIsvConfigAssembler lakalaIsvConfigAssembler; + + @Override + public ProductEnum getProduct() { + return ProductEnum.LAKALA_PAY; + } + + @Override + public CloseTypeEnum doClose(PayStrategyContext context, boolean useCancel) { + // 从上下文容器读取通道路由参数 + NormalPayOrder normalOrder = context.getContainer(); + String channelMchNo = normalOrder != null ? normalOrder.getChannelMchNo() : null; + String capability = normalOrder != null ? normalOrder.getCapability() : null; + PayTrade trade = context.getTrade(); + + // 组装通道调用凭证 + LakalaSdkCredential credential = lakalaIsvConfigAssembler.buildConfig( + trade.getMchNo(), channelMchNo, capability); + + return lakalaIsvCloseService.close(trade, credential, useCancel); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvPayStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvPayStrategy.java new file mode 100644 index 000000000..e7a8d0f64 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvPayStrategy.java @@ -0,0 +1,50 @@ +package cn.daxpay.open.channel.lakala.strategy.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvConfigAssembler; +import cn.daxpay.open.channel.lakala.service.payment.isv.LakalaIsvPayService; +import cn.daxpay.open.payment.core.strategy.pay.AbsNormalPayStrategy; +import cn.daxpay.open.payment.core.strategy.pay.PayStrategyContext; +import cn.daxpay.open.payment.core.trade.bo.PayTradeResultBo; +import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商支付策略 +/// +/// 拉卡拉支付(ProductEnum.LAKALA_PAY)下发起支付的具体执行策略。 +/// 配置组装在 [doBeforePay] 阶段完成(委托 [LakalaIsvConfigAssembler]), 支付执行委托给 [LakalaIsvPayService]。 +/// +/// 拉卡拉为聚合服务商模式, 不区分直连/服务商, 仅有一个产品 LAKALA_PAY。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvPayStrategy extends AbsNormalPayStrategy { + + private final LakalaIsvPayService lakalaIsvPayService; + private final LakalaIsvConfigAssembler lakalaIsvConfigAssembler; + + @Override + public ProductEnum getProduct() { + return ProductEnum.LAKALA_PAY; + } + + /// 支付前预处理: 从容器读取通道路由参数, 组装通道凭证写入上下文 + /// 配置缺失在此阶段抛异常, fail-fast, 不会进入 doPay + @Override + public void doBeforePay(PayStrategyContext context) { + NormalPayParam payParam = context.getPayParam(); + LakalaSdkCredential credential = lakalaIsvConfigAssembler.buildConfig( + payParam.getMchNo(), payParam.getChannelMchNo(), payParam.getCapability()); + context.setChannelConfig(credential); + } + + @Override + public PayTradeResultBo doPay(PayStrategyContext context) { + // 直接使用 doBeforePay 预组装的通道凭证 + LakalaSdkCredential credential = context.getChannelConfig(LakalaSdkCredential.class); + return lakalaIsvPayService.pay(context.getTrade(), context.getPayParam(), credential); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvRefundStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvRefundStrategy.java new file mode 100644 index 000000000..46781742d --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvRefundStrategy.java @@ -0,0 +1,38 @@ +package cn.daxpay.open.channel.lakala.strategy.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvConfigAssembler; +import cn.daxpay.open.channel.lakala.service.payment.isv.LakalaIsvRefundService; +import cn.daxpay.open.payment.core.strategy.refund.AbsRefundStrategy; +import cn.daxpay.open.payment.core.trade.bo.RefundResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商退款策略 +/// +/// 从退款订单读取通道路由参数(channelMchNo / capability), +/// 组装通道凭证(委托 [LakalaIsvConfigAssembler]), 退款执行委托给 [LakalaIsvRefundService]。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvRefundStrategy extends AbsRefundStrategy { + + private final LakalaIsvRefundService lakalaIsvRefundService; + private final LakalaIsvConfigAssembler lakalaIsvConfigAssembler; + + @Override + public ProductEnum getProduct() { + return ProductEnum.LAKALA_PAY; + } + + @Override + public RefundResultBo doRefund(PayRefundOrder refundOrder) { + // 组装通道调用凭证 + LakalaSdkCredential credential = lakalaIsvConfigAssembler.buildConfig( + refundOrder.getMchNo(), refundOrder.getChannelMchNo(), refundOrder.getCapability()); + return lakalaIsvRefundService.refund(refundOrder, credential); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncRefundStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncRefundStrategy.java new file mode 100644 index 000000000..7aa5fa465 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncRefundStrategy.java @@ -0,0 +1,38 @@ +package cn.daxpay.open.channel.lakala.strategy.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvConfigAssembler; +import cn.daxpay.open.channel.lakala.service.payment.isv.LakalaIsvRefundSyncService; +import cn.daxpay.open.payment.core.strategy.refund.AbsSyncRefundStrategy; +import cn.daxpay.open.payment.core.trade.bo.RefundResultBo; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商退款同步策略 +/// +/// 从退款订单读取通道路由参数(channelMchNo / capability), +/// 组装通道凭证(委托 [LakalaIsvConfigAssembler]), 同步执行委托给 [LakalaIsvRefundSyncService]。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvSyncRefundStrategy extends AbsSyncRefundStrategy { + + private final LakalaIsvRefundSyncService lakalaIsvRefundSyncService; + private final LakalaIsvConfigAssembler lakalaIsvConfigAssembler; + + @Override + public ProductEnum getProduct() { + return ProductEnum.LAKALA_PAY; + } + + @Override + public RefundResultBo doSync(PayRefundOrder refundOrder) { + // 组装通道调用凭证 + LakalaSdkCredential credential = lakalaIsvConfigAssembler.buildConfig( + refundOrder.getMchNo(), refundOrder.getChannelMchNo(), refundOrder.getCapability()); + return lakalaIsvRefundSyncService.sync(refundOrder, credential); + } +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncStrategy.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncStrategy.java new file mode 100644 index 000000000..1afa8f312 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/strategy/isv/LakalaIsvSyncStrategy.java @@ -0,0 +1,45 @@ +package cn.daxpay.open.channel.lakala.strategy.isv; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.service.isv.LakalaIsvConfigAssembler; +import cn.daxpay.open.channel.lakala.service.payment.isv.LakalaIsvSyncService; +import cn.daxpay.open.payment.core.strategy.pay.PayStrategyContext; +import cn.daxpay.open.payment.core.strategy.sync.AbsSyncPayOrderStrategy; +import cn.daxpay.open.payment.core.trade.bo.PaySyncResultBo; +import cn.daxpay.open.payment.core.trade.entity.NormalPayOrder; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 拉卡拉服务商支付同步策略 +/// +/// 从上下文容器读取通道路由参数(channelMchNo / capability), +/// 组装通道凭证(委托 [LakalaIsvConfigAssembler]), 同步执行委托给 [LakalaIsvSyncService]。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaIsvSyncStrategy extends AbsSyncPayOrderStrategy { + + private final LakalaIsvSyncService lakalaIsvSyncService; + private final LakalaIsvConfigAssembler lakalaIsvConfigAssembler; + + @Override + public ProductEnum getProduct() { + return ProductEnum.LAKALA_PAY; + } + + @Override + public PaySyncResultBo doSync(PayStrategyContext context) { + // 从上下文容器读取通道路由参数 + NormalPayOrder normalOrder = context.getContainer(); + String channelMchNo = normalOrder != null ? normalOrder.getChannelMchNo() : null; + String capability = normalOrder != null ? normalOrder.getCapability() : null; + + // 组装通道调用凭证 + LakalaSdkCredential credential = lakalaIsvConfigAssembler.buildConfig( + context.getTrade().getMchNo(), channelMchNo, capability); + + return lakalaIsvSyncService.sync(context.getTrade(), credential); + } +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/channel/error.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/channel/error.json index eae08152f..438bad238 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/channel/error.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/channel/error.json @@ -21,5 +21,12 @@ "alipayPreCreateFailed": "Alipay pre-create order failed: {0}", "alipayPayCallFailed": "Alipay payment call failed: {0}", "alipayCloseOrderFailed": "Alipay close order failed: {0}", - "alipayClientCreateFailed": "Failed to create AlipayClient: {0}" + "alipayClientCreateFailed": "Failed to create AlipayClient: {0}", + "lakalaIsvKeyNotConfigured": "Lakala ISV key is not configured", + "lakalaMerchantDuplicate": "This Lakala merchant number already exists under the merchant", + "lakalaPayFailed": "Lakala payment failed: {0}", + "lakalaRefundFailed": "Lakala refund failed: {0}", + "lakalaSyncFailed": "Lakala order sync failed: {0}", + "lakalaCloseFailed": "Lakala close order failed: {0}", + "lakalaUnsupportedPayMethod": "Unsupported payment method for Lakala: {0}" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/channel/error.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/channel/error.json index 70de003aa..7e23ec4bc 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/channel/error.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/channel/error.json @@ -21,5 +21,12 @@ "alipayPreCreateFailed": "支付宝预创建订单失败: {0}", "alipayPayCallFailed": "支付宝支付调用失败: {0}", "alipayCloseOrderFailed": "支付宝关闭订单失败: {0}", - "alipayClientCreateFailed": "创建AlipayClient失败: {0}" + "alipayClientCreateFailed": "创建AlipayClient失败: {0}", + "lakalaIsvKeyNotConfigured": "拉卡拉服务商密钥未配置", + "lakalaMerchantDuplicate": "该商户下已存在此拉卡拉商户号", + "lakalaPayFailed": "拉卡拉支付失败: {0}", + "lakalaRefundFailed": "拉卡拉退款失败: {0}", + "lakalaSyncFailed": "拉卡拉订单同步失败: {0}", + "lakalaCloseFailed": "拉卡拉关闭订单失败: {0}", + "lakalaUnsupportedPayMethod": "拉卡拉不支持的支付方式: {0}" }