diff --git a/daxpay-demo/pom.xml b/daxpay-demo/pom.xml index a8e920a23..268f96313 100644 --- a/daxpay-demo/pom.xml +++ b/daxpay-demo/pom.xml @@ -42,6 +42,13 @@ capability-audit-log ${project.version} + + + + cn.daxpay.open + daxpay-payment-core + ${project.version} + diff --git a/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/controller/TestCallbackController.java b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/controller/TestCallbackController.java new file mode 100644 index 000000000..c3de9ca44 --- /dev/null +++ b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/controller/TestCallbackController.java @@ -0,0 +1,156 @@ +package cn.daxpay.open.demo.callback.controller; + +import cn.daxpay.open.demo.callback.result.TestCallbackRecord; +import cn.daxpay.open.demo.callback.service.TestCallbackStore; +import cn.daxpay.open.payment.common.util.PaySignUtil; +import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties; +import cn.daxpay.open.platform.common.json.util.JacksonUtil; +import cn.daxpay.open.platform.core.annotation.IgnoreAuth; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import cn.hutool.core.lang.UUID; +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; +import tools.jackson.core.type.TypeReference; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +/// # 测试回调接受控制器 +/// +/// 模拟商户端接收平台出站通知([DaxNoticeResult] 签名 JSON), +/// 用于支付/退款回调链路的联调与验证。 +/// +/// 平台出站通知经 [SystemHttpSignedSender] 用平台私钥 RSA 签名后 POST 到商户配置的 notifyUrl, +/// 本端用平台公钥验签并暂存记录, 供 `GET /test/callback/list` 查看联调结果。 +/// +/// Ack 规则: 接收端点(notify/pay/refund)返回固定 "SUCCESS" +/// (平台要求 HTTP 2xx 且 body 等于 SUCCESS, 忽略大小写)。 +/// 管理端点(list/clear)走统一 [Result] 格式, 供人工查看。 +/// +/// 鉴权: `/test/**` 已在白名单, 类上叠加 `@IgnoreAuth` 双保险。 +@Slf4j +@IgnoreAuth +@Tag(name = "测试商户回调接收控制器") +@RestController +@RequestMapping("/test/callback") +@RequiredArgsConstructor +public class TestCallbackController { + + private final PlatformConfigProperties platformConfigProperties; + private final TestCallbackStore store; + + /// 通用通知接收入口(支付/退款均可) + @Operation(summary = "通用通知接收") + @PostMapping("/notify") + public String notify(@RequestBody String body) { + return handle(body); + } + + /// 支付通知接收(语义端点, 商户可将应用 notifyUrl 指向此) + @Operation(summary = "支付通知接收") + @PostMapping("/pay") + public String pay(@RequestBody String body) { + return handle(body); + } + + /// 退款通知接收(语义端点) + @Operation(summary = "退款通知接收") + @PostMapping("/refund") + public String refund(@RequestBody String body) { + return handle(body); + } + + /// 拉取最近的回调接收记录(最新在前) + @Operation(summary = "拉取回调接收记录") + @GetMapping("/list") + public Result> list() { + return Res.ok(store.list()); + } + + /// 清空全部回调接收记录 + @Operation(summary = "清空调回接收记录") + @PostMapping("/clear") + public Result clear() { + store.clear(); + return Res.ok(); + } + + /// 统一处理: 验签 → 解析 → 暂存 → 返回 SUCCESS + private String handle(String body) { + log.info("收到测试回调通知: {}", body); + // 用平台公钥验签 + String publicKey = platformConfigProperties.getKeyConfig().getPublicKey(); + boolean verifyResult = PaySignUtil.verify(body, publicKey); + log.info("回调签名验证结果: {}", verifyResult); + + // 解析报文, 提取关键字段(event 区分支付/退款, data 内业务号等) + String event = null; + String mchNo = null; + String appId = null; + String bizNo = null; + String amount = null; + try { + Map map = JacksonUtil.toBean(body, new TypeReference>() {}); + event = (String) map.get("event"); + mchNo = (String) map.get("mchNo"); + appId = (String) map.get("appId"); + Object dataObj = map.get("data"); + if (dataObj instanceof Map dataMap) { + bizNo = pickFirst(dataMap, "tradeNo", "outTradeNo", "refundNo", "outRefundNo", "orderNo"); + amount = pickFirst(dataMap, "amount", "totalAmount", "payAmount", "refundAmount"); + } + } catch (Exception e) { + // 解析失败不影响 Ack, 仅记录原始报文供排查 + log.warn("解析通知报文失败, 仅记录原始报文", e); + } + + TestCallbackRecord record = new TestCallbackRecord() + .setId(UUID.randomUUID().toString(true)) + .setEvent(event) + .setBizType(resolveBizType(event)) + .setMchNo(mchNo) + .setAppId(appId) + .setBizNo(bizNo) + .setAmount(amount) + .setVerifyResult(verifyResult) + .setReceiveTime(OffsetDateTime.now()) + .setRawBody(StrUtil.sub(body, 0, 2000)); + store.add(record); + return "SUCCESS"; + } + + /// 按 key 顺序取首个非空值 + private String pickFirst(Map map, String... keys) { + for (String key : keys) { + Object val = map.get(key); + if (val != null) { + return String.valueOf(val); + } + } + return null; + } + + /// 由事件码推断业务类型(pay / refund / unknown) + private String resolveBizType(String event) { + if (StrUtil.isBlank(event)) { + return "unknown"; + } + if (event.startsWith("pay")) { + return "pay"; + } + if (event.startsWith("refund")) { + return "refund"; + } + return "unknown"; + } +} diff --git a/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/result/TestCallbackRecord.java b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/result/TestCallbackRecord.java new file mode 100644 index 000000000..ddf4b375e --- /dev/null +++ b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/result/TestCallbackRecord.java @@ -0,0 +1,57 @@ +package cn.daxpay.open.demo.callback.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 测试回调接收记录 +/// +/// 模拟商户端接收平台出站通知([DaxNoticeResult] 签名 JSON)后生成的记录, +/// 暂存于内存供联调时通过 `/test/callback/list` 查看。 +@Data +@Accessors(chain = true) +@Schema(title = "测试回调接收记录") +public class TestCallbackRecord { + + /// 记录 ID + @Schema(description = "记录ID") + private String id; + + /// 通知事件码(如 pay.success / refund.success) + @Schema(description = "通知事件码") + private String event; + + /// 业务类型(pay / refund / unknown, 由 event 前缀解析) + @Schema(description = "业务类型") + private String bizType; + + /// 商户号 + @Schema(description = "商户号") + private String mchNo; + + /// 应用ID + @Schema(description = "应用ID") + private String appId; + + /// 业务号(优先取 tradeNo, 其次 outTradeNo / refundNo / outRefundNo) + @Schema(description = "业务号") + private String bizNo; + + /// 金额(分, 如报文携带) + @Schema(description = "金额(分)") + private String amount; + + /// 签名验证结果 + @Schema(description = "验签结果") + private boolean verifyResult; + + /// 接收时间(UTC) + @Schema(description = "接收时间(UTC)") + private OffsetDateTime receiveTime; + + /// 原始报文(截断保留, 便于排查) + @Schema(description = "原始报文") + private String rawBody; +} diff --git a/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/service/TestCallbackStore.java b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/service/TestCallbackStore.java new file mode 100644 index 000000000..801da1c36 --- /dev/null +++ b/daxpay-demo/src/main/java/cn/daxpay/open/demo/callback/service/TestCallbackStore.java @@ -0,0 +1,50 @@ +package cn.daxpay.open.demo.callback.service; + +import cn.daxpay.open.demo.callback.result.TestCallbackRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; + +/// # 测试回调接收记录内存存储 +/// +/// 演示场景下的回调接收记录暂存地, 进程内存、非持久化。 +/// +/// 设计取舍: +/// - 仅用于 demo 联调, 不做持久化(重启即清空) +/// - 有界容量 100 条, 超出后丢弃最旧记录, 避免内存膨胀 +/// - 使用 {@link ConcurrentLinkedDeque} 保证并发安全 +@Slf4j +@Component +public class TestCallbackStore { + + /// 最大保留条数 + private static final int MAX_SIZE = 100; + + private final ConcurrentLinkedDeque deque = new ConcurrentLinkedDeque<>(); + + /// 追加一条接收记录, 超出容量时淘汰最旧记录 + public void add(TestCallbackRecord record) { + deque.offerFirst(record); + while (deque.size() > MAX_SIZE) { + // 超出容量, 移除最旧(队尾) + TestCallbackRecord removed = deque.pollLast(); + if (removed == null) { + break; + } + } + } + + /// 返回全部记录(最新的在前) + public List list() { + return new ArrayList<>(deque); + } + + /// 清空全部记录 + public void clear() { + deque.clear(); + log.info("测试回调接收记录已清空"); + } +} diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/trade/controller/TestCallbackController.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/trade/controller/TestCallbackController.java deleted file mode 100644 index c2b7561e1..000000000 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/trade/controller/TestCallbackController.java +++ /dev/null @@ -1,35 +0,0 @@ -package cn.daxpay.open.payment.unipay.trade.controller; - -import cn.daxpay.open.platform.core.annotation.IgnoreAuth; -import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties; -import cn.daxpay.open.payment.common.util.PaySignUtil; -import cn.hutool.extra.spring.SpringUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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; - -/// # 测试回调接受控制器 -/// -@Slf4j -@Tag(name = "测试商户回调接收控制器") -@RestController -@RequestMapping("/test/callback") -@RequiredArgsConstructor -@IgnoreAuth -public class TestCallbackController { - - @Operation(summary = "notify") - @PostMapping("/notify") - public String notify(@RequestBody String data){ - log.info("notify:{}",data); - var licenseKey = SpringUtil.getBean(PlatformConfigProperties.class).getKeyConfig(); - boolean verify = PaySignUtil.verify(data, licenseKey.getPublicKey()); - log.info("回调签名认证: :{}",verify); - return "success"; - } -}