feat(withdraw): 实现分销商提现微信自动转账功能
- 新增 WxTransferService 服务类实现微信商家转账到零钱功能 - 在 ShopDealerWithdrawController 中集成微信转账服务 - 修改 update 方法支持微信收款方式的自动转账处理 - 添加事务管理确保转账操作的数据一致性 - 实现转账参数验证和错误处理机制 - 支持通过 openid 自动获取和用户姓名验证 - 添加转账金额转换和批次号生成逻辑
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
package com.gxwebsoft.payment.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import com.gxwebsoft.payment.exception.PaymentException;
|
||||||
|
import com.wechat.pay.java.core.Config;
|
||||||
|
import com.wechat.pay.java.service.transferbatch.TransferBatchService;
|
||||||
|
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferRequest;
|
||||||
|
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferResponse;
|
||||||
|
import com.wechat.pay.java.service.transferbatch.model.TransferDetailInput;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付-商家转账到零钱(批量转账)封装
|
||||||
|
* 使用 wechatpay-java (APIv3) SDK 发起转账。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class WxTransferService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WxPayConfigService wxPayConfigService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起单笔“商家转账到零钱”(使用批量转账接口,明细数=1)。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID(用于获取微信支付配置)
|
||||||
|
* @param openid 收款用户openid(必须是该appid下的openid)
|
||||||
|
* @param amountYuan 转账金额(单位:元)
|
||||||
|
* @param outBatchNo 商家批次单号(字母/数字,商户内唯一)
|
||||||
|
* @param outDetailNo 商家明细单号(字母/数字,批次内唯一)
|
||||||
|
* @param batchName 批次名称(<=32字符)
|
||||||
|
* @param remark 备注(<=32字符),既用于批次备注,也用于单条明细备注
|
||||||
|
* @param userName 收款用户姓名(可选;金额>=2000元时强制要求)
|
||||||
|
*/
|
||||||
|
public InitiateBatchTransferResponse initiateSingleTransfer(Integer tenantId,
|
||||||
|
String openid,
|
||||||
|
BigDecimal amountYuan,
|
||||||
|
String outBatchNo,
|
||||||
|
String outDetailNo,
|
||||||
|
String batchName,
|
||||||
|
String remark,
|
||||||
|
String userName) throws PaymentException {
|
||||||
|
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw PaymentException.paramError("租户ID不能为空");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(openid)) {
|
||||||
|
throw PaymentException.paramError("收款用户openid不能为空");
|
||||||
|
}
|
||||||
|
if (amountYuan == null || amountYuan.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw PaymentException.amountError("转账金额必须大于0");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(outBatchNo) || !outBatchNo.matches("^[0-9A-Za-z]+$")) {
|
||||||
|
throw PaymentException.paramError("outBatchNo不合法(仅允许数字/大小写字母)");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(outDetailNo) || !outDetailNo.matches("^[0-9A-Za-z]+$")) {
|
||||||
|
throw PaymentException.paramError("outDetailNo不合法(仅允许数字/大小写字母)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信要求金额单位为“分”,必须为整数
|
||||||
|
long amountFen = amountYuan
|
||||||
|
.movePointRight(2)
|
||||||
|
.setScale(0, RoundingMode.HALF_UP)
|
||||||
|
.longValueExact();
|
||||||
|
|
||||||
|
if (amountFen <= 0) {
|
||||||
|
throw PaymentException.amountError("转账金额换算为分后必须大于0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额 >= 2000 元时,必须传收款用户姓名;金额 < 0.3 元时,不允许传姓名
|
||||||
|
boolean needUserName = amountFen >= 2000L * 100L;
|
||||||
|
boolean forbidUserName = amountFen < 30L;
|
||||||
|
if (needUserName && StrUtil.isBlank(userName)) {
|
||||||
|
throw PaymentException.paramError("转账金额>=2000元时必须提供收款用户姓名");
|
||||||
|
}
|
||||||
|
if (forbidUserName) {
|
||||||
|
userName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment paymentConfig = wxPayConfigService.getPaymentConfigForStrategy(tenantId);
|
||||||
|
Config wxPayConfig = wxPayConfigService.getWxPayConfig(tenantId);
|
||||||
|
|
||||||
|
InitiateBatchTransferRequest request = new InitiateBatchTransferRequest();
|
||||||
|
request.setAppid(paymentConfig.getAppId());
|
||||||
|
request.setOutBatchNo(outBatchNo);
|
||||||
|
request.setBatchName(limitLen(batchName, 32));
|
||||||
|
request.setBatchRemark(limitLen(remark, 32));
|
||||||
|
request.setTotalAmount(amountFen);
|
||||||
|
request.setTotalNum(1);
|
||||||
|
|
||||||
|
// 可选:复用支付通知地址(如配置为 https),用于接收转账结果通知
|
||||||
|
if (StrUtil.isNotBlank(paymentConfig.getNotifyUrl()) && paymentConfig.getNotifyUrl().startsWith("https://")) {
|
||||||
|
request.setNotifyUrl(paymentConfig.getNotifyUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferDetailInput detail = new TransferDetailInput();
|
||||||
|
detail.setOutDetailNo(outDetailNo);
|
||||||
|
detail.setTransferAmount(amountFen);
|
||||||
|
detail.setTransferRemark(limitLen(remark, 32));
|
||||||
|
detail.setOpenid(openid);
|
||||||
|
if (StrUtil.isNotBlank(userName)) {
|
||||||
|
detail.setUserName(userName);
|
||||||
|
}
|
||||||
|
request.setTransferDetailList(Collections.singletonList(detail));
|
||||||
|
|
||||||
|
try {
|
||||||
|
TransferBatchService service = new TransferBatchService.Builder().config(wxPayConfig).build();
|
||||||
|
InitiateBatchTransferResponse response = service.initiateBatchTransfer(request);
|
||||||
|
log.info("微信商家转账已受理: tenantId={}, outBatchNo={}, batchId={}, batchStatus={}",
|
||||||
|
tenantId, response.getOutBatchNo(), response.getBatchId(), response.getBatchStatus());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("微信商家转账失败: tenantId={}, outBatchNo={}, openid={}, amountFen={}, err={}",
|
||||||
|
tenantId, outBatchNo, openid, amountFen, e.getMessage(), e);
|
||||||
|
throw PaymentException.systemError("微信商家转账失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String limitLen(String s, int maxLen) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (s.length() <= maxLen) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return s.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,10 +9,11 @@ import com.gxwebsoft.shop.entity.ShopDealerWithdraw;
|
|||||||
import com.gxwebsoft.shop.param.ShopDealerWithdrawParam;
|
import com.gxwebsoft.shop.param.ShopDealerWithdrawParam;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
|
||||||
import com.gxwebsoft.common.core.web.BatchParam;
|
import com.gxwebsoft.common.core.web.BatchParam;
|
||||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.payment.exception.PaymentException;
|
||||||
|
import com.gxwebsoft.payment.service.WxTransferService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -20,7 +21,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ public class ShopDealerWithdrawController extends BaseController {
|
|||||||
private ShopDealerWithdrawService shopDealerWithdrawService;
|
private ShopDealerWithdrawService shopDealerWithdrawService;
|
||||||
@Resource
|
@Resource
|
||||||
private ShopDealerUserService shopDealerUserService;
|
private ShopDealerUserService shopDealerUserService;
|
||||||
|
@Resource
|
||||||
|
private WxTransferService wxTransferService;
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('shop:shopDealerWithdraw:list')")
|
@PreAuthorize("hasAuthority('shop:shopDealerWithdraw:list')")
|
||||||
@Operation(summary = "分页查询分销商提现明细表")
|
@Operation(summary = "分页查询分销商提现明细表")
|
||||||
@@ -86,19 +88,84 @@ public class ShopDealerWithdrawController extends BaseController {
|
|||||||
|
|
||||||
@PreAuthorize("hasAuthority('shop:shopDealerWithdraw:update')")
|
@PreAuthorize("hasAuthority('shop:shopDealerWithdraw:update')")
|
||||||
@OperationLog
|
@OperationLog
|
||||||
|
@Transactional(rollbackFor = {Exception.class})
|
||||||
@Operation(summary = "修改分销商提现明细表")
|
@Operation(summary = "修改分销商提现明细表")
|
||||||
@PutMapping()
|
@PutMapping()
|
||||||
public ApiResult<?> update(@RequestBody ShopDealerWithdraw shopDealerWithdraw) {
|
public ApiResult<?> update(@RequestBody ShopDealerWithdraw shopDealerWithdraw) {
|
||||||
// 驳回操作,退回金额
|
if (shopDealerWithdraw.getId() == null) {
|
||||||
if(shopDealerWithdraw.getApplyStatus().equals(30)){
|
return fail("id不能为空");
|
||||||
final ShopDealerUser dealerUser = shopDealerUserService.getByUserIdRel(shopDealerWithdraw.getUserId());
|
}
|
||||||
dealerUser.setMoney(dealerUser.getMoney().add(shopDealerWithdraw.getMoney()));
|
|
||||||
|
// 以DB原数据为准,避免前端未传字段导致逻辑判断错误/金额被篡改等问题
|
||||||
|
ShopDealerWithdraw db = shopDealerWithdrawService.getByIdRel(shopDealerWithdraw.getId());
|
||||||
|
if (db == null) {
|
||||||
|
return fail("提现记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer newStatus = shopDealerWithdraw.getApplyStatus() != null ? shopDealerWithdraw.getApplyStatus() : db.getApplyStatus();
|
||||||
|
Integer oldStatus = db.getApplyStatus();
|
||||||
|
Integer payType = shopDealerWithdraw.getPayType() != null ? shopDealerWithdraw.getPayType() : db.getPayType();
|
||||||
|
|
||||||
|
// 驳回操作(状态迁移到30时),退回金额,避免重复退回
|
||||||
|
if (Integer.valueOf(30).equals(newStatus) && !Integer.valueOf(30).equals(oldStatus)) {
|
||||||
|
ShopDealerUser dealerUser = shopDealerUserService.getByUserIdRel(db.getUserId());
|
||||||
|
if (dealerUser != null && dealerUser.getMoney() != null && db.getMoney() != null) {
|
||||||
|
dealerUser.setMoney(dealerUser.getMoney().add(db.getMoney()));
|
||||||
shopDealerUserService.updateById(dealerUser);
|
shopDealerUserService.updateById(dealerUser);
|
||||||
}
|
}
|
||||||
// 已打款,要求上传凭证
|
}
|
||||||
if(shopDealerWithdraw.getApplyStatus().equals(40)){
|
|
||||||
|
// 微信收款且审核通过(迁移到20时),自动发起商家转账到零钱,并将状态置为已打款(40)
|
||||||
|
if (Integer.valueOf(10).equals(payType)
|
||||||
|
&& Integer.valueOf(20).equals(newStatus)
|
||||||
|
&& !Integer.valueOf(20).equals(oldStatus)
|
||||||
|
&& !Integer.valueOf(40).equals(oldStatus)) {
|
||||||
|
|
||||||
|
Integer tenantId = db.getTenantId() != null ? db.getTenantId() : getTenantId();
|
||||||
|
if (tenantId == null) {
|
||||||
|
return fail("tenantId为空,无法发起微信转账");
|
||||||
|
}
|
||||||
|
|
||||||
|
String openid = StrUtil.isNotBlank(db.getOpenId()) ? db.getOpenId() : db.getOfficeOpenid();
|
||||||
|
if (StrUtil.isBlank(openid)) {
|
||||||
|
// 兜底:从分销商信息关联获取openid
|
||||||
|
ShopDealerUser dealerUser = shopDealerUserService.getByUserIdRel(db.getUserId());
|
||||||
|
if (dealerUser != null && StrUtil.isNotBlank(dealerUser.getOpenid())) {
|
||||||
|
openid = dealerUser.getOpenid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(openid)) {
|
||||||
|
return fail("用户openid为空,无法发起微信转账");
|
||||||
|
}
|
||||||
|
|
||||||
|
String outBatchNo = "WD" + db.getId();
|
||||||
|
String outDetailNo = "WD" + db.getId() + "D1";
|
||||||
|
String remark = "分销商提现";
|
||||||
|
String userName = db.getRealName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
wxTransferService.initiateSingleTransfer(
|
||||||
|
tenantId,
|
||||||
|
openid,
|
||||||
|
db.getMoney(),
|
||||||
|
outBatchNo,
|
||||||
|
outDetailNo,
|
||||||
|
"分销商提现",
|
||||||
|
remark,
|
||||||
|
userName
|
||||||
|
);
|
||||||
|
} catch (PaymentException e) {
|
||||||
|
return fail(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
shopDealerWithdraw.setApplyStatus(40);
|
||||||
shopDealerWithdraw.setAuditTime(LocalDateTime.now());
|
shopDealerWithdraw.setAuditTime(LocalDateTime.now());
|
||||||
if(StrUtil.isBlankIfStr(shopDealerWithdraw.getImage())){
|
}
|
||||||
|
|
||||||
|
// 已打款:非微信自动转账的场景,要求上传凭证
|
||||||
|
if (Integer.valueOf(40).equals(newStatus)) {
|
||||||
|
shopDealerWithdraw.setAuditTime(LocalDateTime.now());
|
||||||
|
if (!Integer.valueOf(10).equals(payType) && StrUtil.isBlankIfStr(shopDealerWithdraw.getImage())) {
|
||||||
return fail("请上传打款凭证");
|
return fail("请上传打款凭证");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user