feat(audit): 统一支付接口审计日志与公共参数 reqId

开放 API 公共参数增加必填 reqId 并响应回显;在验签切面异步记录调用审计(mchNo+reqId 索引),不落 appId/业务单号列;运营端日志管理增加支付接口日志菜单与查询能力。
This commit is contained in:
DaxPay Dev
2026-07-17 17:38:15 +08:00
parent 23ffda2c24
commit ad36fe93f7
28 changed files with 984 additions and 41 deletions

View File

@@ -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');

View File

@@ -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');

View File

@@ -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);
-- ----------------------------

View File

@@ -32,6 +32,10 @@ public class DaxResult<T>{
@Schema(description = "响应时间(UTC)")
private OffsetDateTime resTime;
/// 请求ID回显入参 reqId
@Schema(description = "请求ID")
private String reqId;
/// 追踪ID
@Schema(description = "追踪ID")
private String traceId;

View File

@@ -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}")

View File

@@ -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<Void> 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<Void> 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<String, Object> 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;
}
}

View File

@@ -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<PageResult<UnipayApiLogResult>> page(PageParam pageParam, UnipayApiLogQuery query) {
return Res.ok(unipayApiLogService.page(pageParam, query));
}
@PermCode(code = PermCodes.Action.VIEW)
@Operation(summary = "获取")
@GetMapping("/get")
public Result<UnipayApiLogResult> 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<Void> deleteByDay(@NotNull(message = "{validation.field.deleteDay.notNull}") Integer deleteDay) {
unipayApiLogService.deleteByDay(deleteDay);
return Res.ok();
}
}

View File

@@ -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);
}

View File

@@ -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<UnipayApiLogDbMapper, UnipayApiLogDb> {
/// 分页
public Page<UnipayApiLogDb> page(PageParam pageParam, UnipayApiLogQuery query) {
QueryWrapper<UnipayApiLogDb> generator = QueryGenerator.generator(query);
Page<UnipayApiLogDb> mpPage = MpUtil.getMpPage(pageParam);
return this.page(mpPage, generator);
}
/// 删除小于指定时间的日志
public void deleteByOffset(OffsetDateTime offset) {
lambdaUpdate()
.le(UnipayApiLogDb::getOperateTime, offset)
.remove();
}
}

View File

@@ -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<UnipayApiLogDb> {
}

View File

@@ -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<UnipayApiLogResult> {
/// 商户号
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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<UnipayApiLogParam> 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<UnipayApiLogParam> batch = new ArrayList<>(BATCH_SIZE);
bufferQueue.drainTo(batch, BATCH_SIZE);
if (batch.isEmpty()) {
return;
}
try {
List<UnipayApiLogDb> 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<UnipayApiLogParam> remaining = new ArrayList<>();
bufferQueue.drainTo(remaining);
if (remaining.isEmpty()) {
return;
}
try {
List<UnipayApiLogDb> 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<UnipayApiLogResult> 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();
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -789,6 +789,10 @@
},
"riskCheckAfterPay": {
"notNull": "決済後記録スイッチは必須です"
},
"reqId": {
"notBlank": "リクエストIDは必須です",
"size": "リクエストIDは64文字以内です"
}
}
}

View File

@@ -789,6 +789,10 @@
},
"riskCheckAfterPay": {
"notNull": "결제 후 기록 스위치는 필수입니다"
},
"reqId": {
"notBlank": "요청 ID는 필수입니다",
"size": "요청 ID는 64자를 초과할 수 없습니다"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -767,6 +767,10 @@
},
"timestampToleranceSeconds": {
"min": "ค่าคลาดเคลื่อนการประทับเวลาต้องไม่น้อยกว่า 1 วินาที"
},
"reqId": {
"notBlank": "ต้องระบุ Request ID",
"size": "Request ID ต้องไม่เกิน 64 ตัวอักษร"
}
}
}

View File

@@ -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ự"
}
}
}

View File

@@ -789,6 +789,10 @@
},
"riskCheckAfterPay": {
"notNull": "事后补录开关不能为空"
},
"reqId": {
"notBlank": "请求ID不可为空",
"size": "请求ID不可超过64位"
}
}
}

View File

@@ -789,6 +789,10 @@
},
"riskCheckAfterPay": {
"notNull": "事後補錄開關不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"
}
}
}

View File

@@ -789,6 +789,10 @@
},
"riskCheckAfterPay": {
"notNull": "事後補錄開關不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"
}
}
}

View File

@@ -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

View File

@@ -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;
}