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.ArrayList; import java.util.List; 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 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 result = withdrawalMapper.selectPage( new Page(safePage, safeSize), new LambdaQueryWrapper() .eq(GuideWithdrawal::getGuideId, guideId) .orderByDesc(GuideWithdrawal::getAppliedAt) .orderByDesc(GuideWithdrawal::getId)); return new PageResponse(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) { List requestSequences = callbackSequences(body); if (requestSequences.isEmpty()) { throw new BusinessException(400, "汇付提现回调缺少请求流水号"); } String requestSequence = null; GuideWithdrawal withdrawal = null; for (String candidate : requestSequences) { GuideWithdrawal matched = withdrawalMapper.selectByPayoutSequenceForUpdate(candidate); if (matched != null) { requestSequence = candidate; withdrawal = matched; break; } } 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()) || requestSequence.equals(withdrawal.getCashHfSeqId())); if (!processing && !recoverableTransferred) { return false; } validateCallback(body, withdrawal); String transStatus = first(body, "trans_stat", "cash_stat", "cash_status", "trans_status", "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", "cash_hf_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"); if (data != null) return first(data, keys); String dataText = body.getString("data"); if (StringUtils.hasText(dataText)) { try { JSONObject nested = JSONObject.parseObject(dataText); return nested == null ? null : first(nested, keys); } catch (RuntimeException ignored) { // 非 JSON 包装直接按字段缺失处理。 } } return null; } private List callbackSequences(JSONObject body) { List sequences = new ArrayList<>(); collectCallbackSequences(body, sequences); return sequences; } private void collectCallbackSequences(JSONObject body, List sequences) { if (body == null) return; String[] keys = {"req_seq_id", "org_req_seq_id", "cash_req_seq_id", "withdrawal_no", "hf_seq_id", "trans_seq_id", "cash_hf_seq_id"}; for (String key : keys) { String value = body.getString(key); if (StringUtils.hasText(value) && !sequences.contains(value)) sequences.add(value); } JSONObject data = body.getJSONObject("data"); if (data != null) { collectCallbackSequences(data, sequences); return; } String dataText = body.getString("data"); if (StringUtils.hasText(dataText)) { try { JSONObject nested = JSONObject.parseObject(dataText); collectCallbackSequences(nested, sequences); } catch (RuntimeException ignored) { // 非 JSON 包装继续使用顶层字段。 } } } }