package com.tourguide.modules.finance.service;
|
|
import com.alibaba.fastjson.JSONObject;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.tourguide.common.api.PageResponse;
|
import com.tourguide.common.exception.BusinessException;
|
import com.tourguide.modules.finance.dto.GuideWithdrawalCreateRequest;
|
import com.tourguide.modules.finance.dto.GuideWithdrawalSummaryResponse;
|
import com.tourguide.modules.finance.dto.WithdrawalResponse;
|
import com.tourguide.modules.finance.entity.GuideWithdrawal;
|
import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
|
import com.tourguide.modules.finance.mapper.GuideWithdrawalMapper;
|
import com.tourguide.modules.user.entity.GuideUser;
|
import com.tourguide.modules.user.entity.GuideTeam;
|
import com.tourguide.modules.user.mapper.GuideTeamMapper;
|
import com.tourguide.modules.user.mapper.GuideUserMapper;
|
import com.tourguide.modules.user.service.GuideUserService;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.util.StringUtils;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDateTime;
|
import java.time.format.DateTimeFormatter;
|
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.stream.Collectors;
|
|
/** 导游提现申请、审核发起和斗拱到账回调。 */
|
@Service
|
public class GuideWithdrawalService {
|
private static final DateTimeFormatter NO_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
|
private static final String STATUS_PENDING = "PENDING";
|
private static final String STATUS_APPROVED = "APPROVED";
|
private static final String STATUS_REJECTED = "REJECTED";
|
|
private final GuideWithdrawalMapper withdrawalMapper;
|
private final GuideSettlementService settlementService;
|
private final GuideUserService guideUserService;
|
private final GuideWithdrawableBalanceService balanceService;
|
private final GuideWithdrawalStateService stateService;
|
private final GuideWithdrawalPayoutService payoutService;
|
private final GuideTeamMapper teamMapper;
|
private final GuideUserMapper guideUserMapper;
|
|
public GuideWithdrawalService(GuideWithdrawalMapper withdrawalMapper,
|
GuideSettlementService settlementService,
|
GuideUserService guideUserService,
|
GuideWithdrawableBalanceService balanceService,
|
GuideWithdrawalStateService stateService,
|
GuideWithdrawalPayoutService payoutService,
|
GuideTeamMapper teamMapper,
|
GuideUserMapper guideUserMapper) {
|
this.withdrawalMapper = withdrawalMapper;
|
this.settlementService = settlementService;
|
this.guideUserService = guideUserService;
|
this.balanceService = balanceService;
|
this.stateService = stateService;
|
this.payoutService = payoutService;
|
this.teamMapper = teamMapper;
|
this.guideUserMapper = guideUserMapper;
|
}
|
|
public GuideWithdrawalSummaryResponse summary(Long guideId) {
|
try {
|
settlementService.requireApproved(guideId);
|
GuideUser guide = guideUserService.findEnabledById(guideId);
|
GuideTeam collectionTeam = findCollectionTeam(guide);
|
return new GuideWithdrawalSummaryResponse(collectionTeam == null
|
? balanceService.getAvailableAmount(guideId)
|
: balanceService.getAvailableTeamAmount(collectionTeam.getId()));
|
} catch (BusinessException exception) {
|
if (exception.getCode() == 409) {
|
return new GuideWithdrawalSummaryResponse(money(BigDecimal.ZERO));
|
}
|
throw exception;
|
}
|
}
|
|
public PageResponse<WithdrawalResponse> page(Long guideId, long page, long size) {
|
guideUserService.findEnabledById(guideId);
|
long safePage = Math.max(page, 1);
|
long safeSize = Math.min(Math.max(size, 1), 100);
|
Page<GuideWithdrawal> result = withdrawalMapper.selectPage(
|
new Page<GuideWithdrawal>(safePage, safeSize),
|
new LambdaQueryWrapper<GuideWithdrawal>()
|
.eq(GuideWithdrawal::getGuideId, guideId)
|
.orderByDesc(GuideWithdrawal::getAppliedAt)
|
.orderByDesc(GuideWithdrawal::getId));
|
return new PageResponse<WithdrawalResponse>(result.getRecords().stream()
|
.map(WithdrawalResponse::new).collect(Collectors.toList()),
|
result.getTotal(), result.getCurrent(), result.getSize());
|
}
|
|
@Transactional
|
public WithdrawalResponse create(Long guideId, GuideWithdrawalCreateRequest request) {
|
GuideUser guide = guideUserMapper.selectForUpdate(guideId);
|
if (guide == null || !Integer.valueOf(1).equals(guide.getStatus())) {
|
throw new BusinessException(401, "导游用户不存在或已停用");
|
}
|
GuideWithdrawalAccount account = settlementService.requireApprovedCardForUpdate(
|
guideId, request.getBankCardId());
|
BigDecimal amount = money(request.getAmount());
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
throw new BusinessException(400, "提现金额必须大于0");
|
}
|
GuideTeam collectionTeam = findCollectionTeam(guide);
|
boolean debited = collectionTeam == null
|
? balanceService.debit(guideId, amount)
|
: balanceService.debitTeam(collectionTeam.getId(), amount);
|
if (!debited) {
|
throw new BusinessException(409, "提现金额不能超过当前可提金额");
|
}
|
GuideWithdrawal withdrawal = new GuideWithdrawal();
|
withdrawal.setWithdrawalNo(generateWithdrawalNo());
|
withdrawal.setGuideId(guideId);
|
withdrawal.setTeamId(collectionTeam == null ? null : collectionTeam.getId());
|
withdrawal.setGuideName(StringUtils.hasText(guide.getRealName())
|
? guide.getRealName() : guide.getUsername());
|
withdrawal.setGuideMobile(guide.getMobile());
|
withdrawal.setAmount(amount);
|
withdrawal.setBankCardId(account.getId());
|
withdrawal.setBankName(account.getBankName());
|
withdrawal.setCardMasked(maskCard(account.getBankCardCode()));
|
withdrawal.setStatus(STATUS_PENDING);
|
withdrawal.setAppliedAt(LocalDateTime.now());
|
withdrawal.setCreatedAt(LocalDateTime.now());
|
withdrawal.setUpdatedAt(LocalDateTime.now());
|
withdrawalMapper.insert(withdrawal);
|
return new WithdrawalResponse(withdrawal);
|
}
|
|
public WithdrawalResponse process(Long id, String status, String remark, String processor) {
|
if (STATUS_REJECTED.equals(status)) {
|
return stateService.reject(id, remark, processor);
|
}
|
if (!STATUS_APPROVED.equals(status)) throw new BusinessException(400, "提现处理状态不正确");
|
return payoutService.approve(id, remark, processor);
|
}
|
|
@Transactional
|
public boolean handleCallback(JSONObject body) {
|
String requestSequence = first(body, "req_seq_id", "org_req_seq_id");
|
if (!StringUtils.hasText(requestSequence)) {
|
throw new BusinessException(400, "汇付提现回调缺少请求流水号");
|
}
|
GuideWithdrawal withdrawal = withdrawalMapper.selectByPayoutSequenceForUpdate(requestSequence);
|
if (withdrawal == null) throw new BusinessException(404, "提现申请不存在");
|
if (STATUS_APPROVED.equals(withdrawal.getStatus()) || STATUS_REJECTED.equals(withdrawal.getStatus())) {
|
return false;
|
}
|
boolean processing = GuideWithdrawalStateService.PROCESSING.equals(withdrawal.getStatus());
|
boolean recoverableTransferred = GuideWithdrawalStateService.TRANSFERRED.equals(withdrawal.getStatus())
|
&& requestSequence.equals(withdrawal.getCashReqSeqId());
|
if (!processing && !recoverableTransferred) {
|
return false;
|
}
|
validateCallback(body, withdrawal);
|
String transStatus = first(body, "trans_stat", "cash_stat", "status");
|
LocalDateTime now = LocalDateTime.now();
|
if (isFailure(body, transStatus)) {
|
String message = "导游账户取现失败:" + callbackMessage(body);
|
if (withdrawalMapper.resetCashForRetry(withdrawal.getId(), message, now, now) != 1) {
|
throw new BusinessException(500, "提现回调状态更新失败");
|
}
|
withdrawal.setStatus(GuideWithdrawalStateService.TRANSFERRED);
|
withdrawal.setCashReqSeqId(null);
|
withdrawal.setCashReqDate(null);
|
withdrawal.setCashHfSeqId(null);
|
withdrawal.setPayoutMessage(message);
|
withdrawal.setProcessedAt(now);
|
withdrawal.setUpdatedAt(now);
|
return true;
|
}
|
String hfSeqId = first(body, "hf_seq_id", "trans_seq_id");
|
if (StringUtils.hasText(hfSeqId)) withdrawal.setCashHfSeqId(hfSeqId);
|
if (isSuccess(transStatus)) {
|
withdrawal.setStatus(STATUS_APPROVED);
|
withdrawal.setPayoutMessage("取现到账成功");
|
withdrawal.setProcessedAt(now);
|
} else {
|
withdrawal.setStatus(GuideWithdrawalStateService.PROCESSING);
|
withdrawal.setPayoutMessage("导游账户取现结果待确认:" + callbackMessage(body));
|
}
|
withdrawal.setUpdatedAt(now);
|
if (withdrawalMapper.updateById(withdrawal) != 1) {
|
throw new BusinessException(500, "提现回调状态更新失败");
|
}
|
return true;
|
}
|
|
private void validateCallback(JSONObject body, GuideWithdrawal withdrawal) {
|
GuideWithdrawalAccount account = settlementService.findCurrent(withdrawal.getGuideId());
|
String callbackHuifuId = first(body, "huifu_id", "out_huifu_id");
|
if (account == null || !StringUtils.hasText(callbackHuifuId)
|
|| !callbackHuifuId.equals(account.getHuifuId())) {
|
throw new BusinessException(400, "汇付提现回调账户不匹配");
|
}
|
String amountText = first(body, "cash_amt", "trans_amt", "ord_amt");
|
if (StringUtils.hasText(amountText)) {
|
try {
|
if (withdrawal.getAmount().compareTo(new BigDecimal(amountText)) != 0) {
|
throw new BusinessException(409, "汇付提现回调金额不匹配");
|
}
|
} catch (NumberFormatException exception) {
|
throw new BusinessException(400, "汇付提现回调金额格式错误");
|
}
|
}
|
}
|
|
private String callbackMessage(JSONObject body) {
|
String message = first(body, "resp_desc", "fail_reason", "failure_reason");
|
return StringUtils.hasText(message) ? message : "斗拱未返回终态说明";
|
}
|
|
private String generateWithdrawalNo() {
|
return "WD" + LocalDateTime.now().format(NO_TIME_FORMAT)
|
+ String.format("%04d", ThreadLocalRandom.current().nextInt(10000));
|
}
|
|
private GuideTeam findCollectionTeam(GuideUser guide) {
|
if (guide == null || guide.getTeamId() == null) return null;
|
GuideTeam team = teamMapper.selectById(guide.getTeamId());
|
return team != null && guide.getId().equals(team.getCollectionGuideId()) ? team : null;
|
}
|
|
private String maskCard(String cardNumber) {
|
if (!StringUtils.hasText(cardNumber)) return "";
|
String value = cardNumber.trim();
|
return "****" + value.substring(Math.max(0, value.length() - 4));
|
}
|
|
private BigDecimal money(BigDecimal value) {
|
return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP);
|
}
|
|
private boolean isSuccess(String status) {
|
return "S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status)
|
|| "SUCCEEDED".equalsIgnoreCase(status);
|
}
|
|
private boolean isFailure(JSONObject body, String status) {
|
if ("F".equalsIgnoreCase(status) || "FAIL".equalsIgnoreCase(status)
|
|| "FAILED".equalsIgnoreCase(status)) {
|
return true;
|
}
|
String responseCode = first(body, "resp_code", "code");
|
return StringUtils.hasText(responseCode)
|
&& !"00000000".equals(responseCode)
|
&& !"00000100".equals(responseCode);
|
}
|
|
private String first(JSONObject body, String... keys) {
|
if (body == null) return null;
|
for (String key : keys) {
|
String value = body.getString(key);
|
if (StringUtils.hasText(value)) return value;
|
}
|
JSONObject data = body.getJSONObject("data");
|
return data == null ? null : first(data, keys);
|
}
|
}
|