diff --git a/_config/sql/iam_perm_menu.sql b/_config/sql/iam_perm_menu.sql index 28c728cdb..0ab993f6d 100644 --- a/_config/sql/iam_perm_menu.sql +++ b/_config/sql/iam_perm_menu.sql @@ -180,3 +180,4 @@ INSERT INTO "public"."iam_perm_menu" VALUES (31102, 311, 'system:sensitive-word- -- Primary Key structure for table iam_perm_menu -- ---------------------------- ALTER TABLE "public"."iam_perm_menu" ADD CONSTRAINT "iam_perm_menu_pkey" PRIMARY KEY ("id"); +INSERT INTO "public"."iam_perm_menu" VALUES (30203, 302, 'system:log:unipay', 'admin', 'SystemUnipayApiLog', 'menu.system.log.unipay', 'lucide:webhook', 'f', 'f', '/system/log/unipay/UnipayApiLogList', '/system/log/unipay', NULL, 3, 'f', 't', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-17 00:00:00+00', '2026-07-17 00:00:00+00'); diff --git a/_config/sql/update-datas.sql b/_config/sql/update-datas.sql index c3b3a729c..3c2b64cb0 100644 --- a/_config/sql/update-datas.sql +++ b/_config/sql/update-datas.sql @@ -103,3 +103,10 @@ INSERT INTO "public"."pay_gateway_cashier_item" ( (9100000000000100026, 'M1781861282382', 'A7846150576259390', 'web', NULL, '微信 H5', 'wechat', 'f', 6, 'method', 'wechat_h5', NULL, NULL, 1, '2026-07-17 00:00:00+00', 1, '2026-07-17 00:00:00+00', 0, 'f'); + +-- ---------------------------- +-- 日志管理:支付接口审计菜单 system:log:unipay +-- ---------------------------- +DELETE FROM "public"."iam_perm_menu" WHERE "id" = 30203; +INSERT INTO "public"."iam_perm_menu" VALUES (30203, 302, 'system:log:unipay', 'admin', 'SystemUnipayApiLog', 'menu.system.log.unipay', 'lucide:webhook', 'f', 'f', '/system/log/unipay/UnipayApiLogList', '/system/log/unipay', NULL, 3, 'f', 't', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-17 00:00:00+00', '2026-07-17 00:00:00+00'); + diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index 11676858b..a2c4fecaa 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -263,3 +263,67 @@ CREATE INDEX IF NOT EXISTS "idx_pay_gateway_cashier_item_bucket" ON "public"."pay_gateway_cashier_item" ("app_id", "cashier_type", "client_env"); CREATE INDEX IF NOT EXISTS "idx_pay_gateway_cashier_item_mch" ON "public"."pay_gateway_cashier_item" ("mch_no", "app_id"); + +-- ---------------------------- +-- Table structure for starter_audit_unipay_log(统一支付接口审计日志) +-- 索引键:mch_no + req_id;不单独存 appId/业务单号(可在 body 中查看) +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "public"."starter_audit_unipay_log" ( + "id" int8 NOT NULL, + "mch_no" varchar(32) COLLATE "pg_catalog"."default", + "req_id" varchar(64) COLLATE "pg_catalog"."default", + "api_path" varchar(256) COLLATE "pg_catalog"."default", + "api_title" varchar(64) COLLATE "pg_catalog"."default", + "request_method" varchar(16) COLLATE "pg_catalog"."default", + "client_ip" varchar(64) COLLATE "pg_catalog"."default", + "request_ip" varchar(64) COLLATE "pg_catalog"."default", + "request_location" varchar(128) COLLATE "pg_catalog"."default", + "success" bool, + "error_code" int4, + "error_msg" varchar(512) COLLATE "pg_catalog"."default", + "duration_ms" int8, + "trace_id" varchar(64) COLLATE "pg_catalog"."default", + "req_param" jsonb, + "res_body" jsonb, + "operate_time" timestamptz(6) +); + +COMMENT ON TABLE "public"."starter_audit_unipay_log" IS '统一支付接口审计日志'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."id" IS '主键'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."mch_no" IS '商户号'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."req_id" IS '请求ID(商户传入,审计主索引)'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."api_path" IS '接口路径'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."api_title" IS '接口标题'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."request_method" IS 'HTTP方法'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."client_ip" IS '商户入参声明的客户端IP'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."request_ip" IS '真实接入IP'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."request_location" IS '接入IP归属地'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."success" IS '是否成功'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."error_code" IS '业务错误码'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."error_msg" IS '错误信息'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."duration_ms" IS '耗时毫秒'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."trace_id" IS '链路追踪ID'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."req_param" IS '请求参数(脱敏后)'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."res_body" IS '响应体(脱敏后)'; +COMMENT ON COLUMN "public"."starter_audit_unipay_log"."operate_time" IS '操作时间UTC'; + +ALTER TABLE "public"."starter_audit_unipay_log" DROP CONSTRAINT IF EXISTS "starter_audit_unipay_log_pkey"; +ALTER TABLE "public"."starter_audit_unipay_log" ADD CONSTRAINT "starter_audit_unipay_log_pkey" PRIMARY KEY ("id"); + +-- 若已按旧版建表:补 req_id、删业务键列(审计表可重建,无历史依赖) +ALTER TABLE "public"."starter_audit_unipay_log" ADD COLUMN IF NOT EXISTS "req_id" varchar(64); +ALTER TABLE "public"."starter_audit_unipay_log" DROP COLUMN IF EXISTS "app_id"; +ALTER TABLE "public"."starter_audit_unipay_log" DROP COLUMN IF EXISTS "channel_mch_no"; +ALTER TABLE "public"."starter_audit_unipay_log" DROP COLUMN IF EXISTS "biz_order_no"; +ALTER TABLE "public"."starter_audit_unipay_log" DROP COLUMN IF EXISTS "order_no"; +ALTER TABLE "public"."starter_audit_unipay_log" DROP COLUMN IF EXISTS "trade_no"; + +DROP INDEX IF EXISTS "idx_starter_audit_unipay_log_biz_order"; +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_time" ON "public"."starter_audit_unipay_log" ("operate_time" DESC); +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_mch_time" ON "public"."starter_audit_unipay_log" ("mch_no", "operate_time" DESC); +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_mch_req" ON "public"."starter_audit_unipay_log" ("mch_no", "req_id"); +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_req" ON "public"."starter_audit_unipay_log" ("req_id"); +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_trace" ON "public"."starter_audit_unipay_log" ("trace_id"); +CREATE INDEX IF NOT EXISTS "idx_starter_audit_unipay_log_success_time" ON "public"."starter_audit_unipay_log" ("success", "operate_time" DESC); + +-- ---------------------------- diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/result/DaxResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/result/DaxResult.java index d142d3827..19cbaee17 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/result/DaxResult.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/result/DaxResult.java @@ -32,6 +32,10 @@ public class DaxResult{ @Schema(description = "响应时间(UTC)") private OffsetDateTime resTime; + /// 请求ID(回显入参 reqId) + @Schema(description = "请求ID") + private String reqId; + /// 追踪ID @Schema(description = "追踪ID") private String traceId; diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/PaymentCommonParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/PaymentCommonParam.java index 29d579d5b..ab7252c1b 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/PaymentCommonParam.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/PaymentCommonParam.java @@ -3,6 +3,7 @@ package cn.daxpay.open.payment.unipay.param; import cn.daxpay.open.platform.core.validation.IpAddress; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @@ -23,6 +24,12 @@ public abstract class PaymentCommonParam { @Size(max=64, message = "{validation.field.clientIp.size}") private String clientIp; + /// 请求ID(商户侧生成,调用追踪与审计索引;参与签名) + @Schema(description = "请求ID") + @NotBlank(message = "{validation.field.reqId.notBlank}") + @Size(max = 64, message = "{validation.field.reqId.size}") + private String reqId; + /// 随机数 @Schema(description = "随机数") @Size(max = 32, message = "{validation.field.nonceStr.size}") diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/aop/PaymentVerifyAspect.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/aop/PaymentVerifyAspect.java index 6d25999c6..70a277d45 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/aop/PaymentVerifyAspect.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/aop/PaymentVerifyAspect.java @@ -1,28 +1,43 @@ package cn.daxpay.open.payment.unipay.aop; +import cn.daxpay.open.payment.common.context.MerchantContextLoader; +import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam; +import cn.daxpay.open.platform.common.json.util.JacksonUtil; +import cn.daxpay.open.platform.common.request.context.RequestContextHolder; +import cn.daxpay.open.platform.common.spring.util.WebServletUtil; +import cn.daxpay.open.platform.core.code.CommonCode; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.event.UnipayApiAccessLogEvent; import cn.daxpay.open.platform.core.exception.BizException; import cn.daxpay.open.platform.core.exception.BizInfoException; -import cn.daxpay.open.platform.core.code.CommonErrorCode; import cn.daxpay.open.platform.core.util.ValidationUtil; -import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam; -import cn.daxpay.open.payment.common.result.DaxResult; -import cn.daxpay.open.payment.common.context.MerchantContextLoader; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.JakartaServletUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; +import org.slf4j.MDC; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; /// # 开放支付验签切面([PaymentVerify]) /// /// 执行顺序: 过滤器 -> 拦截器 -> 切面 -> 方法。 /// 切面负责**签名 API 身份源**:校验参数后 `initMch` + 验签;不负责应用解析。 /// 装载后的 mchNo 供 TenantLine / 自动填充使用。 +/// +/// 同时在 finally 中发布 [UnipayApiAccessLogEvent],失败吞异常,不影响支付主流程。 +/// 审计业务索引:`mchNo` + `reqId`。 @Aspect @Slf4j @Component @@ -32,6 +47,7 @@ public class PaymentVerifyAspect { private final PaymentSignService paymentSignService; private final PaymentReplayProtectService paymentReplayProtectService; private final MerchantContextLoader merchantContextLoader; + private final ApplicationEventPublisher eventPublisher; /// 处理方法上的@PaymentVerify注解 @Around("@annotation(paymentVerify)") @@ -45,44 +61,192 @@ public class PaymentVerifyAspect { return doVerify(pjp); } - /// 支付签名校验逻辑 + /// 支付签名校验逻辑 + 接口审计发布 private Object doVerify(ProceedingJoinPoint pjp) throws Throwable { - Object[] args = pjp.getArgs(); - if (args.length == 0){ - // 支付方法至少有一个参数 - throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.methodParamRequired"); - } - Object param = args[0]; - if (param instanceof MerchantPaymentCommonParam paymentParam){ - // 参数校验 - ValidationUtil.validateParam(paymentParam); - // 商户身份初始化(含状态校验), 使 mchNo 进入线程上下文供签名校验与自动填充 - merchantContextLoader.initMch(paymentParam.getMchNo()); - // 参数签名校验 - paymentSignService.signVerify(paymentParam); - // 防重放校验(Nonce一次性消费 + 请求时间窗口,受平台API安全配置开关控制) - paymentReplayProtectService.verify(paymentParam, paymentParam.getMchNo()); - } else { - // 参数需要继承MerchantPaymentCommonParam - throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.paramExtendRequired"); - } - Object proceed; + long startNs = System.nanoTime(); + Object result = null; + Throwable error = null; + String reqId = null; try { - proceed = pjp.proceed(); - } catch (BizException ex) { - DaxResult result = new DaxResult<>(ex.getCode(), ex.getMessage()); - paymentSignService.sign(result); - return result; + Object[] args = pjp.getArgs(); + if (args.length == 0) { + // 支付方法至少有一个参数 + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.methodParamRequired"); + } + Object param = args[0]; + if (param instanceof MerchantPaymentCommonParam paymentParam) { + reqId = paymentParam.getReqId(); + // 参数校验 + ValidationUtil.validateParam(paymentParam); + // 商户身份初始化(含状态校验), 使 mchNo 进入线程上下文供签名校验与自动填充 + merchantContextLoader.initMch(paymentParam.getMchNo()); + // 参数签名校验 + paymentSignService.signVerify(paymentParam); + // 防重放校验(Nonce一次性消费 + 请求时间窗口,受平台API安全配置开关控制) + paymentReplayProtectService.verify(paymentParam, paymentParam.getMchNo()); + } else { + // 参数需要继承MerchantPaymentCommonParam + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.paramExtendRequired"); + } + Object proceed; + try { + proceed = pjp.proceed(); + } catch (BizException ex) { + DaxResult daxResult = new DaxResult<>(ex.getCode(), ex.getMessage()); + enrichDaxResult(daxResult, reqId); + paymentSignService.sign(daxResult); + result = daxResult; + return daxResult; + } + // 对返回值添加响应时间、reqId/traceId 并进行签名 + if (proceed instanceof DaxResult daxResult) { + daxResult.setResTime(OffsetDateTime.now(ZoneOffset.UTC)); + enrichDaxResult(daxResult, reqId); + paymentSignService.sign(daxResult); + result = daxResult; + } else { + // 支付方法返回类型需要为 DaxResult + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.returnTypeRequired"); + } + return proceed; + } catch (Throwable t) { + error = t; + throw t; + } finally { + // 审计发布:任何异常均吞掉,绝不影响支付 + try { + publishAudit(pjp, startNs, result, error, reqId); + } catch (Exception e) { + log.warn("支付接口审计事件发布失败: {}", e.getMessage()); + } } - // 对返回值添加响应时间并进行签名 - if (proceed instanceof DaxResult result){ - result.setResTime(OffsetDateTime.now(ZoneOffset.UTC)); - paymentSignService.sign(result); - } else { - // 支付方法返回类型需要为 DaxResult - throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.verify.returnTypeRequired"); - } - return proceed; } + /// 回写请求ID与链路追踪ID + private void enrichDaxResult(DaxResult daxResult, String reqId) { + if (StrUtil.isNotBlank(reqId)) { + daxResult.setReqId(reqId); + } + String traceId = resolveTraceId(); + if (StrUtil.isNotBlank(traceId)) { + daxResult.setTraceId(traceId); + } + } + + /// 组装并发布审计事件 + private void publishAudit(ProceedingJoinPoint pjp, long startNs, Object result, Throwable error, String reqId) { + long durationMs = (System.nanoTime() - startNs) / 1_000_000L; + String apiPath = RequestContextHolder.getRequestUri(); + String requestMethod = RequestContextHolder.getMethod(); + + UnipayApiAccessLogEvent event = new UnipayApiAccessLogEvent() + .setApiPath(apiPath) + .setApiTitle(resolveApiTitle(apiPath)) + .setRequestMethod(requestMethod) + .setReqId(reqId) + .setDurationMs(durationMs) + .setOperateTime(OffsetDateTime.now(ZoneOffset.UTC)) + .setTraceId(resolveTraceId()); + + // 真实接入 IP + Optional.ofNullable(WebServletUtil.getRequest()) + .map(JakartaServletUtil::getClientIP) + .ifPresent(event::setRequestIp); + + // 请求参数 + Object[] args = pjp.getArgs(); + Object param = (args != null && args.length > 0) ? args[0] : null; + if (param instanceof MerchantPaymentCommonParam paymentParam) { + event.setMchNo(paymentParam.getMchNo()); + event.setClientIp(paymentParam.getClientIp()); + if (StrUtil.isBlank(event.getReqId())) { + event.setReqId(paymentParam.getReqId()); + } + try { + event.setReqParam(JacksonUtil.toJson(param)); + } catch (Exception e) { + event.setReqParam(null); + } + } else if (param != null) { + try { + event.setReqParam(JacksonUtil.toJson(param)); + } catch (Exception ignored) { + // ignore + } + } + + // 响应结果 + if (result instanceof DaxResult daxResult) { + boolean success = daxResult.getCode() == CommonCode.SUCCESS_CODE; + event.setSuccess(success); + event.setErrorCode(daxResult.getCode()); + if (!success) { + event.setErrorMsg(daxResult.getMsg()); + } + if (StrUtil.isBlank(event.getReqId()) && StrUtil.isNotBlank(daxResult.getReqId())) { + event.setReqId(daxResult.getReqId()); + } + try { + event.setResBody(JacksonUtil.toJson(daxResult)); + } catch (Exception e) { + event.setResBody(null); + } + } else if (error != null) { + event.setSuccess(false); + event.setErrorMsg(StrUtil.sub(error.getMessage(), 0, 512)); + try { + Map errBody = new LinkedHashMap<>(2); + errBody.put("exception", error.getClass().getSimpleName()); + errBody.put("message", error.getMessage()); + event.setResBody(JacksonUtil.toJson(errBody)); + } catch (Exception ignored) { + // ignore + } + } else { + event.setSuccess(true); + } + + eventPublisher.publishEvent(event); + } + + /// 解析链路 ID:优先 MDC,其次请求头 + private String resolveTraceId() { + String fromMdc = MDC.get(CommonCode.TRACE_ID); + if (StrUtil.isNotBlank(fromMdc)) { + return fromMdc; + } + return RequestContextHolder.getTraceId(); + } + + /// 按路径映射可读标题 + private String resolveApiTitle(String path) { + if (StrUtil.isBlank(path)) { + return "统一支付接口"; + } + if (path.contains("/sync/") && path.endsWith("/pay")) { + return "支付订单同步"; + } + if (path.endsWith("/close")) { + return "关单/撤销"; + } + if (path.contains("/pay-order")) { + return "支付订单查询"; + } + if (path.contains("/gateway/pre-pay")) { + return "网关预下单"; + } + if (path.contains("/gateway/query")) { + return "网关订单查询"; + } + if (path.contains("/generate-auth-url")) { + return "生成通道授权链接"; + } + if (path.contains("/assist/channel/auth")) { + return "通道授权"; + } + if (path.endsWith("/pay")) { + return "支付下单"; + } + return path; + } } diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/controller/UnipayApiLogController.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/controller/UnipayApiLogController.java new file mode 100644 index 000000000..94327aeb8 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/controller/UnipayApiLogController.java @@ -0,0 +1,55 @@ +package cn.daxpay.open.platform.capability.audit.log.controller; + +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogQuery; +import cn.daxpay.open.platform.capability.audit.log.result.UnipayApiLogResult; +import cn.daxpay.open.platform.capability.audit.log.service.log.UnipayApiLogService; +import cn.daxpay.open.platform.core.annotation.PermCode; +import cn.daxpay.open.platform.core.code.PermCodes; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.param.PageParam; +import cn.daxpay.open.platform.core.rest.result.PageResult; +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.NotNull; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// # 统一支付接口审计日志 +/// +@PermCode(menuCode = PermCodes.System.Log.Unipay.MENU) +@Validated +@Tag(name = "支付接口日志") +@RestController +@RequestMapping("/log/unipay") +@RequiredArgsConstructor +public class UnipayApiLogController { + + private final UnipayApiLogService unipayApiLogService; + + @PermCode(code = PermCodes.Action.VIEW) + @Operation(summary = "分页") + @GetMapping("/page") + public Result> page(PageParam pageParam, UnipayApiLogQuery query) { + return Res.ok(unipayApiLogService.page(pageParam, query)); + } + + @PermCode(code = PermCodes.Action.VIEW) + @Operation(summary = "获取") + @GetMapping("/get") + public Result findById(@NotNull(message = "{validation.field.id.notNull}") Long id) { + return Res.ok(unipayApiLogService.findById(id)); + } + + @PermCode(code = PermCodes.Action.MANAGE) + @Operation(summary = "清除指定天数之前的日志") + @PostMapping("/delete-by-day") + public Result deleteByDay(@NotNull(message = "{validation.field.deleteDay.notNull}") Integer deleteDay) { + unipayApiLogService.deleteByDay(deleteDay); + return Res.ok(); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/convert/LogConvert.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/convert/LogConvert.java index 2aa5bf2fc..5603d42f0 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/convert/LogConvert.java +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/convert/LogConvert.java @@ -2,10 +2,13 @@ package cn.daxpay.open.platform.capability.audit.log.convert; import cn.daxpay.open.platform.capability.audit.log.entity.LoginLogDb; import cn.daxpay.open.platform.capability.audit.log.entity.OperateLogDb; -import cn.daxpay.open.platform.capability.audit.log.result.LoginLogResult; -import cn.daxpay.open.platform.capability.audit.log.result.OperateLogResult; +import cn.daxpay.open.platform.capability.audit.log.entity.UnipayApiLogDb; import cn.daxpay.open.platform.capability.audit.log.param.LoginLogParam; import cn.daxpay.open.platform.capability.audit.log.param.OperateLogParam; +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogParam; +import cn.daxpay.open.platform.capability.audit.log.result.LoginLogResult; +import cn.daxpay.open.platform.capability.audit.log.result.OperateLogResult; +import cn.daxpay.open.platform.capability.audit.log.result.UnipayApiLogResult; import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; @@ -20,8 +23,12 @@ public interface LogConvert { LoginLogResult convert(LoginLogDb in); + UnipayApiLogResult convert(UnipayApiLogDb in); + OperateLogDb convert(OperateLogParam in); LoginLogDb convert(LoginLogParam in); + UnipayApiLogDb convert(UnipayApiLogParam in); + } diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbManager.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbManager.java new file mode 100644 index 000000000..a91848ea4 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbManager.java @@ -0,0 +1,37 @@ +package cn.daxpay.open.platform.capability.audit.log.dao; + +import cn.daxpay.open.platform.capability.audit.log.entity.UnipayApiLogDb; +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogQuery; +import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager; +import cn.daxpay.open.platform.common.mybatisplus.query.generator.QueryGenerator; +import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil; +import cn.daxpay.open.platform.core.rest.param.PageParam; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; + +/// # 统一支付接口审计日志 Manager +/// +@Slf4j +@Service +@RequiredArgsConstructor +public class UnipayApiLogDbManager extends BaseManager { + + /// 分页 + public Page page(PageParam pageParam, UnipayApiLogQuery query) { + QueryWrapper generator = QueryGenerator.generator(query); + Page mpPage = MpUtil.getMpPage(pageParam); + return this.page(mpPage, generator); + } + + /// 删除小于指定时间的日志 + public void deleteByOffset(OffsetDateTime offset) { + lambdaUpdate() + .le(UnipayApiLogDb::getOperateTime, offset) + .remove(); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbMapper.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbMapper.java new file mode 100644 index 000000000..487530f53 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/dao/UnipayApiLogDbMapper.java @@ -0,0 +1,11 @@ +package cn.daxpay.open.platform.capability.audit.log.dao; + +import cn.daxpay.open.platform.capability.audit.log.entity.UnipayApiLogDb; +import com.github.yulichang.base.MPJBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/// # 统一支付接口审计日志 Mapper +/// +@Mapper +public interface UnipayApiLogDbMapper extends MPJBaseMapper { +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/entity/UnipayApiLogDb.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/entity/UnipayApiLogDb.java new file mode 100644 index 000000000..0c589ba5e --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/entity/UnipayApiLogDb.java @@ -0,0 +1,78 @@ +package cn.daxpay.open.platform.capability.audit.log.entity; + +import cn.daxpay.open.platform.capability.audit.log.convert.LogConvert; +import cn.daxpay.open.platform.capability.audit.log.result.UnipayApiLogResult; +import cn.daxpay.open.platform.common.mybatisplus.base.MpIdEntity; +import cn.daxpay.open.platform.common.mybatisplus.function.ToResult; +import cn.daxpay.open.platform.common.mybatisplus.handler.type.JsonbStringTypeHandler; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 统一支付接口审计日志 +/// +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +@TableName(value = "starter_audit_unipay_log", autoResultMap = true) +public class UnipayApiLogDb extends MpIdEntity implements ToResult { + + /// 商户号 + private String mchNo; + + /// 请求 ID + private String reqId; + + /// 接口路径 + private String apiPath; + + /// 接口标题 + private String apiTitle; + + /// HTTP 方法 + private String requestMethod; + + /// 商户入参声明的客户端 IP + private String clientIp; + + /// 真实接入 IP + private String requestIp; + + /// 接入 IP 归属地 + private String requestLocation; + + /// 是否成功 + private Boolean success; + + /// 业务错误码 + private Integer errorCode; + + /// 错误信息 + private String errorMsg; + + /// 耗时(毫秒) + private Long durationMs; + + /// 链路追踪 ID + private String traceId; + + /// 请求参数(脱敏后) + @TableField(typeHandler = JsonbStringTypeHandler.class) + private String reqParam; + + /// 响应体(脱敏后) + @TableField(typeHandler = JsonbStringTypeHandler.class) + private String resBody; + + /// 操作时间 (UTC) + private OffsetDateTime operateTime; + + @Override + public UnipayApiLogResult toResult() { + return LogConvert.CONVERT.convert(this); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/handler/UnipayApiLogEventListener.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/handler/UnipayApiLogEventListener.java new file mode 100644 index 000000000..2e5f7217b --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/handler/UnipayApiLogEventListener.java @@ -0,0 +1,61 @@ +package cn.daxpay.open.platform.capability.audit.log.handler; + +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogParam; +import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpToRegionService; +import cn.daxpay.open.platform.capability.audit.log.service.log.UnipayApiLogService; +import cn.daxpay.open.platform.core.event.UnipayApiAccessLogEvent; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/// # 统一支付接口审计事件监听 +/// +/// 同步监听仅负责补全归属地并入队,禁止重业务逻辑。 +@Slf4j +@Component +@RequiredArgsConstructor +public class UnipayApiLogEventListener { + + private final UnipayApiLogService unipayApiLogService; + private final IpToRegionService ipToRegionService; + + @EventListener + public void onUnipayApiAccess(UnipayApiAccessLogEvent event) { + if (event == null) { + return; + } + try { + UnipayApiLogParam param = new UnipayApiLogParam() + .setMchNo(event.getMchNo()) + .setReqId(event.getReqId()) + .setApiPath(event.getApiPath()) + .setApiTitle(event.getApiTitle()) + .setRequestMethod(event.getRequestMethod()) + .setClientIp(event.getClientIp()) + .setRequestIp(event.getRequestIp()) + .setSuccess(event.getSuccess()) + .setErrorCode(event.getErrorCode()) + .setErrorMsg(event.getErrorMsg()) + .setDurationMs(event.getDurationMs()) + .setTraceId(event.getTraceId()) + .setReqParam(event.getReqParam()) + .setResBody(event.getResBody()) + .setOperateTime(event.getOperateTime()); + + // IP 归属地 + if (StrUtil.isNotBlank(param.getRequestIp())) { + try { + param.setRequestLocation(ipToRegionService.getRegionStrByIp(param.getRequestIp())); + } catch (Exception e) { + param.setRequestLocation("未知"); + } + } + + unipayApiLogService.add(param); + } catch (Exception e) { + log.warn("处理支付接口审计事件失败: {}", e.getMessage()); + } + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogParam.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogParam.java new file mode 100644 index 000000000..ba592ccd4 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogParam.java @@ -0,0 +1,63 @@ +package cn.daxpay.open.platform.capability.audit.log.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 统一支付接口审计日志写入参数 +/// +@Data +@Accessors(chain = true) +@Schema(description = "统一支付接口审计日志写入参数") +public class UnipayApiLogParam { + + @Schema(description = "商户号") + private String mchNo; + + @Schema(description = "请求ID") + private String reqId; + + @Schema(description = "接口路径") + private String apiPath; + + @Schema(description = "接口标题") + private String apiTitle; + + @Schema(description = "HTTP 方法") + private String requestMethod; + + @Schema(description = "商户入参声明的客户端 IP") + private String clientIp; + + @Schema(description = "真实接入 IP") + private String requestIp; + + @Schema(description = "接入 IP 归属地") + private String requestLocation; + + @Schema(description = "是否成功") + private Boolean success; + + @Schema(description = "业务错误码") + private Integer errorCode; + + @Schema(description = "错误信息") + private String errorMsg; + + @Schema(description = "耗时(毫秒)") + private Long durationMs; + + @Schema(description = "链路追踪 ID") + private String traceId; + + @Schema(description = "请求参数 JSON") + private String reqParam; + + @Schema(description = "响应体 JSON") + private String resBody; + + @Schema(description = "操作时间 (UTC)") + private OffsetDateTime operateTime; +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogQuery.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogQuery.java new file mode 100644 index 000000000..9352d420e --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/param/UnipayApiLogQuery.java @@ -0,0 +1,40 @@ +package cn.daxpay.open.platform.capability.audit.log.param; + +import cn.daxpay.open.platform.core.annotation.QueryParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 统一支付接口审计日志查询参数 +/// +@QueryParam(type = QueryParam.CompareTypeEnum.LIKE) +@Data +@Accessors(chain = true) +@Schema(description = "统一支付接口审计日志查询参数") +public class UnipayApiLogQuery { + + @QueryParam(type = QueryParam.CompareTypeEnum.EQ) + @Schema(description = "商户号") + private String mchNo; + + @QueryParam(type = QueryParam.CompareTypeEnum.EQ) + @Schema(description = "请求ID") + private String reqId; + + @Schema(description = "接口路径") + private String apiPath; + + @Schema(description = "接口标题") + private String apiTitle; + + @QueryParam(type = QueryParam.CompareTypeEnum.EQ) + @Schema(description = "是否成功") + private Boolean success; + + @QueryParam(type = QueryParam.CompareTypeEnum.EQ) + @Schema(description = "链路追踪 ID") + private String traceId; + + @Schema(description = "真实接入 IP") + private String requestIp; +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/result/UnipayApiLogResult.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/result/UnipayApiLogResult.java new file mode 100644 index 000000000..f5e4c07f1 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/result/UnipayApiLogResult.java @@ -0,0 +1,66 @@ +package cn.daxpay.open.platform.capability.audit.log.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 统一支付接口审计日志结果 +/// +@Data +@Accessors(chain = true) +@Schema(description = "统一支付接口审计日志结果") +public class UnipayApiLogResult { + + @Schema(description = "日志 id") + private Long id; + + @Schema(description = "商户号") + private String mchNo; + + @Schema(description = "请求ID") + private String reqId; + + @Schema(description = "接口路径") + private String apiPath; + + @Schema(description = "接口标题") + private String apiTitle; + + @Schema(description = "HTTP 方法") + private String requestMethod; + + @Schema(description = "商户入参声明的客户端 IP") + private String clientIp; + + @Schema(description = "真实接入 IP") + private String requestIp; + + @Schema(description = "接入 IP 归属地") + private String requestLocation; + + @Schema(description = "是否成功") + private Boolean success; + + @Schema(description = "业务错误码") + private Integer errorCode; + + @Schema(description = "错误信息") + private String errorMsg; + + @Schema(description = "耗时(毫秒)") + private Long durationMs; + + @Schema(description = "链路追踪 ID") + private String traceId; + + @Schema(description = "请求参数") + private String reqParam; + + @Schema(description = "响应体") + private String resBody; + + @Schema(description = "操作时间 (UTC)") + private OffsetDateTime operateTime; +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/service/log/UnipayApiLogService.java b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/service/log/UnipayApiLogService.java new file mode 100644 index 000000000..1f0688955 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-audit-log/src/main/java/cn/daxpay/open/platform/capability/audit/log/service/log/UnipayApiLogService.java @@ -0,0 +1,172 @@ +package cn.daxpay.open.platform.capability.audit.log.service.log; + +import cn.daxpay.open.platform.capability.audit.log.convert.LogConvert; +import cn.daxpay.open.platform.capability.audit.log.dao.UnipayApiLogDbManager; +import cn.daxpay.open.platform.capability.audit.log.entity.UnipayApiLogDb; +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogParam; +import cn.daxpay.open.platform.capability.audit.log.param.UnipayApiLogQuery; +import cn.daxpay.open.platform.capability.audit.log.result.UnipayApiLogResult; +import cn.daxpay.open.platform.capability.audit.log.service.mask.AuditLogMaskService; +import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil; +import cn.daxpay.open.platform.core.exception.DataNotExistException; +import cn.daxpay.open.platform.core.rest.param.PageParam; +import cn.daxpay.open.platform.core.rest.result.PageResult; +import cn.hutool.core.util.StrUtil; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/// # 统一支付接口审计日志服务 +/// +/// 写入策略与操作日志类似,但队列满时**丢弃**(禁止同步写库,避免拖慢支付)。 +/// 请求/响应 body 强制脱敏并截断。 +@Slf4j +@Service +@RequiredArgsConstructor +public class UnipayApiLogService { + + /// 队列容量(高于操作日志,支付流量更大) + private static final int DEFAULT_QUEUE_CAPACITY = 10000; + + /// 单次最大批量保存条数 + private static final int BATCH_SIZE = 200; + + /// body 最大长度(字符) + private static final int PAYLOAD_MAX_LENGTH = 8192; + + /// 支付接口强制全量脱敏键(sign 非敏感密钥,不脱敏) + private static final String[] UNIPAY_FULL_MASK_KEYS = { + "password", "token", "secret", "credential", "accesstoken", "refreshtoken", + "idcard", "phone", "mobile", "bankcard", "cvv", "ssn", "passport", + "authcode", "paybody" + }; + + private final UnipayApiLogDbManager unipayApiLogManager; + private final AuditLogMaskService maskService; + + /// 内存缓冲队列 + private final BlockingQueue bufferQueue = new LinkedBlockingQueue<>(DEFAULT_QUEUE_CAPACITY); + + /// 添加入队:强制脱敏 + 截断;队列满则丢弃 + public void add(UnipayApiLogParam param) { + if (param == null) { + return; + } + + try { + // 请求参数强制脱敏截断 + if (StrUtil.isNotEmpty(param.getReqParam())) { + param.setReqParam(maskService.process( + param.getReqParam(), + true, + UNIPAY_FULL_MASK_KEYS, + null, + PAYLOAD_MAX_LENGTH)); + } + // 响应体强制脱敏截断 + if (StrUtil.isNotEmpty(param.getResBody())) { + param.setResBody(maskService.process( + param.getResBody(), + true, + UNIPAY_FULL_MASK_KEYS, + null, + PAYLOAD_MAX_LENGTH)); + } + // 错误信息截断 + if (StrUtil.isNotEmpty(param.getErrorMsg()) && param.getErrorMsg().length() > 512) { + param.setErrorMsg(param.getErrorMsg().substring(0, 512)); + } + + boolean offered = bufferQueue.offer(param); + if (!offered) { + // 队列满:丢弃,绝不同步写库拖慢支付 + log.warn("支付接口审计队列已满,丢弃日志 path={} mchNo={}", param.getApiPath(), param.getMchNo()); + } + } catch (Exception e) { + log.warn("支付接口审计入队失败 path={}: {}", param.getApiPath(), e.getMessage()); + } + } + + /// 定时批量保存(每 1 秒) + @Scheduled(fixedDelay = 1000) + public void flushBuffer() { + if (bufferQueue.isEmpty()) { + return; + } + + List batch = new ArrayList<>(BATCH_SIZE); + bufferQueue.drainTo(batch, BATCH_SIZE); + if (batch.isEmpty()) { + return; + } + + try { + List entities = batch.stream() + .map(LogConvert.CONVERT::convert) + .toList(); + unipayApiLogManager.saveAll(entities); + log.debug("支付接口审计批量保存成功,数量: {}", entities.size()); + } catch (Exception e) { + log.error("支付接口审计批量保存失败,待处理条数: {}", batch.size(), e); + for (UnipayApiLogParam item : batch) { + try { + unipayApiLogManager.save(LogConvert.CONVERT.convert(item)); + } catch (Exception ex) { + log.error("支付接口审计逐条写入失败 path={}", item.getApiPath(), ex); + } + } + } + } + + /// 应用关闭时刷空队列 + @PreDestroy + public void shutdown() { + log.info("正在刷空支付接口审计缓冲队列..."); + List remaining = new ArrayList<>(); + bufferQueue.drainTo(remaining); + if (remaining.isEmpty()) { + return; + } + try { + List entities = remaining.stream() + .map(LogConvert.CONVERT::convert) + .toList(); + unipayApiLogManager.saveAll(entities); + log.info("应用关闭前保存剩余支付接口审计: {} 条", entities.size()); + } catch (Exception e) { + log.error("应用关闭前保存剩余支付接口审计失败", e); + } + } + + /// 详情 + public UnipayApiLogResult findById(Long id) { + return unipayApiLogManager.findById(id) + .map(UnipayApiLogDb::toResult) + .orElseThrow(DataNotExistException::new); + } + + /// 分页 + public PageResult page(PageParam pageParam, UnipayApiLogQuery query) { + return MpUtil.toPageResult(unipayApiLogManager.page(pageParam, query)); + } + + /// 按天数清理 + public void deleteByDay(int deleteDay) { + OffsetDateTime offset = OffsetDateTime.now(ZoneOffset.UTC).minusDays(deleteDay); + unipayApiLogManager.deleteByOffset(offset); + } + + /// 当前队列大小(监控用) + public int getQueueSize() { + return bufferQueue.size(); + } +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json index 6483db3c5..dc3f13dfd 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "Post-pay logging toggle cannot be empty" + }, + "reqId": { + "notBlank": "Request ID is required", + "size": "Request ID must not exceed 64 characters" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json index 204b71f9f..f3a969c3d 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json @@ -767,6 +767,10 @@ }, "timestampToleranceSeconds": { "min": "Toleransi stempel waktu tidak boleh kurang dari 1 detik" + }, + "reqId": { + "notBlank": "Request ID wajib diisi", + "size": "Request ID maksimal 64 karakter" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json index 7e5e98412..bdae16871 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "決済後記録スイッチは必須です" + }, + "reqId": { + "notBlank": "リクエストIDは必須です", + "size": "リクエストIDは64文字以内です" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json index d3bdad2aa..cc6701b25 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "결제 후 기록 스위치는 필수입니다" + }, + "reqId": { + "notBlank": "요청 ID는 필수입니다", + "size": "요청 ID는 64자를 초과할 수 없습니다" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json index 48aeaf626..e8e467537 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json @@ -767,6 +767,10 @@ }, "timestampToleranceSeconds": { "min": "Toleransi cap masa tidak boleh kurang daripada 1 saat" + }, + "reqId": { + "notBlank": "Request ID diperlukan", + "size": "Request ID tidak boleh melebihi 64 aksara" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json index 40d921a02..746f7f545 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json @@ -767,6 +767,10 @@ }, "timestampToleranceSeconds": { "min": "ค่าคลาดเคลื่อนการประทับเวลาต้องไม่น้อยกว่า 1 วินาที" + }, + "reqId": { + "notBlank": "ต้องระบุ Request ID", + "size": "Request ID ต้องไม่เกิน 64 ตัวอักษร" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json index ec831225a..0cde3f466 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json @@ -767,6 +767,10 @@ }, "timestampToleranceSeconds": { "min": "Dung sai dấu thời gian không được nhỏ hơn 1 giây" + }, + "reqId": { + "notBlank": "Request ID không được để trống", + "size": "Request ID không quá 64 ký tự" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json index e97b06dcf..e7bc6538e 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "事后补录开关不能为空" + }, + "reqId": { + "notBlank": "请求ID不可为空", + "size": "请求ID不可超过64位" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json index 2e47dcf74..05f6eb8a5 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "事後補錄開關不能為空" + }, + "reqId": { + "notBlank": "請求ID不可為空", + "size": "請求ID不可超過64位" } } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json index fac305109..60604d5fe 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json @@ -789,6 +789,10 @@ }, "riskCheckAfterPay": { "notNull": "事後補錄開關不能為空" + }, + "reqId": { + "notBlank": "請求ID不可為空", + "size": "請求ID不可超過64位" } } } diff --git a/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java index 0f262d916..e4dafc8a8 100644 --- a/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java +++ b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java @@ -288,6 +288,12 @@ public interface PermCodes { /// 菜单编码 String MENU = "system:log:operate"; } + + /// 支付接口日志 menuCode=system:log:unipay + interface Unipay { + /// 菜单编码 + String MENU = "system:log:unipay"; + } } /// 公告通知 menuCode=system:notify:notice diff --git a/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/event/UnipayApiAccessLogEvent.java b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/event/UnipayApiAccessLogEvent.java new file mode 100644 index 000000000..6d734a3e6 --- /dev/null +++ b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/event/UnipayApiAccessLogEvent.java @@ -0,0 +1,60 @@ +package cn.daxpay.open.platform.core.event; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 统一支付接口访问审计事件 +/// +/// 由开放支付验签切面发布,审计模块异步落库。纯 POJO,无 Spring 依赖。 +/// 业务索引:`mchNo` + `reqId`;不单独索引 appId/业务单号。 +@Data +@Accessors(chain = true) +public class UnipayApiAccessLogEvent { + + /// 商户号 + private String mchNo; + + /// 请求 ID(商户传入,审计主索引) + private String reqId; + + /// 接口路径 + private String apiPath; + + /// 接口标题 + private String apiTitle; + + /// HTTP 方法 + private String requestMethod; + + /// 商户入参声明的客户端 IP + private String clientIp; + + /// 真实接入 IP + private String requestIp; + + /// 是否成功 + private Boolean success; + + /// 业务错误码 + private Integer errorCode; + + /// 错误信息 + private String errorMsg; + + /// 耗时(毫秒) + private Long durationMs; + + /// 链路追踪 ID + private String traceId; + + /// 请求参数 JSON(未脱敏,由审计模块强制脱敏) + private String reqParam; + + /// 响应体 JSON(未脱敏,由审计模块强制脱敏) + private String resBody; + + /// 操作时间 (UTC) + private OffsetDateTime operateTime; +}