feat(pay): tenant-scope native pay and add legacy account/transaction import

This commit is contained in:
2026-08-01 12:18:10 +08:00
parent 891c461aff
commit abf82f86ab
63 changed files with 2320 additions and 56 deletions

View File

@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
import jakarta.servlet.http.HttpServletRequest;
@@ -43,13 +44,19 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
// 打印 request 日志
if (!SpringUtils.isProd()) {
Map<String, String> queryString = ServletUtils.getParamMap(request);
String requestBody = ServletUtils.getBody(request);
if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
log.info("[preHandle][开始请求 URL({}) 参数]", request.getRequestURI());
ApiAccessLog accessLog = handlerMethod != null
? handlerMethod.getMethodAnnotation(ApiAccessLog.class) : null;
if (accessLog != null && !accessLog.requestEnable()) {
log.info("[preHandle][开始请求 URL({}) 参数日志已关闭]", request.getRequestURI());
} else {
log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(),
StrUtil.blankToDefault(requestBody, queryString.toString()));
Map<String, String> queryString = ServletUtils.getParamMap(request);
String requestBody = ServletUtils.getBody(request);
if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI());
} else {
log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(),
StrUtil.blankToDefault(requestBody, queryString.toString()));
}
}
// 计时
StopWatch stopWatch = new StopWatch();

View File

@@ -5,6 +5,8 @@ import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.apilog.core.interceptor.ApiAccessLogInterceptor;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
@@ -34,6 +36,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
@@ -372,9 +375,13 @@ public class GlobalExceptionHandler {
errorLog.setTraceId(TracerUtils.getTraceId());
errorLog.setApplicationName(applicationName);
errorLog.setRequestUrl(request.getRequestURI());
HandlerMethod handlerMethod = (HandlerMethod) request.getAttribute(
ApiAccessLogInterceptor.ATTRIBUTE_HANDLER_METHOD);
ApiAccessLog accessLog = handlerMethod != null ? handlerMethod.getMethodAnnotation(ApiAccessLog.class) : null;
Map<String, Object> requestParams = MapUtil.<String, Object>builder()
.put("query", ServletUtils.getParamMap(request))
.put("body", ServletUtils.getBody(request)).build();
.put("body", accessLog != null && !accessLog.requestEnable() ? null : ServletUtils.getBody(request))
.build();
errorLog.setRequestParams(JsonUtils.toJsonString(requestParams));
errorLog.setRequestMethod(request.getMethod());
errorLog.setUserAgent(ServletUtils.getUserAgent(request));

View File

@@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.pay.api.wallet.dto;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import lombok.Data;
/**
@@ -18,6 +21,7 @@ public class PayWalletAddBalanceReqDTO {
* 关联 MemberUserDO 的 id 属性,或者 AdminUserDO 的 id 属性
*/
@NotNull(message = "用户编号不能为空")
@Positive(message = "用户编号必须大于零")
private Long userId;
/**
* 用户类型
@@ -31,6 +35,7 @@ public class PayWalletAddBalanceReqDTO {
* 关联业务分类
*/
@NotNull(message = "关联业务分类不能为空")
@InEnum(PayWalletBizTypeEnum.class)
private Integer bizType;
/**
* 关联业务编号
@@ -44,6 +49,7 @@ public class PayWalletAddBalanceReqDTO {
* 正值表示余额增加,负值表示余额减少
*/
@NotNull(message = "交易金额不能为空")
@Positive(message = "交易金额必须大于零")
private Integer price;
}

View File

@@ -99,7 +99,7 @@ public class PayAppController {
@GetMapping("/list")
@Operation(summary = "获得应用列表")
@PreAuthorize("@ss.hasPermission('pay:merchant:query')")
@PreAuthorize("@ss.hasPermission('pay:app:query')")
public CommonResult<List<PayAppRespVO>> getAppList() {
List<PayAppDO> appListDO = appService.getAppList();
return success(PayAppConvert.INSTANCE.convertList(appListDO));

View File

@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportPageReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportRespVO;
import cn.iocoder.yudao.module.pay.service.legacy.PayLegacyAccountImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 旧支付账号迁移")
@RestController
@RequestMapping("/pay/legacy-account-import")
@Validated
public class PayLegacyAccountImportController {
@Resource
private PayLegacyAccountImportService importService;
@PostMapping("/import")
@Operation(summary = "显式、幂等地导入一个旧租户支付账号")
@ApiAccessLog(requestEnable = false)
@PreAuthorize("@ss.hasPermission('pay:app:create') and @ss.hasPermission('pay:channel:create')")
public CommonResult<PayLegacyAccountImportRespVO> importAccount(
@Valid @RequestBody PayLegacyAccountImportReqVO reqVO) {
return success(importService.importAccount(reqVO, getLoginUserId()));
}
@GetMapping("/page")
@Operation(summary = "获得旧支付账号导入审计分页")
@PreAuthorize("@ss.hasPermission('pay:app:query') and @ss.hasPermission('pay:channel:query')")
public CommonResult<PageResult<PayLegacyAccountImportRespVO>> getImportPage(
@Valid PayLegacyAccountImportPageReqVO reqVO) {
return success(importService.getImportPage(reqVO));
}
}

View File

@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.*;
import cn.iocoder.yudao.module.pay.service.legacy.PayLegacyTransactionImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 旧支付交易迁移")
@RestController
@RequestMapping("/pay/legacy-transaction-import")
@Validated
public class PayLegacyTransactionImportController {
@Resource private PayLegacyTransactionImportService importService;
@PostMapping("/import")
@Operation(summary = "显式、幂等地导入一个已终结并对账的旧支付交易聚合")
@ApiAccessLog(requestEnable = false)
@PreAuthorize("@ss.hasPermission('pay:legacy-transaction:import')")
public CommonResult<PayLegacyTransactionImportRespVO> importTransaction(
@Valid @RequestBody PayLegacyTransactionImportReqVO reqVO) {
return success(importService.importTransaction(reqVO, getLoginUserId()));
}
@GetMapping("/page")
@Operation(summary = "获得旧支付交易导入审计分页")
@PreAuthorize("@ss.hasPermission('pay:legacy-transaction:query')")
public CommonResult<PageResult<PayLegacyTransactionImportRespVO>> getImportPage(
@Valid PayLegacyTransactionImportPageReqVO reqVO) {
return success(importService.getImportPage(reqVO));
}
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 旧支付账号导入记录分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class PayLegacyAccountImportPageReqVO extends PageParam {
@Schema(description = "旧系统租户 UUID")
private String sourceTenantId;
@Schema(description = "规范化 Provider", example = "wechat_pay")
private String normalizedProvider;
}

View File

@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.hibernate.validator.constraints.URL;
import java.util.Map;
@Schema(description = "管理后台 - 旧支付账号导入 Request VO")
@Data
public class PayLegacyAccountImportReqVO {
private static final String UUID_PATTERN =
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
@Schema(description = "旧系统租户 UUID仅用于审计", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "旧系统租户 ID 不能为空")
@Pattern(regexp = UUID_PATTERN, message = "旧系统租户 ID 必须为 UUID")
private String sourceTenantId;
@Schema(description = "旧支付账号 UUID作为幂等业务键", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "旧支付账号 ID 不能为空")
@Pattern(regexp = UUID_PATTERN, message = "旧支付账号 ID 必须为 UUID")
private String sourceAccountId;
@Schema(description = "旧账号及其密钥导出内容的 SHA-256", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "来源校验和不能为空")
@Pattern(regexp = "^[0-9a-fA-F]{64}$", message = "来源校验和必须为 64 位 SHA-256 十六进制")
private String sourceChecksumSha256;
@Schema(description = "旧 Provider可使用受支持的历史别名", requiredMode = Schema.RequiredMode.REQUIRED,
example = "wechat_pay")
@NotBlank(message = "旧 Provider 不能为空")
@Size(max = 32, message = "旧 Provider 最长 32 个字符")
private String sourceProvider;
@Schema(description = "旧收款模式;仅 tenant_collect 可安全自动迁移", requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"platform_collect", "tenant_collect", "service_provider"})
@NotBlank(message = "旧收款模式不能为空")
@Pattern(regexp = "^(platform_collect|tenant_collect|service_provider)$", message = "旧收款模式不合法")
private String sourceMode;
@Schema(description = "旧账号状态", requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"active", "disabled", "pending"})
@NotBlank(message = "旧账号状态不能为空")
@Pattern(regexp = "^(active|disabled|pending)$", message = "旧账号状态不合法")
private String sourceStatus;
@Schema(description = "显式选择的原生 Pay 渠道;不会根据 Provider 猜测", requiredMode = Schema.RequiredMode.REQUIRED,
example = "wx_lite")
@NotBlank(message = "目标渠道不能为空")
@InEnum(value = PayChannelEnum.class, message = "目标渠道不合法")
private String targetChannelCode;
@Schema(description = "原生 Pay 应用标识", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "应用标识不能为空")
@Size(max = 64, message = "应用标识最长 64 个字符")
private String appKey;
@Schema(description = "原生 Pay 应用名称", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "应用名称不能为空")
@Size(max = 64, message = "应用名称最长 64 个字符")
private String appName;
@Schema(description = "新系统业务支付结果回调,不复用旧 Provider 回调", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "支付结果回调地址不能为空")
@URL(message = "支付结果回调地址必须为 URL")
private String orderNotifyUrl;
@Schema(description = "新系统业务退款结果回调,不复用旧 Provider 回调", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "退款结果回调地址不能为空")
@URL(message = "退款结果回调地址必须为 URL")
private String refundNotifyUrl;
@Schema(description = "新系统业务转账结果回调")
@URL(message = "转账结果回调地址必须为 URL")
private String transferNotifyUrl;
@Schema(description = "旧 tenant_payment_accounts.config_public", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "旧公开配置不能为空")
private Map<String, Object> configPublic;
@Schema(description = "旧 app_private.tenant_secrets.secret_json只写入原生 Pay 渠道配置,不写入导入审计",
requiredMode = Schema.RequiredMode.REQUIRED, writeOnly = true)
@NotNull(message = "旧私密配置不能为空")
private Map<String, Object> secretJson;
}

View File

@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 旧支付账号导入 Response VO")
@Data
@Builder
public class PayLegacyAccountImportRespVO {
private Long id;
private String sourceTenantId;
private String sourceAccountId;
private String normalizedProvider;
private String sourceMode;
private String sourceStatus;
private Long targetAppId;
private Long targetChannelId;
private String targetChannelCode;
private Integer targetStatus;
private String normalizedConfigDigest;
private List<String> mappingNotes;
private Long importedBy;
private LocalDateTime importedAt;
private Boolean replayed;
}

View File

@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 旧支付交易导入审计分页 Request VO")
@Data
public class PayLegacyTransactionImportPageReqVO extends PageParam {
private String sourceTenantId;
private String sourceOrderNo;
private String sourceOrderStatus;
}

View File

@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 旧支付交易聚合导入 Request VO")
@Data
public class PayLegacyTransactionImportReqVO {
private static final String UUID =
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
private static final String SHA256 = "^[0-9a-fA-F]{64}$";
@NotBlank @Pattern(regexp = UUID)
private String sourceTenantId;
@NotNull @Positive
private Long sourceAccountImportId;
@NotBlank @Pattern(regexp = UUID)
private String sourceOrderId;
@NotBlank @Pattern(regexp = SHA256)
private String sourceChecksumSha256;
@NotBlank @Size(max = 64)
private String sourceOrderNo;
@NotBlank @Pattern(regexp = "^(paid|failed|closed|partially_refunded|refunded)$")
private String sourceOrderStatus;
@Positive
private Long targetUserId;
@NotBlank @Size(max = 32)
private String subject;
@Size(max = 128)
private String body;
@NotNull @Positive
private Integer price;
@NotNull @PositiveOrZero
private Integer refundedPrice;
@NotNull
private LocalDateTime sourceCreatedAt;
private LocalDateTime sourcePaidAt;
@NotEmpty @Size(max = 20) @Valid
private List<Payment> payments;
@NotNull @Size(max = 50) @Valid
private List<Refund> refunds;
@Data
public static class Payment {
@NotBlank @Pattern(regexp = UUID)
private String sourcePaymentId;
@NotBlank @Size(max = 32)
private String sourceProvider;
@Size(max = 40)
private String sourceMethod;
@NotBlank @Pattern(regexp = "^(paid|failed|cancelled|partially_refunded|refunded)$")
private String sourceStatus;
@NotNull @Positive
private Integer amount;
@NotBlank @Size(max = 64)
private String providerOutTradeNo;
@Size(max = 64)
private String providerTradeNo;
private LocalDateTime sourcePaidAt;
@NotNull
private LocalDateTime sourceCreatedAt;
@NotNull @PositiveOrZero
private Integer sourceEventCount;
@Pattern(regexp = SHA256)
private String sourceEventDigest;
}
@Data
public static class Refund {
@NotBlank @Pattern(regexp = UUID)
private String sourceRefundId;
@NotBlank @Size(max = 32)
private String sourceProvider;
@NotBlank @Pattern(regexp = "^(succeeded|failed|rejected|cancelled)$")
private String sourceStatus;
@NotBlank @Size(max = 64)
private String refundNo;
@Size(max = 64)
private String providerRefundNo;
@NotNull @Positive
private Integer amount;
@NotBlank @Size(max = 128)
private String reason;
private LocalDateTime sourceSucceededAt;
@NotNull
private LocalDateTime sourceCreatedAt;
}
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 旧支付交易导入 Response VO")
@Data
@Builder
public class PayLegacyTransactionImportRespVO {
private Long id;
private String sourceTenantId;
private String sourceOrderId;
private String sourceOrderNo;
private String sourceOrderStatus;
private Integer sourcePaymentCount;
private Integer sourcePaymentEventCount;
private Integer sourceRefundCount;
private Integer sourceRefundedPrice;
private Long sourceAccountImportId;
private Long targetAppId;
private Long targetChannelId;
private Long targetOrderId;
private String normalizedPayloadDigest;
private List<String> mappingNotes;
private Long importedBy;
private LocalDateTime importedAt;
private Boolean replayed;
}

View File

@@ -62,8 +62,13 @@ public class PayWalletController {
}
// 更新钱包余额
payWalletService.addWalletBalance(wallet.getId(), String.valueOf(updateReqVO.getUserId()),
PayWalletBizTypeEnum.UPDATE_BALANCE, updateReqVO.getBalance());
if (updateReqVO.getBalance() > 0) {
payWalletService.addWalletBalance(wallet.getId(), String.valueOf(updateReqVO.getUserId()),
PayWalletBizTypeEnum.UPDATE_BALANCE, updateReqVO.getBalance());
} else {
payWalletService.reduceWalletBalance(wallet.getId(), updateReqVO.getUserId(),
PayWalletBizTypeEnum.UPDATE_BALANCE, -updateReqVO.getBalance());
}
return success(true);
}

View File

@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
@@ -40,6 +41,7 @@ public class PayWalletRechargeController {
@PostMapping("/refund")
@Operation(summary = "发起钱包充值退款")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('pay:wallet-recharge:refund')")
public CommonResult<Boolean> refundWalletRecharge(@RequestParam("id") Long id) {
walletRechargeService.refundWalletRecharge(id, getClientIP());
return success(true);

View File

@@ -3,7 +3,13 @@ package cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.rechargepackage;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/**
* 充值套餐 Base VO提供给添加、修改、详细的子 VO 使用
@@ -13,19 +19,24 @@ import jakarta.validation.constraints.NotNull;
public class WalletRechargePackageBaseVO {
@Schema(description = "套餐名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
@NotNull(message = "套餐名不能为空")
@NotBlank(message = "套餐名不能为空")
@Size(max = 64, message = "套餐名不能超过 64 个字符")
private String name;
@Schema(description = "支付金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "16454")
@NotNull(message = "支付金额不能为空")
@Positive(message = "支付金额必须大于零")
private Integer payPrice;
@Schema(description = "赠送金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "20887")
@NotNull(message = "赠送金额不能为空")
@PositiveOrZero(message = "赠送金额不能小于零")
private Integer bonusPrice;
@Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@NotNull(message = "状态不能为空")
@Min(value = 0, message = "状态必须为启用或停用")
@Max(value = 1, message = "状态必须为启用或停用")
private Byte status;
}

View File

@@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.Data;
@Schema(description = "管理后台 - 修改钱包余额 Request VO")
@@ -10,10 +13,17 @@ public class PayWalletUpdateBalanceReqVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23788")
@NotNull(message = "用户编号不能为空")
@Positive(message = "用户编号必须大于零")
private Long userId;
@Schema(description = "变动余额,正数为增加,负数为减少", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
@NotNull(message = "变动余额不能为空")
@Min(value = -2147483647, message = "变动余额超出支持范围")
private Integer balance;
@AssertTrue(message = "变动余额不能为零")
public boolean isBalanceChanged() {
return balance == null || balance != 0;
}
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import java.util.Objects;
@Schema(description = "用户 APP - 创建钱包充值 Request VO")
@@ -16,6 +17,7 @@ public class AppPayWalletRechargeCreateReqVO {
private Integer payPrice;
@Schema(description = "充值套餐编号", example = "1024")
@Positive(message = "充值套餐编号必须大于零")
private Long packageId;
@AssertTrue(message = "充值金额和充钱套餐不能同时为空")

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.app;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -24,7 +24,7 @@ import lombok.*;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayAppDO extends BaseDO {
public class PayAppDO extends TenantBaseDO {
/**
* 应用编号,数据库自增

View File

@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.legacy;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.List;
@TableName(value = "pay_legacy_account_import", autoResultMap = true)
@KeySequence("pay_legacy_account_import_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayLegacyAccountImportDO extends TenantBaseDO {
private Long id;
private String sourceTenantId;
private String sourceAccountId;
private String sourceChecksumSha256;
private String sourceProvider;
private String normalizedProvider;
private String sourceMode;
private String sourceStatus;
private Long targetAppId;
private Long targetChannelId;
private String targetChannelCode;
private Integer targetStatus;
private String normalizedConfigDigest;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> mappingNotes;
private Long importedBy;
private LocalDateTime importedAt;
}

View File

@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.legacy;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
@TableName(value = "pay_legacy_transaction_import", autoResultMap = true)
@KeySequence("pay_legacy_transaction_import_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayLegacyTransactionImportDO extends TenantBaseDO {
private Long id;
private String sourceTenantId;
private String sourceOrderId;
private String sourceChecksumSha256;
private String sourceOrderNo;
private String sourceOrderStatus;
private Integer sourcePaymentCount;
private Integer sourcePaymentEventCount;
private Integer sourceRefundCount;
private Integer sourceRefundedPrice;
private Long sourceAccountImportId;
private Long targetAppId;
private Long targetChannelId;
private Long targetOrderId;
private String normalizedPayloadDigest;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> mappingNotes;
private Long importedBy;
private LocalDateTime importedAt;
}

View File

@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.legacy;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@TableName("pay_legacy_transaction_payment_import")
@KeySequence("pay_legacy_transaction_payment_import_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayLegacyTransactionPaymentImportDO extends TenantBaseDO {
private Long id;
private Long transactionImportId;
private String sourcePaymentId;
private String sourceStatus;
private String sourceProvider;
private Integer sourceEventCount;
private String sourceEventDigest;
private Long targetExtensionId;
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.legacy;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@TableName("pay_legacy_transaction_refund_import")
@KeySequence("pay_legacy_transaction_refund_import_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayLegacyTransactionRefundImportDO extends TenantBaseDO {
private Long id;
private Long transactionImportId;
private String sourceRefundId;
private String sourceStatus;
private Long targetRefundId;
}

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.notify;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@@ -19,7 +19,7 @@ import lombok.*;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayNotifyLogDO extends BaseDO {
public class PayNotifyLogDO extends TenantBaseDO {
/**
* 日志编号,自增

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.order;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
@@ -24,7 +24,7 @@ import java.time.LocalDateTime;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayOrderDO extends BaseDO {
public class PayOrderDO extends TenantBaseDO {
/**
* 订单编号,数据库自增

View File

@@ -1,9 +1,9 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.order;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderRespDTO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderRespDTO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -27,7 +27,7 @@ import java.util.Map;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayOrderExtensionDO extends BaseDO {
public class PayOrderExtensionDO extends TenantBaseDO {
/**
* 订单拓展编号,数据库自增

View File

@@ -1,12 +1,12 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.refund;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -30,7 +30,7 @@ import java.time.LocalDateTime;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayRefundDO extends BaseDO {
public class PayRefundDO extends TenantBaseDO {
/**
* 退款单编号,数据库自增

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.transfer;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
@@ -23,7 +23,7 @@ import java.util.Map;
@TableName(value ="pay_transfer", autoResultMap = true)
@KeySequence("pay_transfer_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class PayTransferDO extends BaseDO {
public class PayTransferDO extends TenantBaseDO {
/**
* 编号
@@ -149,4 +149,4 @@ public class PayTransferDO extends BaseDO {
*/
private String channelPackageInfo;
}
}

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.wallet;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -15,7 +15,7 @@ import lombok.Data;
@TableName(value ="pay_wallet")
@KeySequence("pay_wallet_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class PayWalletDO extends BaseDO {
public class PayWalletDO extends TenantBaseDO {
/**
* 编号

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.wallet;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName(value ="pay_wallet_recharge")
@KeySequence("pay_wallet_recharge_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class PayWalletRechargeDO extends BaseDO {
public class PayWalletRechargeDO extends TenantBaseDO {
/**
* 编号

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.wallet;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -16,7 +16,7 @@ import lombok.Data;
@TableName(value ="pay_wallet_recharge_package")
@KeySequence("pay_wallet_recharge_package_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class PayWalletRechargePackageDO extends BaseDO {
public class PayWalletRechargePackageDO extends TenantBaseDO {
/**
* 编号

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.wallet;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -15,7 +15,7 @@ import lombok.Data;
@TableName(value ="pay_wallet_transaction")
@KeySequence("pay_wallet_transaction_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
public class PayWalletTransactionDO extends BaseDO {
public class PayWalletTransactionDO extends TenantBaseDO {
/**
* 编号

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.pay.dal.mysql.legacy;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportPageReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyAccountImportDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PayLegacyAccountImportMapper extends BaseMapperX<PayLegacyAccountImportDO> {
default PayLegacyAccountImportDO selectBySourceAccountId(String sourceAccountId) {
return selectOne(PayLegacyAccountImportDO::getSourceAccountId, sourceAccountId);
}
default PageResult<PayLegacyAccountImportDO> selectPage(PayLegacyAccountImportPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<PayLegacyAccountImportDO>()
.eqIfPresent(PayLegacyAccountImportDO::getSourceTenantId, reqVO.getSourceTenantId())
.eqIfPresent(PayLegacyAccountImportDO::getNormalizedProvider, reqVO.getNormalizedProvider())
.orderByDesc(PayLegacyAccountImportDO::getId));
}
}

View File

@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.pay.dal.mysql.legacy;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportPageReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyTransactionImportDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PayLegacyTransactionImportMapper extends BaseMapperX<PayLegacyTransactionImportDO> {
default PayLegacyTransactionImportDO selectBySourceOrderId(String sourceOrderId) {
return selectOne(PayLegacyTransactionImportDO::getSourceOrderId, sourceOrderId);
}
default PageResult<PayLegacyTransactionImportDO> selectPage(PayLegacyTransactionImportPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<PayLegacyTransactionImportDO>()
.eqIfPresent(PayLegacyTransactionImportDO::getSourceTenantId, reqVO.getSourceTenantId())
.likeIfPresent(PayLegacyTransactionImportDO::getSourceOrderNo, reqVO.getSourceOrderNo())
.eqIfPresent(PayLegacyTransactionImportDO::getSourceOrderStatus, reqVO.getSourceOrderStatus())
.orderByDesc(PayLegacyTransactionImportDO::getId));
}
}

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.pay.dal.mysql.legacy;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyTransactionPaymentImportDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PayLegacyTransactionPaymentImportMapper extends BaseMapperX<PayLegacyTransactionPaymentImportDO> {
}

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.pay.dal.mysql.legacy;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyTransactionRefundImportDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PayLegacyTransactionRefundImportMapper extends BaseMapperX<PayLegacyTransactionRefundImportDO> {
}

View File

@@ -81,6 +81,16 @@ public interface PayWalletMapper extends BaseMapperX<PayWalletDO> {
update(null, lambdaUpdateWrapper);
}
/**
* 管理员调减余额,不计入会员消费累计。
*/
default int updateWhenSubtract(Long id, Integer price) {
return update(null, new LambdaUpdateWrapper<PayWalletDO>()
.setSql(" balance = balance - " + price)
.eq(PayWalletDO::getId, id)
.ge(PayWalletDO::getBalance, price));
}
/**
* 冻结钱包部分余额
*
@@ -131,4 +141,3 @@ public interface PayWalletMapper extends BaseMapperX<PayWalletDO> {

View File

@@ -19,11 +19,11 @@ public interface RedisKeyConstants {
/**
* 支付钱包的分布式锁
*
* KEY 格式pay_wallet:lock:%d
* KEY 格式pay_wallet:lock:{tenantId}:{walletOrUserId}
* VALUE 数据格式HASH // RLock.classRedisson 的 Lock 锁,使用 Hash 数据结构
* 过期时间:不固定
*/
String PAY_WALLET_LOCK = "pay_wallet:lock:%d";
String PAY_WALLET_LOCK = "pay_wallet:lock:%d:%d";
/**
* 支付序号的缓存

View File

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import static cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder.getRequiredTenantId;
import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_WALLET_LOCK;
/**
@@ -36,7 +37,7 @@ public class PayWalletLockRedisDAO {
}
private static String formatKey(Long id) {
return String.format(PAY_WALLET_LOCK, id);
return String.format(PAY_WALLET_LOCK, getRequiredTenantId(), id);
}
}

View File

@@ -21,6 +21,30 @@ public interface ErrorCodeConstants {
ErrorCode CHANNEL_IS_DISABLE = new ErrorCode(1_007_001_001, "支付渠道已经禁用");
ErrorCode CHANNEL_EXIST_SAME_CHANNEL_ERROR = new ErrorCode(1_007_001_004, "已存在相同的渠道");
// ========== LEGACY ACCOUNT IMPORT 模块 1-007-001-100 ==========
ErrorCode LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_PROVIDER = new ErrorCode(1_007_001_100,
"旧支付 Provider 不支持自动迁移:{}");
ErrorCode LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_MODE = new ErrorCode(1_007_001_101,
"旧收款模式不支持自动迁移:{}");
ErrorCode LEGACY_ACCOUNT_IMPORT_CHANNEL_MISMATCH = new ErrorCode(1_007_001_102,
"目标渠道 {} 与旧支付 Provider {} 不匹配");
ErrorCode LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING = new ErrorCode(1_007_001_103,
"旧支付配置缺少迁移必需字段:{}");
ErrorCode LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE = new ErrorCode(1_007_001_104,
"旧支付配置需要人工确认:{}");
ErrorCode LEGACY_ACCOUNT_IMPORT_CONFLICT = new ErrorCode(1_007_001_105,
"旧支付账号已按不同校验和迁移,禁止覆盖:{}");
ErrorCode LEGACY_TRANSACTION_IMPORT_ACCOUNT_INVALID = new ErrorCode(1_007_001_106,
"旧交易关联的支付账号导入记录无效:{}");
ErrorCode LEGACY_TRANSACTION_IMPORT_CONFLICT = new ErrorCode(1_007_001_107,
"旧支付订单已按不同校验和迁移,禁止覆盖:{}");
ErrorCode LEGACY_TRANSACTION_IMPORT_UNSAFE_STATE = new ErrorCode(1_007_001_108,
"旧支付交易包含不可安全接管的非终态:{}");
ErrorCode LEGACY_TRANSACTION_IMPORT_RECONCILIATION = new ErrorCode(1_007_001_109,
"旧支付交易金额或状态对账失败:{}");
ErrorCode LEGACY_TRANSACTION_IMPORT_PROVIDER_MISMATCH = new ErrorCode(1_007_001_110,
"旧支付交易 Provider 与已迁移账号不一致:{}");
// ========== ORDER 模块 1-007-002-000 ==========
ErrorCode PAY_ORDER_NOT_FOUND = new ErrorCode(1_007_002_000, "支付订单不存在");
ErrorCode PAY_ORDER_STATUS_IS_NOT_WAITING = new ErrorCode(1_007_002_001, "支付订单不处于待支付");

View File

@@ -228,7 +228,7 @@ public class WalletPayClient extends AbstractPayClient<NonePayClientConfig> {
// 成功状态
if (PayTransferStatusEnum.isSuccess(transfer.getStatus())) {
PayWalletTransactionDO walletTransaction = walletTransactionService.getWalletTransaction(
String.valueOf(transfer.getId()), PayWalletBizTypeEnum.TRANSFER);
transfer.getNo(), PayWalletBizTypeEnum.TRANSFER);
Assert.notNull(walletTransaction, "转账单 {} 钱包流水不能为空", outTradeNo);
return PayTransferRespDTO.successOf(walletTransaction.getNo(), walletTransaction.getCreateTime(),
outTradeNo, walletTransaction);

View File

@@ -16,6 +16,7 @@ import cn.iocoder.yudao.module.pay.dal.mysql.channel.PayChannelMapper;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.NonePayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.alipay.AlipayPayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import lombok.extern.slf4j.Slf4j;
@@ -44,11 +45,15 @@ public class PayChannelServiceImpl implements PayChannelService {
@Resource
private PayChannelMapper payChannelMapper;
@Resource
private PayAppService payAppService;
@Resource
private Validator validator;
@Override
public Long createChannel(PayChannelCreateReqVO reqVO) {
validateAppExists(reqVO.getAppId());
// 断言是否有重复的
PayChannelDO dbChannel = getChannelByAppIdAndCode(reqVO.getAppId(), reqVO.getCode());
if (dbChannel != null) {
@@ -66,6 +71,7 @@ public class PayChannelServiceImpl implements PayChannelService {
public void updateChannel(PayChannelUpdateReqVO updateReqVO) {
// 校验存在
PayChannelDO dbChannel = validateChannelExists(updateReqVO.getId());
validateAppExists(updateReqVO.getAppId());
// 更新
PayChannelDO channel = PayChannelConvert.INSTANCE.convert(updateReqVO)
@@ -113,6 +119,12 @@ public class PayChannelServiceImpl implements PayChannelService {
return channel;
}
private void validateAppExists(Long appId) {
if (payAppService.getApp(appId) == null) {
throw exception(APP_NOT_FOUND);
}
}
@Override
public PayChannelDO getChannel(Long id) {
return payChannelMapper.selectById(id);

View File

@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportPageReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportRespVO;
import jakarta.validation.Valid;
public interface PayLegacyAccountImportService {
PayLegacyAccountImportRespVO importAccount(@Valid PayLegacyAccountImportReqVO reqVO, Long importedBy);
PageResult<PayLegacyAccountImportRespVO> getImportPage(PayLegacyAccountImportPageReqVO reqVO);
}

View File

@@ -0,0 +1,274 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.channel.vo.PayChannelCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportPageReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportRespVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyAccountImportDO;
import cn.iocoder.yudao.module.pay.dal.mysql.legacy.PayLegacyAccountImportMapper;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.alipay.AlipayPayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CHANNEL_MISMATCH;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFLICT;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_MODE;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_PROVIDER;
@Service
@Validated
public class PayLegacyAccountImportServiceImpl implements PayLegacyAccountImportService {
private static final Set<String> ALIPAY_SERVER_URLS = Set.of(
"https://openapi.alipay.com/gateway.do",
"https://openapi-sandbox.dl.alipaydev.com/gateway.do");
@Resource
private PayLegacyAccountImportMapper importMapper;
@Resource
private PayAppService appService;
@Resource
private PayChannelService channelService;
@Override
@Transactional(rollbackFor = Exception.class)
public PayLegacyAccountImportRespVO importAccount(PayLegacyAccountImportReqVO reqVO, Long importedBy) {
PayLegacyAccountImportDO existing = importMapper.selectBySourceAccountId(reqVO.getSourceAccountId());
if (existing != null) {
if (!existing.getSourceChecksumSha256().equalsIgnoreCase(reqVO.getSourceChecksumSha256())) {
throw exception(LEGACY_ACCOUNT_IMPORT_CONFLICT, reqVO.getSourceAccountId());
}
return toResp(existing, true);
}
if (!"tenant_collect".equals(reqVO.getSourceMode())) {
throw exception(LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_MODE, reqVO.getSourceMode());
}
String provider = normalizeProvider(reqVO.getSourceProvider());
validateChannelFamily(provider, reqVO.getTargetChannelCode());
List<String> notes = new ArrayList<>();
if (!provider.equals(reqVO.getSourceProvider().trim().toLowerCase(Locale.ROOT))) {
notes.add("Provider 别名已规范化为 " + provider);
}
notes.add("旧 Provider 回调地址不迁移Pay App 使用本次显式提供的新业务回调");
if (!"active".equals(reqVO.getSourceStatus())) {
notes.add("旧状态 " + reqVO.getSourceStatus() + " 已安全映射为禁用");
}
PayClientConfig config = "wechat_pay".equals(provider)
? mapWechatConfig(reqVO, notes) : mapAlipayConfig(reqVO, notes);
String configJson = JsonUtils.toJsonString(config);
String configDigest = DigestUtil.sha256Hex(configJson);
Integer targetStatus = "active".equals(reqVO.getSourceStatus())
? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus();
PayAppCreateReqVO appReqVO = new PayAppCreateReqVO();
appReqVO.setAppKey(reqVO.getAppKey());
appReqVO.setName(reqVO.getAppName());
appReqVO.setStatus(targetStatus);
appReqVO.setRemark("由旧支付账号 " + reqVO.getSourceAccountId() + " 审计导入");
appReqVO.setOrderNotifyUrl(reqVO.getOrderNotifyUrl());
appReqVO.setRefundNotifyUrl(reqVO.getRefundNotifyUrl());
appReqVO.setTransferNotifyUrl(reqVO.getTransferNotifyUrl());
Long appId = appService.createApp(appReqVO);
PayChannelCreateReqVO channelReqVO = new PayChannelCreateReqVO();
channelReqVO.setCode(reqVO.getTargetChannelCode());
channelReqVO.setAppId(appId);
channelReqVO.setStatus(targetStatus);
channelReqVO.setFeeRate(0D);
channelReqVO.setRemark("由旧 Provider " + reqVO.getSourceProvider() + " 审计导入");
channelReqVO.setConfig(configJson);
Long channelId = channelService.createChannel(channelReqVO);
LocalDateTime now = LocalDateTime.now();
PayLegacyAccountImportDO record = PayLegacyAccountImportDO.builder()
.sourceTenantId(reqVO.getSourceTenantId().toLowerCase(Locale.ROOT))
.sourceAccountId(reqVO.getSourceAccountId().toLowerCase(Locale.ROOT))
.sourceChecksumSha256(reqVO.getSourceChecksumSha256().toLowerCase(Locale.ROOT))
.sourceProvider(reqVO.getSourceProvider().trim())
.normalizedProvider(provider)
.sourceMode(reqVO.getSourceMode())
.sourceStatus(reqVO.getSourceStatus())
.targetAppId(appId)
.targetChannelId(channelId)
.targetChannelCode(reqVO.getTargetChannelCode())
.targetStatus(targetStatus)
.normalizedConfigDigest(configDigest)
.mappingNotes(List.copyOf(notes))
.importedBy(importedBy)
.importedAt(now)
.build();
importMapper.insert(record);
return toResp(record, false);
}
@Override
public PageResult<PayLegacyAccountImportRespVO> getImportPage(PayLegacyAccountImportPageReqVO reqVO) {
PageResult<PayLegacyAccountImportDO> page = importMapper.selectPage(reqVO);
return new PageResult<>(page.getList().stream().map(record -> toResp(record, false)).toList(), page.getTotal());
}
private String normalizeProvider(String value) {
String normalized = normalizeToken(value);
if (Set.of("wechat", "wechatpay", "wxpay", "wx_pay", "wechat_pay").contains(normalized)) {
return "wechat_pay";
}
if (Set.of("alipay", "ali_pay").contains(normalized)) {
return "alipay";
}
throw exception(LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_PROVIDER, value);
}
private String normalizeToken(String value) {
return value.trim().toLowerCase(Locale.ROOT).replace('-', '_').replace(' ', '_');
}
private void validateChannelFamily(String provider, String channelCode) {
boolean matches = "wechat_pay".equals(provider) ? PayChannelEnum.isWeixin(channelCode)
: PayChannelEnum.isAlipay(channelCode);
if (!matches) {
throw exception(LEGACY_ACCOUNT_IMPORT_CHANNEL_MISMATCH, channelCode, provider);
}
}
private WxPayClientConfig mapWechatConfig(PayLegacyAccountImportReqVO reqVO, List<String> notes) {
Map<String, Object> publicConfig = reqVO.getConfigPublic();
Map<String, Object> secretConfig = reqVO.getSecretJson();
WxPayClientConfig config = new WxPayClientConfig();
config.setApiVersion(WxPayClientConfig.API_VERSION_V3);
config.setAppId(required(publicConfig, "appId"));
config.setMchId(required(publicConfig, "merchantId", "mchId"));
config.setCertSerialNo(required(publicConfig, "merchantSerialNo", "certSerialNo", "serialNo"));
config.setPrivateKeyContent(required(secretConfig, "privateKey", "merchantPrivateKey", "privateKeyContent"));
config.setApiV3Key(required(secretConfig, "apiV3Key", "apiv3Key"));
String publicKey = optional(publicConfig, "wechatpayPublicKey", "platformCertificatePublicKey", "publicKeyContent");
String publicKeyId = optional(publicConfig, "wechatpayPublicKeyId", "platformCertificateSerial",
"wechatpaySerial", "publicKeyId");
Map<String, String> rotatingKeys = stringMap(publicConfig.get("wechatpayPublicKeys"));
if (publicKey.isEmpty() && rotatingKeys.size() == 1) {
Map.Entry<String, String> entry = rotatingKeys.entrySet().iterator().next();
publicKeyId = entry.getKey();
publicKey = entry.getValue();
notes.add("单个旧微信平台公钥已映射为原生 Pay publicKeyId/publicKeyContent");
} else if (publicKey.isEmpty() && rotatingKeys.size() > 1) {
throw exception(LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE,
"微信轮换公钥多于一个,请先显式选择当前 publicKeyId/publicKeyContent");
}
if (publicKeyId.isEmpty()) {
throw exception(LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING, "wechatpayPublicKeyId/publicKeyId");
}
config.setPublicKeyId(publicKeyId);
config.setPublicKeyContent(publicKey.isEmpty() ? null : publicKey);
if (publicConfig.containsKey("endpoint") || publicConfig.containsKey("refundEndpoint")) {
notes.add("旧微信自定义 endpoint 未迁移;原生 Pay SDK 使用其受支持的官方协议端点");
}
return config;
}
private AlipayPayClientConfig mapAlipayConfig(PayLegacyAccountImportReqVO reqVO, List<String> notes) {
Map<String, Object> publicConfig = reqVO.getConfigPublic();
Map<String, Object> secretConfig = reqVO.getSecretJson();
String serverUrl = optional(publicConfig, "endpoint", "serverUrl");
if (serverUrl.isEmpty()) {
serverUrl = "https://openapi.alipay.com/gateway.do";
}
if (!ALIPAY_SERVER_URLS.contains(serverUrl)) {
throw exception(LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE,
"支付宝 endpoint 不是原生 Pay 允许的生产或沙箱网关");
}
AlipayPayClientConfig config = new AlipayPayClientConfig();
config.setServerUrl(serverUrl);
config.setAppId(required(publicConfig, "appId"));
config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT);
config.setMode(AlipayPayClientConfig.MODE_PUBLIC_KEY);
config.setPrivateKey(required(secretConfig, "privateKey", "appPrivateKey"));
config.setAlipayPublicKey(required(secretConfig, "alipayPublicKey", "publicKey"));
if (publicConfig.containsKey("sellerId") || publicConfig.containsKey("seller_id")
|| publicConfig.containsKey("pid")) {
notes.add("旧 sellerId 仅用于旧回调校验,原生 Pay 渠道配置不保存该字段");
}
if (publicConfig.containsKey("returnUrl") || publicConfig.containsKey("quitUrl")) {
notes.add("旧 returnUrl/quitUrl 不属于原生渠道凭据,未迁移");
}
return config;
}
private String required(Map<String, Object> values, String... keys) {
String value = optional(values, keys);
if (value.isEmpty()) {
throw exception(LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING, String.join("/", keys));
}
return value;
}
private String optional(Map<String, Object> values, String... keys) {
for (String key : keys) {
Object value = values.get(key);
if (value instanceof String string && !string.isBlank()) {
return string.trim();
}
}
return "";
}
private Map<String, String> stringMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, String> result = new LinkedHashMap<>();
map.forEach((key, item) -> {
if (key instanceof String stringKey && item instanceof String stringValue
&& !stringKey.isBlank() && !stringValue.isBlank()) {
result.put(stringKey.trim(), stringValue.trim());
}
});
return result;
}
private PayLegacyAccountImportRespVO toResp(PayLegacyAccountImportDO record, boolean replayed) {
return PayLegacyAccountImportRespVO.builder()
.id(record.getId())
.sourceTenantId(record.getSourceTenantId())
.sourceAccountId(record.getSourceAccountId())
.normalizedProvider(record.getNormalizedProvider())
.sourceMode(record.getSourceMode())
.sourceStatus(record.getSourceStatus())
.targetAppId(record.getTargetAppId())
.targetChannelId(record.getTargetChannelId())
.targetChannelCode(record.getTargetChannelCode())
.targetStatus(record.getTargetStatus())
.normalizedConfigDigest(record.getNormalizedConfigDigest())
.mappingNotes(record.getMappingNotes())
.importedBy(record.getImportedBy())
.importedAt(record.getImportedAt())
.replayed(replayed)
.build();
}
}

View File

@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportPageReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportRespVO;
public interface PayLegacyTransactionImportService {
PayLegacyTransactionImportRespVO importTransaction(PayLegacyTransactionImportReqVO reqVO, Long importedBy);
PageResult<PayLegacyTransactionImportRespVO> getImportPage(PayLegacyTransactionImportPageReqVO reqVO);
}

View File

@@ -0,0 +1,262 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.*;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.*;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
import cn.iocoder.yudao.module.pay.dal.mysql.legacy.*;
import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper;
import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper;
import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper;
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
@Service
@Validated
public class PayLegacyTransactionImportServiceImpl implements PayLegacyTransactionImportService {
private static final Set<String> SUCCESS_PAYMENT_STATUSES =
Set.of("paid", "partially_refunded", "refunded");
@Resource private PayLegacyTransactionImportMapper importMapper;
@Resource private PayLegacyTransactionPaymentImportMapper paymentImportMapper;
@Resource private PayLegacyTransactionRefundImportMapper refundImportMapper;
@Resource private PayLegacyAccountImportMapper accountImportMapper;
@Resource private PayOrderMapper orderMapper;
@Resource private PayOrderExtensionMapper extensionMapper;
@Resource private PayRefundMapper refundMapper;
@Resource private PayAppService appService;
@Resource private PayChannelService channelService;
@Override
@Transactional(rollbackFor = Exception.class)
public PayLegacyTransactionImportRespVO importTransaction(PayLegacyTransactionImportReqVO req, Long importedBy) {
String sourceOrderId = lower(req.getSourceOrderId());
PayLegacyTransactionImportDO existing = importMapper.selectBySourceOrderId(sourceOrderId);
if (existing != null) {
if (!existing.getSourceChecksumSha256().equalsIgnoreCase(req.getSourceChecksumSha256())) {
throw exception(LEGACY_TRANSACTION_IMPORT_CONFLICT, sourceOrderId);
}
return toResp(existing, true);
}
PayLegacyAccountImportDO account = accountImportMapper.selectById(req.getSourceAccountImportId());
if (account == null || !account.getSourceTenantId().equalsIgnoreCase(req.getSourceTenantId())) {
throw exception(LEGACY_TRANSACTION_IMPORT_ACCOUNT_INVALID, req.getSourceAccountImportId());
}
PayAppDO app = appService.getApp(account.getTargetAppId());
PayChannelDO channel = channelService.getChannel(account.getTargetChannelId());
if (app == null || channel == null || !Objects.equals(channel.getAppId(), app.getId())
|| !Objects.equals(channel.getCode(), account.getTargetChannelCode())) {
throw exception(LEGACY_TRANSACTION_IMPORT_ACCOUNT_INVALID, req.getSourceAccountImportId());
}
if (app.getOrderNotifyUrl() == null || app.getOrderNotifyUrl().isBlank()
|| app.getRefundNotifyUrl() == null || app.getRefundNotifyUrl().isBlank()) {
throw exception(LEGACY_TRANSACTION_IMPORT_ACCOUNT_INVALID, "目标 Pay App 缺少业务回调");
}
Validation result = validate(req, account.getNormalizedProvider());
validateNativeNumberAvailability(req, app.getId());
LocalDateTime importedAt = LocalDateTime.now();
PayOrderDO order = buildOrder(req, app, channel, result.orderStatus());
orderMapper.insert(order);
PayOrderExtensionDO successfulExtension = null;
Map<String, PayOrderExtensionDO> extensions = new LinkedHashMap<>();
for (PayLegacyTransactionImportReqVO.Payment payment : req.getPayments()) {
int status = SUCCESS_PAYMENT_STATUSES.contains(payment.getSourceStatus())
? PayOrderStatusEnum.SUCCESS.getStatus() : PayOrderStatusEnum.CLOSED.getStatus();
PayOrderExtensionDO extension = PayOrderExtensionDO.builder()
.no(payment.getProviderOutTradeNo()).orderId(order.getId()).channelId(channel.getId())
.channelCode(channel.getCode()).userIp("0.0.0.0").status(status)
.channelExtras(Map.of()).channelNotifyData(null).build();
extension.setCreateTime(payment.getSourceCreatedAt());
extensionMapper.insert(extension);
extensions.put(lower(payment.getSourcePaymentId()), extension);
if (status == PayOrderStatusEnum.SUCCESS.getStatus()) successfulExtension = extension;
}
if (successfulExtension != null) {
PayLegacyTransactionImportReqVO.Payment payment = result.successfulPayment();
PayOrderDO update = new PayOrderDO().setExtensionId(successfulExtension.getId())
.setNo(successfulExtension.getNo()).setChannelOrderNo(payment.getProviderTradeNo());
orderMapper.updateById(update.setId(order.getId()));
order.setExtensionId(successfulExtension.getId()).setNo(successfulExtension.getNo())
.setChannelOrderNo(payment.getProviderTradeNo());
}
Map<String, PayRefundDO> refunds = new LinkedHashMap<>();
for (PayLegacyTransactionImportReqVO.Refund source : req.getRefunds()) {
int status = "succeeded".equals(source.getSourceStatus())
? PayRefundStatusEnum.SUCCESS.getStatus() : PayRefundStatusEnum.FAILURE.getStatus();
PayRefundDO refund = PayRefundDO.builder().no(source.getRefundNo()).appId(app.getId())
.channelId(channel.getId()).channelCode(channel.getCode()).orderId(order.getId())
.orderNo(order.getNo()).userId(req.getTargetUserId())
.userType(req.getTargetUserId() == null ? null : UserTypeEnum.MEMBER.getValue())
.merchantOrderId(req.getSourceOrderNo()).merchantRefundId(source.getRefundNo())
.notifyUrl(app.getRefundNotifyUrl()).status(status).payPrice(req.getPrice())
.refundPrice(source.getAmount()).reason(source.getReason()).userIp("0.0.0.0")
.channelOrderNo(order.getChannelOrderNo()).channelRefundNo(source.getProviderRefundNo())
.successTime(source.getSourceSucceededAt()).channelNotifyData(null).build();
refund.setCreateTime(source.getSourceCreatedAt());
refundMapper.insert(refund);
refunds.put(lower(source.getSourceRefundId()), refund);
}
List<String> notes = List.of(
"仅导入已终结且金额对账通过的交易;未调用渠道 SDK、业务回调或通知任务",
"旧 raw_payload、payment_events.payload 与错误原文未复制,仅保留数量和 SHA-256 摘要",
"旧模型无可靠用户 IP 与渠道手续费,分别使用 0.0.0.0 与 0");
String digest = DigestUtil.sha256Hex(JsonUtils.toJsonString(req));
int eventCount = req.getPayments().stream().mapToInt(PayLegacyTransactionImportReqVO.Payment::getSourceEventCount).sum();
PayLegacyTransactionImportDO audit = PayLegacyTransactionImportDO.builder()
.sourceTenantId(lower(req.getSourceTenantId())).sourceOrderId(sourceOrderId)
.sourceChecksumSha256(lower(req.getSourceChecksumSha256())).sourceOrderNo(req.getSourceOrderNo())
.sourceOrderStatus(req.getSourceOrderStatus()).sourcePaymentCount(req.getPayments().size())
.sourcePaymentEventCount(eventCount).sourceRefundCount(req.getRefunds().size())
.sourceRefundedPrice(req.getRefundedPrice()).sourceAccountImportId(account.getId())
.targetAppId(app.getId()).targetChannelId(channel.getId()).targetOrderId(order.getId())
.normalizedPayloadDigest(digest).mappingNotes(notes).importedBy(importedBy).importedAt(importedAt).build();
importMapper.insert(audit);
req.getPayments().forEach(source -> paymentImportMapper.insert(PayLegacyTransactionPaymentImportDO.builder()
.transactionImportId(audit.getId()).sourcePaymentId(lower(source.getSourcePaymentId()))
.sourceStatus(source.getSourceStatus()).sourceProvider(source.getSourceProvider().trim())
.sourceEventCount(source.getSourceEventCount())
.sourceEventDigest(source.getSourceEventDigest() == null ? null : lower(source.getSourceEventDigest()))
.targetExtensionId(extensions.get(lower(source.getSourcePaymentId())).getId()).build()));
req.getRefunds().forEach(source -> refundImportMapper.insert(PayLegacyTransactionRefundImportDO.builder()
.transactionImportId(audit.getId()).sourceRefundId(lower(source.getSourceRefundId()))
.sourceStatus(source.getSourceStatus())
.targetRefundId(refunds.get(lower(source.getSourceRefundId())).getId()).build()));
return toResp(audit, false);
}
private Validation validate(PayLegacyTransactionImportReqVO req, String expectedProvider) {
Set<String> paymentIds = new HashSet<>(), outTradeNos = new HashSet<>(), refundIds = new HashSet<>();
PayLegacyTransactionImportReqVO.Payment successful = null;
for (PayLegacyTransactionImportReqVO.Payment payment : req.getPayments()) {
if (!paymentIds.add(lower(payment.getSourcePaymentId())) || !outTradeNos.add(payment.getProviderOutTradeNo()))
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "支付 ID 或商户支付号重复");
requireProvider(expectedProvider, payment.getSourceProvider());
if (!Objects.equals(payment.getAmount(), req.getPrice()))
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "支付金额与订单金额不一致");
if (payment.getSourceEventCount() == 0 && payment.getSourceEventDigest() != null
|| payment.getSourceEventCount() > 0 && payment.getSourceEventDigest() == null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "支付事件数量与摘要不一致");
if (SUCCESS_PAYMENT_STATUSES.contains(payment.getSourceStatus())) {
if (successful != null || payment.getProviderTradeNo() == null || payment.getProviderTradeNo().isBlank()
|| payment.getSourcePaidAt() == null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "必须且只能有一笔可核验的成功支付");
successful = payment;
}
}
boolean paidOrder = Set.of("paid", "partially_refunded", "refunded").contains(req.getSourceOrderStatus());
if (paidOrder != (successful != null) || paidOrder && req.getSourcePaidAt() == null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "订单状态与成功支付不一致");
if (!req.getRefunds().isEmpty() && successful == null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "退款记录必须关联已成功支付");
int succeededRefund = 0;
for (PayLegacyTransactionImportReqVO.Refund refund : req.getRefunds()) {
if (!refundIds.add(lower(refund.getSourceRefundId())))
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "退款 ID 重复");
requireProvider(expectedProvider, refund.getSourceProvider());
if (refund.getAmount() > req.getPrice())
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "退款金额超过订单金额");
if ("succeeded".equals(refund.getSourceStatus())) {
if (refund.getSourceSucceededAt() == null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "成功退款缺少成功时间");
succeededRefund += refund.getAmount();
}
}
if (succeededRefund != req.getRefundedPrice())
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "成功退款合计与订单已退金额不一致");
int orderStatus = switch (req.getSourceOrderStatus()) {
case "paid" -> requireRefund(req, 0, false, PayOrderStatusEnum.SUCCESS.getStatus());
case "partially_refunded" -> requireRefund(req, req.getRefundedPrice(), true,
PayOrderStatusEnum.SUCCESS.getStatus());
case "refunded" -> requireRefund(req, req.getPrice(), false, PayOrderStatusEnum.REFUND.getStatus());
case "failed", "closed" -> requireRefund(req, 0, false, PayOrderStatusEnum.CLOSED.getStatus());
default -> throw exception(LEGACY_TRANSACTION_IMPORT_UNSAFE_STATE, req.getSourceOrderStatus());
};
return new Validation(orderStatus, successful);
}
private void validateNativeNumberAvailability(PayLegacyTransactionImportReqVO req, Long appId) {
if (orderMapper.selectByAppIdAndMerchantOrderId(appId, req.getSourceOrderNo()) != null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "目标应用已存在相同旧订单号");
for (PayLegacyTransactionImportReqVO.Payment payment : req.getPayments()) {
if (extensionMapper.selectByNo(payment.getProviderOutTradeNo()) != null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "目标账本已存在相同商户支付号");
}
for (PayLegacyTransactionImportReqVO.Refund refund : req.getRefunds()) {
if (refundMapper.selectByAppIdAndNo(appId, refund.getRefundNo()) != null)
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "目标应用已存在相同退款号");
}
}
private int requireRefund(PayLegacyTransactionImportReqVO req, int expected, boolean partial, int status) {
if (req.getRefundedPrice() != expected && !partial
|| partial && (req.getRefundedPrice() <= 0 || req.getRefundedPrice() >= req.getPrice()))
throw exception(LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "订单状态与已退金额不一致");
return status;
}
private void requireProvider(String expected, String actual) {
String normalized = actual.trim().toLowerCase(Locale.ROOT).replace('-', '_');
if (Set.of("wechat", "wechatpay", "wxpay", "wx_pay").contains(normalized)) normalized = "wechat_pay";
if (!Objects.equals(expected, normalized))
throw exception(LEGACY_TRANSACTION_IMPORT_PROVIDER_MISMATCH, actual);
}
private PayOrderDO buildOrder(PayLegacyTransactionImportReqVO req, PayAppDO app,
PayChannelDO channel, int status) {
PayOrderDO order = PayOrderDO.builder().appId(app.getId()).channelId(channel.getId())
.channelCode(channel.getCode()).userId(req.getTargetUserId())
.userType(req.getTargetUserId() == null ? null : UserTypeEnum.MEMBER.getValue())
.merchantOrderId(req.getSourceOrderNo()).subject(req.getSubject()).body(req.getBody())
.notifyUrl(app.getOrderNotifyUrl()).price(req.getPrice()).channelFeeRate(0D)
.channelFeePrice(0).status(status).userIp("0.0.0.0").expireTime(req.getSourceCreatedAt())
.successTime(req.getSourcePaidAt()).refundPrice(req.getRefundedPrice()).build();
order.setCreateTime(req.getSourceCreatedAt());
return order;
}
@Override
public PageResult<PayLegacyTransactionImportRespVO> getImportPage(PayLegacyTransactionImportPageReqVO reqVO) {
PageResult<PayLegacyTransactionImportDO> page = importMapper.selectPage(reqVO);
return new PageResult<>(page.getList().stream().map(row -> toResp(row, false)).toList(), page.getTotal());
}
private PayLegacyTransactionImportRespVO toResp(PayLegacyTransactionImportDO row, boolean replayed) {
return PayLegacyTransactionImportRespVO.builder().id(row.getId()).sourceTenantId(row.getSourceTenantId())
.sourceOrderId(row.getSourceOrderId()).sourceOrderNo(row.getSourceOrderNo())
.sourceOrderStatus(row.getSourceOrderStatus()).sourcePaymentCount(row.getSourcePaymentCount())
.sourcePaymentEventCount(row.getSourcePaymentEventCount()).sourceRefundCount(row.getSourceRefundCount())
.sourceRefundedPrice(row.getSourceRefundedPrice()).sourceAccountImportId(row.getSourceAccountImportId())
.targetAppId(row.getTargetAppId()).targetChannelId(row.getTargetChannelId())
.targetOrderId(row.getTargetOrderId()).normalizedPayloadDigest(row.getNormalizedPayloadDigest())
.mappingNotes(row.getMappingNotes()).importedBy(row.getImportedBy()).importedAt(row.getImportedAt())
.replayed(replayed).build();
}
private String lower(String value) { return value.toLowerCase(Locale.ROOT); }
private record Validation(int orderStatus, PayLegacyTransactionImportReqVO.Payment successfulPayment) {}
}

View File

@@ -293,7 +293,7 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
}
// 校验钱包余额是否足够
PayWalletDO wallet = payWalletService.getWallet(walletRecharge.getWalletId());
Assert.notNull(wallet, "用户钱包({}) 不存在", wallet.getId());
Assert.notNull(wallet, "用户钱包({}) 不存在", walletRecharge.getWalletId());
if (wallet.getBalance() < walletRecharge.getTotalPrice()) {
throw exception(WALLET_RECHARGE_REFUND_BALANCE_NOT_ENOUGH);
}

View File

@@ -144,6 +144,9 @@ public class PayWalletServiceImpl implements PayWalletService {
@SneakyThrows
public PayWalletTransactionDO reduceWalletBalance(Long walletId, Long bizId,
PayWalletBizTypeEnum bizType, Integer price) {
if (price == null || price <= 0) {
throw new IllegalArgumentException("扣减钱包余额必须为正数");
}
// 1. 获取钱包
PayWalletDO payWallet = getWallet(walletId);
if (payWallet == null) {
@@ -164,6 +167,10 @@ public class PayWalletServiceImpl implements PayWalletService {
updateCounts = walletMapper.updateWhenRechargeRefund(payWallet.getId(), price);
break;
}
case UPDATE_BALANCE: {
updateCounts = walletMapper.updateWhenSubtract(payWallet.getId(), price);
break;
}
default: {
// TODO 其它类型待实现
throw new UnsupportedOperationException("待实现");
@@ -191,6 +198,9 @@ public class PayWalletServiceImpl implements PayWalletService {
@SneakyThrows
public PayWalletTransactionDO addWalletBalance(Long walletId, String bizId,
PayWalletBizTypeEnum bizType, Integer price) {
if (price == null || price <= 0) {
throw new IllegalArgumentException("增加钱包余额必须为正数");
}
// 1. 获取钱包
PayWalletDO payWallet = getWallet(walletId);
if (payWallet == null) {
@@ -229,6 +239,9 @@ public class PayWalletServiceImpl implements PayWalletService {
@Override
public void freezePrice(Long id, Integer price) {
if (price == null || price <= 0) {
throw new IllegalArgumentException("冻结钱包余额必须为正数");
}
int updateCounts = walletMapper.freezePrice(id, price);
if (updateCounts == 0) {
throw exception(WALLET_BALANCE_NOT_ENOUGH);
@@ -237,6 +250,9 @@ public class PayWalletServiceImpl implements PayWalletService {
@Override
public void unfreezePrice(Long id, Integer price) {
if (price == null || price <= 0) {
throw new IllegalArgumentException("解冻钱包余额必须为正数");
}
int updateCounts = walletMapper.unFreezePrice(id, price);
if (updateCounts == 0) {
throw exception(WALLET_FREEZE_PRICE_NOT_ENOUGH);

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportReqVO;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PayLegacyAccountImportControllerContractTest {
@Test
void importRequiresBothWritePermissionsAndDisablesRequestBodyLogging() throws NoSuchMethodException {
Method method = PayLegacyAccountImportController.class.getMethod(
"importAccount", PayLegacyAccountImportReqVO.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermission('pay:app:create') and @ss.hasPermission('pay:channel:create')");
assertThat(method.getAnnotation(ApiAccessLog.class).requestEnable()).isFalse();
}
}

View File

@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.pay.controller.admin.legacy;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportReqVO;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PayLegacyTransactionImportControllerContractTest {
@Test
void importUsesDedicatedPermissionAndSuppressesRequestLogging() throws NoSuchMethodException {
Method method = PayLegacyTransactionImportController.class.getMethod(
"importTransaction", PayLegacyTransactionImportReqVO.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermission('pay:legacy-transaction:import')");
assertThat(method.getAnnotation(ApiAccessLog.class).requestEnable()).isFalse();
}
}

View File

@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.pay.controller.admin.wallet;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletUpdateBalanceReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import cn.iocoder.yudao.module.pay.service.wallet.PayWalletService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PayWalletControllerTest {
private final PayWalletService walletService = mock(PayWalletService.class);
private final PayWalletController controller = new PayWalletController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "payWalletService", walletService);
PayWalletDO wallet = new PayWalletDO();
wallet.setId(88L);
when(walletService.getOrCreateWallet(7L, UserTypeEnum.MEMBER.getValue())).thenReturn(wallet);
}
@Test
void positiveAdjustmentUsesAddBalancePath() {
PayWalletUpdateBalanceReqVO request = new PayWalletUpdateBalanceReqVO();
request.setUserId(7L);
request.setBalance(120);
controller.updateWalletBalance(request);
verify(walletService).addWalletBalance(88L, "7", PayWalletBizTypeEnum.UPDATE_BALANCE, 120);
verify(walletService, never()).reduceWalletBalance(
88L, 7L, PayWalletBizTypeEnum.UPDATE_BALANCE, 120);
}
@Test
void negativeAdjustmentUsesSafeSubtractPath() {
PayWalletUpdateBalanceReqVO request = new PayWalletUpdateBalanceReqVO();
request.setUserId(7L);
request.setBalance(-120);
controller.updateWalletBalance(request);
verify(walletService).reduceWalletBalance(88L, 7L, PayWalletBizTypeEnum.UPDATE_BALANCE, 120);
verify(walletService, never()).addWalletBalance(
88L, "7", PayWalletBizTypeEnum.UPDATE_BALANCE, -120);
}
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.pay.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.notify.PayNotifyLogDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.notify.PayNotifyTaskDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PayTransactionTenantContractTest {
@Test
void orderRefundAndNotifyRecordsParticipateInFrameworkTenantIsolation() {
assertThat(TenantBaseDO.class).isAssignableFrom(PayOrderDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayOrderExtensionDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayRefundDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayNotifyTaskDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayNotifyLogDO.class);
}
}

View File

@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.pay.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
import cn.iocoder.yudao.module.pay.controller.admin.wallet.PayWalletRechargeController;
import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargePackageDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO;
import cn.iocoder.yudao.module.pay.job.transfer.PayTransferSyncJob;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PayTransferWalletTenantContractTest {
@Test
void transferAndWalletLedgersParticipateInFrameworkTenantIsolation() {
assertThat(TenantBaseDO.class).isAssignableFrom(PayTransferDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayWalletDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayWalletTransactionDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayWalletRechargeDO.class);
assertThat(TenantBaseDO.class).isAssignableFrom(PayWalletRechargePackageDO.class);
}
@Test
void transferSynchronizationRunsForEveryTenant() throws NoSuchMethodException {
Method method = PayTransferSyncJob.class.getMethod("execute", String.class);
assertThat(method.getAnnotation(TenantJob.class)).isNotNull();
}
@Test
void rechargeRefundRequiresDedicatedPermission() throws NoSuchMethodException {
Method method = PayWalletRechargeController.class.getMethod("refundWalletRecharge", Long.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermission('pay:wallet-recharge:refund')");
}
}

View File

@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.pay.dal.dataobject.app;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.pay.controller.admin.app.PayAppController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PayAppTenantContractTest {
@Test
void payApplicationsParticipateInFrameworkTenantIsolation() {
assertThat(TenantBaseDO.class).isAssignableFrom(PayAppDO.class);
}
@Test
void nativeAppListUsesTheAppQueryPermission() throws NoSuchMethodException {
Method method = PayAppController.class.getMethod("getAppList");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermission('pay:app:query')");
}
}

View File

@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.pay.dal.redis.wallet;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PayWalletLockRedisDAOTest {
@AfterEach
void clearTenant() {
TenantContextHolder.clear();
}
@Test
void lockKeyContainsTenantAndWalletIdentifiers() throws Exception {
RedissonClient redissonClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
when(redissonClient.getLock("pay_wallet:lock:42:99")).thenReturn(lock);
PayWalletLockRedisDAO dao = new PayWalletLockRedisDAO();
ReflectionTestUtils.setField(dao, "redissonClient", redissonClient);
TenantContextHolder.setTenantId(42L);
String result = dao.lock(99L, 2_000L, () -> "done");
assertThat(result).isEqualTo("done");
verify(redissonClient).getLock("pay_wallet:lock:42:99");
verify(lock).lock(2_000L, TimeUnit.MILLISECONDS);
verify(lock).unlock();
}
}

View File

@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.wallet;
import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO;
import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.NonePayClientConfig;
import cn.iocoder.yudao.module.pay.service.transfer.PayTransferService;
import cn.iocoder.yudao.module.pay.service.wallet.PayWalletTransactionService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WalletPayClientTest {
@Test
void successfulTransferLooksUpWalletTransactionByTransferNumber() {
PayTransferService transferService = mock(PayTransferService.class);
PayWalletTransactionService transactionService = mock(PayWalletTransactionService.class);
PayTransferDO transfer = new PayTransferDO();
transfer.setId(77L);
transfer.setNo("T20260801001");
transfer.setStatus(PayTransferStatusEnum.SUCCESS.getStatus());
PayWalletTransactionDO transaction = new PayWalletTransactionDO();
transaction.setNo("WT20260801001");
transaction.setCreateTime(LocalDateTime.of(2026, 8, 1, 6, 0));
when(transferService.getTransferByNo("merchant-transfer-1")).thenReturn(transfer);
when(transactionService.getWalletTransaction("T20260801001", PayWalletBizTypeEnum.TRANSFER))
.thenReturn(transaction);
WalletPayClient client = new WalletPayClient(1L, new NonePayClientConfig());
ReflectionTestUtils.setField(client, "transferService", transferService);
ReflectionTestUtils.setField(client, "walletTransactionService", transactionService);
PayTransferRespDTO response = client.getTransfer("merchant-transfer-1");
assertThat(response.getStatus()).isEqualTo(PayTransferStatusEnum.SUCCESS.getStatus());
assertThat(response.getChannelTransferNo()).isEqualTo("WT20260801001");
verify(transactionService).getWalletTransaction("T20260801001", PayWalletBizTypeEnum.TRANSFER);
}
}

View File

@@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.pay.controller.admin.channel.vo.PayChannelCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.channel.vo.PayChannelUpdateReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.dal.mysql.channel.PayChannelMapper;
import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
@@ -12,9 +13,11 @@ import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClientFactory;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.alipay.AlipayPayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import com.alibaba.fastjson.JSON;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -27,6 +30,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -44,6 +48,37 @@ public class PayChannelServiceTest extends BaseDbUnitTest {
private PayClientFactory payClientFactory;
@MockitoBean
private Validator validator;
@MockitoBean
private PayAppService payAppService;
@BeforeEach
void setUpPayApp() {
when(payAppService.getApp(anyLong())).thenReturn(new PayAppDO());
}
@Test
public void testCreateChannel_appNotFound() {
PayChannelCreateReqVO reqVO = randomPojo(PayChannelCreateReqVO.class);
when(payAppService.getApp(reqVO.getAppId())).thenReturn(null);
assertServiceException(() -> channelService.createChannel(reqVO), APP_NOT_FOUND);
}
@Test
public void testUpdateChannel_appNotFound() {
PayChannelDO dbChannel = randomPojo(PayChannelDO.class, o -> {
o.setCode(PayChannelEnum.ALIPAY_APP.getCode());
o.setConfig(randomAlipayPayClientConfig());
});
channelMapper.insert(dbChannel);
PayChannelUpdateReqVO reqVO = randomPojo(PayChannelUpdateReqVO.class, o -> {
o.setId(dbChannel.getId());
o.setConfig(JsonUtils.toJsonString(randomAlipayPayClientConfig()));
});
when(payAppService.getApp(reqVO.getAppId())).thenReturn(null);
assertServiceException(() -> channelService.updateChannel(reqVO), APP_NOT_FOUND);
}
@Test
public void testCreateChannel_success() {

View File

@@ -0,0 +1,254 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.channel.vo.PayChannelCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyAccountImportRespVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.PayLegacyAccountImportDO;
import cn.iocoder.yudao.module.pay.dal.mysql.legacy.PayLegacyAccountImportMapper;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.alipay.AlipayPayClientConfig;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CHANNEL_MISMATCH;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_CONFLICT;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_MODE;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_PROVIDER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class PayLegacyAccountImportServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private PayLegacyAccountImportServiceImpl importService;
@Mock
private PayLegacyAccountImportMapper importMapper;
@Mock
private PayAppService appService;
@Mock
private PayChannelService channelService;
@BeforeEach
void setUp() {
lenient().when(appService.createApp(any())).thenReturn(100L);
lenient().when(channelService.createChannel(any())).thenReturn(200L);
}
@Test
void importsWechatAliasIntoExplicitNativeChannelWithoutPersistingRawSourcePayload() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
reqVO.setSourceProvider("wechat-pay");
PayLegacyAccountImportRespVO result = importService.importAccount(reqVO, 7L);
ArgumentCaptor<PayAppCreateReqVO> app = ArgumentCaptor.forClass(PayAppCreateReqVO.class);
verify(appService).createApp(app.capture());
assertEquals("legacy-pay", app.getValue().getAppKey());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), app.getValue().getStatus());
assertEquals("https://new.example/order", app.getValue().getOrderNotifyUrl());
ArgumentCaptor<PayChannelCreateReqVO> channel = ArgumentCaptor.forClass(PayChannelCreateReqVO.class);
verify(channelService).createChannel(channel.capture());
WxPayClientConfig config = JsonUtils.parseObject2(channel.getValue().getConfig(), WxPayClientConfig.class);
assertEquals("wx-app", config.getAppId());
assertEquals("mch-1", config.getMchId());
assertEquals("serial-1", config.getCertSerialNo());
assertEquals("private-key", config.getPrivateKeyContent());
assertEquals("api-v3-key", config.getApiV3Key());
assertEquals("platform-serial", config.getPublicKeyId());
assertEquals("platform-key", config.getPublicKeyContent());
ArgumentCaptor<PayLegacyAccountImportDO> audit = ArgumentCaptor.forClass(PayLegacyAccountImportDO.class);
verify(importMapper).insert(audit.capture());
assertEquals("wechat_pay", audit.getValue().getNormalizedProvider());
assertFalse(audit.getValue().toString().contains("private-key"));
assertFalse(audit.getValue().toString().contains("api-v3-key"));
assertNotNull(audit.getValue().getNormalizedConfigDigest());
assertTrue(result.getMappingNotes().stream().anyMatch(note -> note.contains("Provider 别名")));
}
@Test
void importsDisabledAlipayIntoDisabledNativeObjects() {
PayLegacyAccountImportReqVO reqVO = alipayRequest();
reqVO.setSourceStatus("pending");
PayLegacyAccountImportRespVO result = importService.importAccount(reqVO, 8L);
ArgumentCaptor<PayChannelCreateReqVO> channel = ArgumentCaptor.forClass(PayChannelCreateReqVO.class);
verify(channelService).createChannel(channel.capture());
AlipayPayClientConfig config = JsonUtils.parseObject2(channel.getValue().getConfig(), AlipayPayClientConfig.class);
assertEquals("ali-app", config.getAppId());
assertEquals(AlipayPayClientConfig.MODE_PUBLIC_KEY, config.getMode());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), channel.getValue().getStatus());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), result.getTargetStatus());
assertThat(result.getMappingNotes()).anyMatch(note -> note.contains("安全映射为禁用"));
}
@Test
void replaysSameSourceChecksumWithoutCreatingAnotherNativeObject() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
PayLegacyAccountImportDO existing = existing(reqVO);
when(importMapper.selectBySourceAccountId(reqVO.getSourceAccountId())).thenReturn(existing);
PayLegacyAccountImportRespVO result = importService.importAccount(reqVO, 7L);
assertTrue(result.getReplayed());
verifyNoInteractions(appService, channelService);
verify(importMapper, never()).insert(any(PayLegacyAccountImportDO.class));
}
@Test
void rejectsChecksumChangeForAlreadyImportedSourceAccount() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
PayLegacyAccountImportDO existing = existing(reqVO);
existing.setSourceChecksumSha256("b".repeat(64));
when(importMapper.selectBySourceAccountId(reqVO.getSourceAccountId())).thenReturn(existing);
assertServiceException(() -> importService.importAccount(reqVO, 7L), LEGACY_ACCOUNT_IMPORT_CONFLICT,
reqVO.getSourceAccountId());
verifyNoInteractions(appService, channelService);
}
@Test
void rejectsNonEquivalentCollectionModes() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
reqVO.setSourceMode("service_provider");
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_MODE, "service_provider");
verifyNoInteractions(appService, channelService);
}
@Test
void rejectsUnsupportedLegacyProviders() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
reqVO.setSourceProvider("xpay");
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_UNSUPPORTED_PROVIDER, "xpay");
}
@Test
void rejectsImplicitWechatToAlipayChannelMapping() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
reqVO.setTargetChannelCode("alipay_wap");
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_CHANNEL_MISMATCH, "alipay_wap", "wechat_pay");
}
@Test
void rejectsWechatRotatingKeySetUntilOneKeyIsExplicitlySelected() {
PayLegacyAccountImportReqVO reqVO = wechatRequest();
reqVO.getConfigPublic().put("wechatpayPublicKeys", Map.of("serial-a", "key-a", "serial-b", "key-b"));
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE,
"微信轮换公钥多于一个,请先显式选择当前 publicKeyId/publicKeyContent");
}
@Test
void rejectsUnsafeAlipayEndpointAndMissingRequiredCredential() {
PayLegacyAccountImportReqVO reqVO = alipayRequest();
reqVO.getConfigPublic().put("endpoint", "http://127.0.0.1:9999/gateway.do");
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_CONFIG_UNSAFE, "支付宝 endpoint 不是原生 Pay 允许的生产或沙箱网关");
reqVO.getConfigPublic().put("endpoint", "https://openapi.alipay.com/gateway.do");
reqVO.getSecretJson().remove("alipayPublicKey");
assertServiceException(() -> importService.importAccount(reqVO, 7L),
LEGACY_ACCOUNT_IMPORT_CONFIG_MISSING, "alipayPublicKey/publicKey");
}
private PayLegacyAccountImportReqVO wechatRequest() {
PayLegacyAccountImportReqVO reqVO = baseRequest();
reqVO.setSourceProvider("wechat_pay");
reqVO.setTargetChannelCode("wx_lite");
Map<String, Object> publicConfig = new LinkedHashMap<>();
publicConfig.put("appId", "wx-app");
publicConfig.put("merchantId", "mch-1");
publicConfig.put("merchantSerialNo", "serial-1");
publicConfig.put("wechatpayPublicKeys", Map.of("platform-serial", "platform-key"));
publicConfig.put("notifyUrl", "https://legacy.example/wechat/notify");
reqVO.setConfigPublic(publicConfig);
reqVO.setSecretJson(new LinkedHashMap<>(Map.of(
"privateKey", "private-key",
"apiV3Key", "api-v3-key")));
return reqVO;
}
private PayLegacyAccountImportReqVO alipayRequest() {
PayLegacyAccountImportReqVO reqVO = baseRequest();
reqVO.setSourceProvider("alipay");
reqVO.setTargetChannelCode("alipay_wap");
reqVO.setConfigPublic(new LinkedHashMap<>(Map.of(
"appId", "ali-app",
"sellerId", "seller-1",
"endpoint", "https://openapi.alipay.com/gateway.do")));
reqVO.setSecretJson(new LinkedHashMap<>(Map.of(
"privateKey", "ali-private-key",
"alipayPublicKey", "ali-public-key")));
return reqVO;
}
private PayLegacyAccountImportReqVO baseRequest() {
PayLegacyAccountImportReqVO reqVO = new PayLegacyAccountImportReqVO();
reqVO.setSourceTenantId("11111111-1111-4111-8111-111111111111");
reqVO.setSourceAccountId("22222222-2222-4222-8222-222222222222");
reqVO.setSourceChecksumSha256("a".repeat(64));
reqVO.setSourceMode("tenant_collect");
reqVO.setSourceStatus("active");
reqVO.setAppKey("legacy-pay");
reqVO.setAppName("旧支付账号");
reqVO.setOrderNotifyUrl("https://new.example/order");
reqVO.setRefundNotifyUrl("https://new.example/refund");
return reqVO;
}
private PayLegacyAccountImportDO existing(PayLegacyAccountImportReqVO reqVO) {
return PayLegacyAccountImportDO.builder()
.id(9L)
.sourceTenantId(reqVO.getSourceTenantId())
.sourceAccountId(reqVO.getSourceAccountId())
.sourceChecksumSha256(reqVO.getSourceChecksumSha256())
.normalizedProvider("wechat_pay")
.sourceMode("tenant_collect")
.sourceStatus("active")
.targetAppId(100L)
.targetChannelId(200L)
.targetChannelCode("wx_lite")
.targetStatus(CommonStatusEnum.ENABLE.getStatus())
.normalizedConfigDigest("c".repeat(64))
.mappingNotes(List.of("mapped"))
.importedBy(7L)
.importedAt(LocalDateTime.now())
.build();
}
}

View File

@@ -0,0 +1,204 @@
package cn.iocoder.yudao.module.pay.service.legacy;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.legacy.vo.PayLegacyTransactionImportRespVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.legacy.*;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
import cn.iocoder.yudao.module.pay.dal.mysql.legacy.*;
import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper;
import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper;
import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper;
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class PayLegacyTransactionImportServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks private PayLegacyTransactionImportServiceImpl service;
@Mock private PayLegacyTransactionImportMapper importMapper;
@Mock private PayLegacyTransactionPaymentImportMapper paymentImportMapper;
@Mock private PayLegacyTransactionRefundImportMapper refundImportMapper;
@Mock private PayLegacyAccountImportMapper accountImportMapper;
@Mock private PayOrderMapper orderMapper;
@Mock private PayOrderExtensionMapper extensionMapper;
@Mock private PayRefundMapper refundMapper;
@Mock private PayAppService appService;
@Mock private PayChannelService channelService;
@BeforeEach
void setUp() {
PayLegacyAccountImportDO account = PayLegacyAccountImportDO.builder().id(9L)
.sourceTenantId("11111111-1111-4111-8111-111111111111")
.normalizedProvider("wechat_pay").targetAppId(100L).targetChannelId(200L)
.targetChannelCode("wx_lite").build();
lenient().when(accountImportMapper.selectById(9L)).thenReturn(account);
lenient().when(appService.getApp(100L)).thenReturn(PayAppDO.builder().id(100L)
.orderNotifyUrl("https://new.example/order").refundNotifyUrl("https://new.example/refund").build());
lenient().when(channelService.getChannel(200L)).thenReturn(PayChannelDO.builder().id(200L)
.appId(100L).code("wx_lite").build());
AtomicLong ids = new AtomicLong(1000);
lenient().doAnswer(invocation -> { ((PayOrderDO) invocation.getArgument(0)).setId(ids.incrementAndGet()); return 1; })
.when(orderMapper).insert(any(PayOrderDO.class));
lenient().doAnswer(invocation -> { ((PayOrderExtensionDO) invocation.getArgument(0)).setId(ids.incrementAndGet()); return 1; })
.when(extensionMapper).insert(any(PayOrderExtensionDO.class));
lenient().doAnswer(invocation -> { ((PayRefundDO) invocation.getArgument(0)).setId(ids.incrementAndGet()); return 1; })
.when(refundMapper).insert(any(PayRefundDO.class));
lenient().doAnswer(invocation -> { ((PayLegacyTransactionImportDO) invocation.getArgument(0)).setId(ids.incrementAndGet()); return 1; })
.when(importMapper).insert(any(PayLegacyTransactionImportDO.class));
}
@Test
void importsReconciledTerminalAggregateWithoutRawProviderPayload() {
PayLegacyTransactionImportReqVO req = refundedRequest();
PayLegacyTransactionImportRespVO result = service.importTransaction(req, 7L);
ArgumentCaptor<PayOrderDO> order = ArgumentCaptor.forClass(PayOrderDO.class);
verify(orderMapper).insert(order.capture());
assertThat(order.getValue().getStatus()).isEqualTo(PayOrderStatusEnum.REFUND.getStatus());
assertThat(order.getValue().getRefundPrice()).isEqualTo(1000);
assertThat(order.getValue().getUserIp()).isEqualTo("0.0.0.0");
assertThat(order.getValue().getChannelFeePrice()).isZero();
ArgumentCaptor<PayOrderExtensionDO> extension = ArgumentCaptor.forClass(PayOrderExtensionDO.class);
verify(extensionMapper).insert(extension.capture());
assertThat(extension.getValue().getNo()).isEqualTo("OLD-OUT-1");
assertThat(extension.getValue().getChannelNotifyData()).isNull();
ArgumentCaptor<PayRefundDO> refund = ArgumentCaptor.forClass(PayRefundDO.class);
verify(refundMapper).insert(refund.capture());
assertThat(refund.getValue().getChannelNotifyData()).isNull();
ArgumentCaptor<PayLegacyTransactionImportDO> audit = ArgumentCaptor.forClass(PayLegacyTransactionImportDO.class);
verify(importMapper).insert(audit.capture());
assertThat(audit.getValue().getSourcePaymentEventCount()).isEqualTo(2);
assertThat(audit.getValue().toString()).doesNotContain("provider raw", "event payload");
assertThat(result.getTargetOrderId()).isNotNull();
assertThat(result.getReplayed()).isFalse();
verify(paymentImportMapper).insert(any(PayLegacyTransactionPaymentImportDO.class));
verify(refundImportMapper).insert(any(PayLegacyTransactionRefundImportDO.class));
}
@Test
void replaysSameChecksumWithoutWritingNativeLedgers() {
PayLegacyTransactionImportReqVO req = refundedRequest();
PayLegacyTransactionImportDO existing = PayLegacyTransactionImportDO.builder().id(88L)
.sourceOrderId(req.getSourceOrderId()).sourceChecksumSha256(req.getSourceChecksumSha256())
.sourceOrderNo(req.getSourceOrderNo()).sourceOrderStatus(req.getSourceOrderStatus())
.sourcePaymentCount(1).sourcePaymentEventCount(2).sourceRefundCount(1).sourceRefundedPrice(1000)
.mappingNotes(List.of()).build();
when(importMapper.selectBySourceOrderId(req.getSourceOrderId())).thenReturn(existing);
assertThat(service.importTransaction(req, 7L).getReplayed()).isTrue();
verifyNoInteractions(orderMapper, extensionMapper, refundMapper, paymentImportMapper, refundImportMapper);
}
@Test
void rejectsChecksumConflictBeforeLoadingAccount() {
PayLegacyTransactionImportReqVO req = refundedRequest();
when(importMapper.selectBySourceOrderId(req.getSourceOrderId())).thenReturn(
PayLegacyTransactionImportDO.builder().sourceChecksumSha256("b".repeat(64)).build());
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_CONFLICT, req.getSourceOrderId());
verifyNoInteractions(accountImportMapper, orderMapper);
}
@Test
void rejectsProviderOutsidePreviouslyAuditedAccountMapping() {
PayLegacyTransactionImportReqVO req = refundedRequest();
req.getPayments().getFirst().setSourceProvider("alipay");
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_PROVIDER_MISMATCH, "alipay");
verify(orderMapper, never()).insert(any(PayOrderDO.class));
}
@Test
void rejectsOrderStatusAndRefundAmountMismatch() {
PayLegacyTransactionImportReqVO req = refundedRequest();
req.setSourceOrderStatus("partially_refunded");
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "订单状态与已退金额不一致");
verify(orderMapper, never()).insert(any(PayOrderDO.class));
}
@Test
void rejectsEventCountWithoutDigest() {
PayLegacyTransactionImportReqVO req = refundedRequest();
req.getPayments().getFirst().setSourceEventDigest(null);
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "支付事件数量与摘要不一致");
verify(orderMapper, never()).insert(any(PayOrderDO.class));
}
@Test
void rejectsRefundWithoutSuccessfulPaymentBeforeWritingNativeLedgers() {
PayLegacyTransactionImportReqVO req = refundedRequest();
req.setSourceOrderStatus("failed");
req.setRefundedPrice(0);
req.setSourcePaidAt(null);
req.getPayments().getFirst().setSourceStatus("failed");
req.getPayments().getFirst().setProviderTradeNo(null);
req.getPayments().getFirst().setSourcePaidAt(null);
req.getRefunds().getFirst().setSourceStatus("failed");
req.getRefunds().getFirst().setSourceSucceededAt(null);
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "退款记录必须关联已成功支付");
verify(orderMapper, never()).insert(any(PayOrderDO.class));
}
@Test
void rejectsNativeMerchantNumberCollisionBeforeWritingNativeLedgers() {
PayLegacyTransactionImportReqVO req = refundedRequest();
when(orderMapper.selectByAppIdAndMerchantOrderId(100L, req.getSourceOrderNo()))
.thenReturn(new PayOrderDO().setId(42L));
assertServiceException(() -> service.importTransaction(req, 7L),
LEGACY_TRANSACTION_IMPORT_RECONCILIATION, "目标应用已存在相同旧订单号");
verify(orderMapper, never()).insert(any(PayOrderDO.class));
verifyNoInteractions(extensionMapper, refundMapper);
}
private PayLegacyTransactionImportReqVO refundedRequest() {
LocalDateTime created = LocalDateTime.of(2026, 7, 1, 10, 0);
LocalDateTime paid = created.plusMinutes(2);
PayLegacyTransactionImportReqVO.Payment payment = new PayLegacyTransactionImportReqVO.Payment();
payment.setSourcePaymentId("33333333-3333-4333-8333-333333333333");
payment.setSourceProvider("wechat-pay"); payment.setSourceMethod("jsapi");
payment.setSourceStatus("refunded"); payment.setAmount(1000);
payment.setProviderOutTradeNo("OLD-OUT-1"); payment.setProviderTradeNo("WX-TRADE-1");
payment.setSourceCreatedAt(created.plusMinutes(1)); payment.setSourcePaidAt(paid);
payment.setSourceEventCount(2); payment.setSourceEventDigest("d".repeat(64));
PayLegacyTransactionImportReqVO.Refund refund = new PayLegacyTransactionImportReqVO.Refund();
refund.setSourceRefundId("44444444-4444-4444-8444-444444444444");
refund.setSourceProvider("wechat_pay"); refund.setSourceStatus("succeeded");
refund.setRefundNo("OLD-REFUND-1"); refund.setProviderRefundNo("WX-REFUND-1");
refund.setAmount(1000); refund.setReason("requested");
refund.setSourceCreatedAt(paid.plusDays(1)); refund.setSourceSucceededAt(paid.plusDays(1).plusMinutes(1));
PayLegacyTransactionImportReqVO req = new PayLegacyTransactionImportReqVO();
req.setSourceTenantId("11111111-1111-4111-8111-111111111111"); req.setSourceAccountImportId(9L);
req.setSourceOrderId("22222222-2222-4222-8222-222222222222");
req.setSourceChecksumSha256("a".repeat(64)); req.setSourceOrderNo("OLD-ORDER-1");
req.setSourceOrderStatus("refunded"); req.setTargetUserId(77L); req.setSubject("Legacy course");
req.setPrice(1000); req.setRefundedPrice(1000); req.setSourceCreatedAt(created); req.setSourcePaidAt(paid);
req.setPayments(List.of(payment)); req.setRefunds(List.of(refund));
return req;
}
}

View File

@@ -19,7 +19,6 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
import cn.iocoder.yudao.module.pay.service.refund.PayRefundServiceImpl;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.redisson.api.RLock;
@@ -47,7 +46,6 @@ import static org.mockito.Mockito.*;
*
* @author 管理员
*/
@Disabled // TODO 管理员:后续 fix 补充的单测
@Import({PayJobConfiguration.class, PayNotifyServiceImpl.class, PayNotifyLockRedisDAO.class})
public class PayNotifyServiceTest extends BaseDbUnitTest {
@@ -94,7 +92,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
.containsExactly(type, dataId, PayNotifyStatusEnum.WAITING.getStatus(), 0, 9,
order.getAppId(), order.getMerchantOrderId(), order.getNotifyUrl());
// 断言,调用
verify(payNotifyService).executeNotify0(eq(dbTask));
verify(payNotifyService).executeNotifyAsync(any(PayNotifyTaskDO.class));
}
}
@@ -125,7 +123,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
.containsExactly(type, dataId, PayNotifyStatusEnum.WAITING.getStatus(), 0, 9,
refund.getAppId(), refund.getMerchantOrderId(), refund.getNotifyUrl());
// 断言,调用
verify(payNotifyService).executeNotify0(eq(dbTask));
verify(payNotifyService).executeNotifyAsync(any(PayNotifyTaskDO.class));
}
}
@@ -134,14 +132,17 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// mock 数据notify
PayNotifyTaskDO dbTask01 = randomPojo(PayNotifyTaskDO.class,
o -> o.setStatus(PayNotifyStatusEnum.WAITING.getStatus())
.setType(-1).setNotifyTimes(0).setMaxNotifyTimes(9)
.setNextNotifyTime(addTime(Duration.ofMinutes(-1))));
notifyTaskMapper.insert(dbTask01);
PayNotifyTaskDO dbTask02 = randomPojo(PayNotifyTaskDO.class,
o -> o.setStatus(PayNotifyStatusEnum.REQUEST_SUCCESS.getStatus())
.setType(-1).setNotifyTimes(0).setMaxNotifyTimes(9)
.setNextNotifyTime(addTime(Duration.ofMinutes(-1))));
notifyTaskMapper.insert(dbTask02);
PayNotifyTaskDO dbTask03 = randomPojo(PayNotifyTaskDO.class,
o -> o.setStatus(PayNotifyStatusEnum.REQUEST_FAILURE.getStatus())
.setType(-1).setNotifyTimes(0).setMaxNotifyTimes(9)
.setNextNotifyTime(addTime(Duration.ofMinutes(-1))));
notifyTaskMapper.insert(dbTask03);
PayNotifyTaskDO dbTask04 = randomPojo(PayNotifyTaskDO.class, // 不满足状态
@@ -171,7 +172,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
public void testExecuteNotify0_exception() {
// mock 数据task
PayNotifyTaskDO task = randomPojo(PayNotifyTaskDO.class, o -> o.setType(-1)
.setNotifyTimes(0).setMaxNotifyTimes(9));
.setNotifyTimes(0).setMaxNotifyTimes(9).setLastExecuteTime(null));
notifyTaskMapper.insert(task);
// 调用
@@ -179,7 +180,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// 断言task
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
assertNotEquals(task.getNextNotifyTime(), dbTask.getNextNotifyTime());
assertNotEquals(task.getLastExecuteTime(), dbTask.getNextNotifyTime());
assertNotNull(dbTask.getLastExecuteTime());
assertEquals(dbTask.getNotifyTimes(), 1);
assertEquals(dbTask.getStatus(), PayNotifyStatusEnum.REQUEST_FAILURE.getStatus());
// 断言log
@@ -194,7 +195,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
public void testProcessNotifyResult_success() {
// mock 数据task
PayNotifyTaskDO task = randomPojo(PayNotifyTaskDO.class,
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9));
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9).setLastExecuteTime(null));
notifyTaskMapper.insert(task);
// 准备参数
CommonResult<?> invokeResult = CommonResult.success(randomString());
@@ -204,7 +205,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// 断言
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
assertEquals(task.getNextNotifyTime(), dbTask.getNextNotifyTime());
assertNotEquals(task.getLastExecuteTime(), dbTask.getNextNotifyTime());
assertNotNull(dbTask.getLastExecuteTime());
assertEquals(dbTask.getNotifyTimes(), 1);
assertEquals(dbTask.getStatus(), PayNotifyStatusEnum.SUCCESS.getStatus());
}
@@ -213,7 +214,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
public void testProcessNotifyResult_failure() {
// mock 数据task
PayNotifyTaskDO task = randomPojo(PayNotifyTaskDO.class,
o -> o.setNotifyTimes(8).setMaxNotifyTimes(9));
o -> o.setNotifyTimes(8).setMaxNotifyTimes(9).setLastExecuteTime(null));
notifyTaskMapper.insert(task);
// 准备参数
CommonResult<?> invokeResult = CommonResult.error(BAD_REQUEST);
@@ -223,7 +224,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// 断言
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
assertEquals(task.getNextNotifyTime(), dbTask.getNextNotifyTime());
assertNotEquals(task.getLastExecuteTime(), dbTask.getNextNotifyTime());
assertNotNull(dbTask.getLastExecuteTime());
assertEquals(dbTask.getNotifyTimes(), 9);
assertEquals(dbTask.getStatus(), PayNotifyStatusEnum.FAILURE.getStatus());
}
@@ -232,7 +233,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
public void testProcessNotifyResult_requestFailure() {
// mock 数据task
PayNotifyTaskDO task = randomPojo(PayNotifyTaskDO.class,
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9));
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9).setLastExecuteTime(null));
notifyTaskMapper.insert(task);
// 准备参数
CommonResult<?> invokeResult = CommonResult.error(BAD_REQUEST);
@@ -242,7 +243,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// 断言
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
assertNotEquals(task.getNextNotifyTime(), dbTask.getNextNotifyTime());
assertNotEquals(task.getLastExecuteTime(), dbTask.getNextNotifyTime());
assertNotNull(dbTask.getLastExecuteTime());
assertEquals(dbTask.getNotifyTimes(), 1);
assertEquals(dbTask.getStatus(), PayNotifyStatusEnum.REQUEST_SUCCESS.getStatus());
}
@@ -251,7 +252,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
public void testProcessNotifyResult_requestSuccess() {
// mock 数据task
PayNotifyTaskDO task = randomPojo(PayNotifyTaskDO.class,
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9));
o -> o.setNotifyTimes(0).setMaxNotifyTimes(9).setLastExecuteTime(null));
notifyTaskMapper.insert(task);
// 准备参数
CommonResult<?> invokeResult = CommonResult.error(BAD_REQUEST);
@@ -262,7 +263,7 @@ public class PayNotifyServiceTest extends BaseDbUnitTest {
// 断言
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
assertNotEquals(task.getNextNotifyTime(), dbTask.getNextNotifyTime());
assertNotEquals(task.getLastExecuteTime(), dbTask.getNextNotifyTime());
assertNotNull(dbTask.getLastExecuteTime());
assertEquals(dbTask.getNotifyTimes(), 1);
assertEquals(dbTask.getStatus(), PayNotifyStatusEnum.REQUEST_FAILURE.getStatus());
}

View File

@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.pay.service.wallet;
import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi;
import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO;
import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PayWalletRechargeServiceImplTest {
@Test
void successfulRefundUsesRechargeWalletIdWithoutReloadingWallet() {
PayWalletRechargeMapper rechargeMapper = mock(PayWalletRechargeMapper.class);
PayWalletService walletService = mock(PayWalletService.class);
PayRefundApi refundApi = mock(PayRefundApi.class);
PayWalletRechargeServiceImpl service = new PayWalletRechargeServiceImpl();
ReflectionTestUtils.setField(service, "walletRechargeMapper", rechargeMapper);
ReflectionTestUtils.setField(service, "payWalletService", walletService);
ReflectionTestUtils.setField(service, "payRefundApi", refundApi);
PayWalletRechargeDO recharge = new PayWalletRechargeDO();
recharge.setId(10L);
recharge.setWalletId(20L);
recharge.setTotalPrice(100);
recharge.setPayPrice(80);
recharge.setBonusPrice(20);
recharge.setPayRefundId(30L);
when(rechargeMapper.selectById(10L)).thenReturn(recharge);
PayRefundRespDTO refund = new PayRefundRespDTO();
refund.setStatus(PayRefundStatusEnum.SUCCESS.getStatus());
refund.setRefundPrice(80);
refund.setMerchantRefundId("10-refund");
refund.setSuccessTime(LocalDateTime.of(2026, 8, 1, 6, 0));
when(refundApi.getRefund(30L)).thenReturn(refund);
service.updateWalletRechargeRefunded(10L, "10-refund", 30L);
verify(walletService).reduceWalletBalance(
20L, 10L, PayWalletBizTypeEnum.RECHARGE_REFUND, 100);
verify(rechargeMapper).updateByIdAndRefunded(
org.mockito.ArgumentMatchers.eq(10L),
org.mockito.ArgumentMatchers.eq(PayRefundStatusEnum.WAITING.getStatus()),
any(PayWalletRechargeDO.class));
}
}

View File

@@ -0,0 +1,66 @@
package cn.iocoder.yudao.module.pay.service.wallet;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO;
import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletMapper;
import cn.iocoder.yudao.module.pay.dal.redis.wallet.PayWalletLockRedisDAO;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import cn.iocoder.yudao.module.pay.service.wallet.bo.WalletTransactionCreateReqBO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.concurrent.Callable;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PayWalletServiceImplTest {
private final PayWalletMapper walletMapper = mock(PayWalletMapper.class);
private final PayWalletLockRedisDAO lockRedisDAO = mock(PayWalletLockRedisDAO.class);
private final PayWalletTransactionService transactionService = mock(PayWalletTransactionService.class);
private final PayWalletServiceImpl service = new PayWalletServiceImpl();
@BeforeEach
void setUp() throws Exception {
ReflectionTestUtils.setField(service, "walletMapper", walletMapper);
ReflectionTestUtils.setField(service, "lockRedisDAO", lockRedisDAO);
ReflectionTestUtils.setField(service, "walletTransactionService", transactionService);
when(lockRedisDAO.lock(anyLong(), anyLong(), any())).thenAnswer(invocation -> {
Callable<?> callable = invocation.getArgument(2);
return callable.call();
});
}
@Test
void administrativeReductionDoesNotIncreaseExpenseTotal() {
PayWalletDO wallet = new PayWalletDO();
wallet.setId(9L);
wallet.setBalance(500);
when(walletMapper.selectById(9L)).thenReturn(wallet);
when(walletMapper.updateWhenSubtract(9L, 120)).thenReturn(1);
when(transactionService.createWalletTransaction(any(WalletTransactionCreateReqBO.class)))
.thenReturn(new PayWalletTransactionDO());
service.reduceWalletBalance(9L, 7L, PayWalletBizTypeEnum.UPDATE_BALANCE, 120);
verify(walletMapper).updateWhenSubtract(9L, 120);
verify(transactionService).createWalletTransaction(any(WalletTransactionCreateReqBO.class));
}
@Test
void addAndReduceRejectNonPositiveAmounts() {
assertThatThrownBy(() -> service.addWalletBalance(
9L, "7", PayWalletBizTypeEnum.UPDATE_BALANCE, 0))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.reduceWalletBalance(
9L, 7L, PayWalletBizTypeEnum.UPDATE_BALANCE, -1))
.isInstanceOf(IllegalArgumentException.class);
}
}

View File

@@ -4,5 +4,9 @@ DELETE FROM pay_order;
DELETE FROM pay_order_extension;
DELETE FROM pay_refund;
DELETE FROM pay_transfer;
DELETE FROM pay_wallet_recharge;
DELETE FROM pay_wallet_transaction;
DELETE FROM pay_wallet_recharge_package;
DELETE FROM pay_wallet;
DELETE FROM pay_notify_task;
DELETE FROM pay_notify_log;

View File

@@ -6,11 +6,13 @@ CREATE TABLE IF NOT EXISTS "pay_app" (
"remark" varchar(255) DEFAULT NULL,
`order_notify_url` varchar(1024) NOT NULL,
`refund_notify_url` varchar(1024) NOT NULL,
`transfer_notify_url` varchar(1024) DEFAULT NULL,
"creator" varchar(64) DEFAULT '',
"create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
"deleted" bit(1) NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT = '支付应用';
@@ -31,8 +33,36 @@ CREATE TABLE IF NOT EXISTS "pay_channel" (
PRIMARY KEY ("id")
) COMMENT = '支付渠道';
CREATE TABLE IF NOT EXISTS "pay_legacy_account_import" (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" bigint NOT NULL DEFAULT 0,
"source_tenant_id" varchar(36) NOT NULL,
"source_account_id" varchar(36) NOT NULL,
"source_checksum_sha256" char(64) NOT NULL,
"source_provider" varchar(32) NOT NULL,
"normalized_provider" varchar(16) NOT NULL,
"source_mode" varchar(32) NOT NULL,
"source_status" varchar(16) NOT NULL,
"target_app_id" bigint NOT NULL,
"target_channel_id" bigint NOT NULL,
"target_channel_code" varchar(32) NOT NULL,
"target_status" tinyint NOT NULL,
"normalized_config_digest" char(64) NOT NULL,
"mapping_notes" varchar(2048) NOT NULL,
"imported_by" bigint NOT NULL,
"imported_at" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creator" varchar(64) DEFAULT '',
"create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
"deleted" bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
UNIQUE ("tenant_id", "source_account_id")
) COMMENT = '旧支付账号导入审计';
CREATE TABLE IF NOT EXISTS `pay_order` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`app_id` bigint(20) NOT NULL,
`channel_id` bigint(20) DEFAULT NULL,
`channel_code` varchar(32) DEFAULT NULL,
@@ -65,6 +95,7 @@ CREATE TABLE IF NOT EXISTS `pay_order` (
CREATE TABLE IF NOT EXISTS `pay_order_extension` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`no` varchar(64) NOT NULL,
`order_id` bigint(20) NOT NULL,
`channel_id` bigint(20) NOT NULL,
@@ -85,6 +116,7 @@ CREATE TABLE IF NOT EXISTS `pay_order_extension` (
CREATE TABLE IF NOT EXISTS `pay_refund` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`no` varchar(64) NOT NULL,
`app_id` bigint(20) NOT NULL,
`channel_id` bigint(20) NOT NULL,
@@ -120,7 +152,9 @@ CREATE TABLE IF NOT EXISTS `pay_notify_task` (
`app_id` bigint(20) NOT NULL,
`type` tinyint(4) NOT NULL,
`data_id` bigint(20) NOT NULL,
`merchant_order_id` varchar(64) NOT NULL,
`merchant_order_id` varchar(64) NULL,
`merchant_refund_id` varchar(64) NULL,
`merchant_transfer_id` varchar(64) NULL,
`status` tinyint(4) NOT NULL,
`next_notify_time` datetime(0) NULL DEFAULT NULL,
`last_execute_time` datetime(0) NULL DEFAULT NULL,
@@ -138,6 +172,7 @@ CREATE TABLE IF NOT EXISTS `pay_notify_task` (
CREATE TABLE IF NOT EXISTS `pay_notify_log` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`task_id` bigint(20) NOT NULL,
`notify_times` int NOT NULL,
`response` varchar(1024) NOT NULL,
@@ -152,6 +187,7 @@ CREATE TABLE IF NOT EXISTS `pay_notify_log` (
CREATE TABLE IF NOT EXISTS `pay_transfer` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`no` varchar(64) NOT NULL,
`app_id` bigint(20) NOT NULL,
`channel_id` bigint(20) NOT NULL,
@@ -180,3 +216,79 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` (
`deleted` bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT = '转账单';
CREATE TABLE IF NOT EXISTS `pay_wallet` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`user_id` bigint(20) NOT NULL,
`user_type` tinyint(4) NOT NULL,
`balance` int NOT NULL DEFAULT 0,
`freeze_price` int NOT NULL DEFAULT 0,
`total_expense` int NOT NULL DEFAULT 0,
`total_recharge` int NOT NULL DEFAULT 0,
`creator` varchar(64) NULL DEFAULT '',
`create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updater` varchar(64) NULL DEFAULT '',
`update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT = '支付钱包';
CREATE TABLE IF NOT EXISTS `pay_wallet_transaction` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`no` varchar(64) NOT NULL,
`wallet_id` bigint(20) NOT NULL,
`biz_type` tinyint(4) NOT NULL,
`biz_id` varchar(64) NOT NULL,
`title` varchar(128) NOT NULL,
`price` int NOT NULL,
`balance` int NOT NULL,
`creator` varchar(64) NULL DEFAULT '',
`create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updater` varchar(64) NULL DEFAULT '',
`update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT = '支付钱包流水';
CREATE TABLE IF NOT EXISTS `pay_wallet_recharge_package` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`name` varchar(64) NOT NULL,
`pay_price` int NOT NULL,
`bonus_price` int NOT NULL DEFAULT 0,
`status` tinyint(4) NOT NULL,
`creator` varchar(64) NULL DEFAULT '',
`create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updater` varchar(64) NULL DEFAULT '',
`update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT = '支付钱包充值套餐';
CREATE TABLE IF NOT EXISTS `pay_wallet_recharge` (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
`tenant_id` bigint(20) NOT NULL DEFAULT 0,
`wallet_id` bigint(20) NOT NULL,
`total_price` int NOT NULL,
`pay_price` int NOT NULL,
`bonus_price` int NOT NULL DEFAULT 0,
`package_id` bigint(20) NULL,
`pay_status` bit(1) NOT NULL DEFAULT FALSE,
`pay_order_id` bigint(20) NULL,
`pay_channel_code` varchar(32) NULL,
`pay_time` datetime(0) NULL,
`pay_refund_id` bigint(20) NULL,
`refund_total_price` int NULL,
`refund_pay_price` int NULL,
`refund_bonus_price` int NULL,
`refund_time` datetime(0) NULL,
`refund_status` tinyint(4) NULL,
`creator` varchar(64) NULL DEFAULT '',
`create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updater` varchar(64) NULL DEFAULT '',
`update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT = '支付钱包充值';