From 4ebfb3cc6b813bdc812c54a6b58acecc00e31d22 Mon Sep 17 00:00:00 2001 From: hy001 Date: Mon, 20 Jul 2026 15:46:35 +0800 Subject: [PATCH] feat(withdrawal): route Yumi reviews through HyApp --- .../bank/api/UserBankBalanceClientApi.java | 12 +- .../api/UserBankRunningWaterClientApi.java | 16 +- .../model/cmd/ApprovalMoneyApplyCmd.java | 11 +- .../cmd/UserBankApprovalMoneyApplyCmd.java | 13 +- .../cmd/UserBankWithdrawMoneyApplyQryCmd.java | 11 +- .../cmd/UserBankWithdrawalRefundCmd.java | 30 ++ .../dto/UserBankWithdrawMoneyApplyDTO.java | 27 +- .../user/UserBankBalanceRestController.java | 12 +- ...WithdrawalReviewIntegrationController.java | 110 ++++ .../user/UserBankBalanceBackServiceImpl.java | 278 ++++++---- .../src/main/resources/application.yml | 15 +- .../command/bank/UserBankWithdrawCmdExe.java | 479 +++++++++++++----- .../app/convertor/SalaryAccountConvertor.java | 3 +- .../HyAppWithdrawalReviewClient.java | 157 ++++++ .../WithdrawalReviewSubmissionTask.java | 64 +++ .../wallet/app/dto/cmd/SalaryWithdrawCmd.java | 11 +- .../domain/gateway/SalaryAccountGateway.java | 9 +- .../mongo/entity/bank/UserBankBalance.java | 17 +- .../bank/UserBankWithdrawMoneyApply.java | 51 +- .../service/bank/UserBankBalanceService.java | 20 +- .../UserBankWithdrawMoneyApplyService.java | 54 +- .../bank/impl/UserBankBalanceServiceImpl.java | 96 +++- ...UserBankWithdrawMoneyApplyServiceImpl.java | 176 ++++++- .../gateway/SalaryAccountGatewayImpl.java | 93 ++-- .../SalaryAccountInnerConvertor.java | 8 +- .../UserBankRunningWaterClientEndpoint.java | 19 +- .../wallet/UserBankBalanceClientEndpoint.java | 14 +- .../UserBankRunningWaterClientService.java | 6 +- ...UserBankRunningWaterClientServiceImpl.java | 16 +- .../wallet/UserBankBalanceClientService.java | 7 +- .../UserBankBalanceClientServiceImpl.java | 15 +- .../src/main/resources/application.yml | 20 +- 32 files changed, 1541 insertions(+), 329 deletions(-) create mode 100644 rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawalRefundCmd.java create mode 100644 rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/integration/WithdrawalReviewIntegrationController.java create mode 100644 rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/integration/HyAppWithdrawalReviewClient.java create mode 100644 rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WithdrawalReviewSubmissionTask.java diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankBalanceClientApi.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankBalanceClientApi.java index ac42a8c0..9af46c51 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankBalanceClientApi.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankBalanceClientApi.java @@ -3,7 +3,8 @@ package com.red.circle.wallet.inner.endpoint.bank.api; import com.red.circle.framework.dto.ResultResponse; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd; import com.red.circle.wallet.inner.model.dto.BankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO; @@ -38,7 +39,14 @@ public interface UserBankBalanceClientApi { * @return 是否操作成功 */ @PostMapping("/incr") - ResultResponse incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd); + ResultResponse incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd); + + /** + * 提现驳回按 trackId 原子退款,供统一审核回调安全重试. + */ + @PostMapping("/refundWithdrawalOnce") + ResultResponse refundWithdrawalOnce( + @RequestBody @Validated UserBankWithdrawalRefundCmd cmd); /** * 类型消费累计. diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankRunningWaterClientApi.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankRunningWaterClientApi.java index d56777df..e6d746d4 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankRunningWaterClientApi.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-api/src/main/java/com/red/circle/wallet/inner/endpoint/bank/api/UserBankRunningWaterClientApi.java @@ -75,8 +75,20 @@ public interface UserBankRunningWaterClientApi { @GetMapping("/getWithdrawMoneyApplyById") ResultResponse getWithdrawMoneyApplyById(@RequestParam("id") Long id); - @PostMapping("/approvalMoneyApply") - ResultResponse approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply); + @PostMapping("/approvalMoneyApply") + ResultResponse approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply); + + /** + * 统一双审专用 CAS 接口;保留旧接口响应协议,确保 Wallet 先发、Console 后发的滚动发布兼容. + */ + @PostMapping("/approvalMoneyApplyCas") + ResultResponse approvalMoneyApplyCas( + @RequestBody UserBankApprovalMoneyApplyCmd apply); + + @PostMapping("/markExternalReviewRefundCompleted") + ResultResponse markExternalReviewRefundCompleted( + @RequestParam("id") Long id, + @RequestParam("commandId") String commandId); /** * 月用户工资流水 diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/ApprovalMoneyApplyCmd.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/ApprovalMoneyApplyCmd.java index ccb9c610..6960bb05 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/ApprovalMoneyApplyCmd.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/ApprovalMoneyApplyCmd.java @@ -44,6 +44,11 @@ public class ApprovalMoneyApplyCmd extends CommonCommand { */ private String remark; - private Long updateUser; - -} + private Long updateUser; + + /** + * 统一审核终态命令号;普通旧 Console 审核为空,外部双审回调必须携带并持久化. + */ + private String externalReviewCommandId; + +} diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankApprovalMoneyApplyCmd.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankApprovalMoneyApplyCmd.java index 280d37ae..e8fe1004 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankApprovalMoneyApplyCmd.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankApprovalMoneyApplyCmd.java @@ -46,7 +46,12 @@ public class UserBankApprovalMoneyApplyCmd extends CommonCommand { */ private Long updateUser; - private Integer updateUserOrigin; - - -} + private Integer updateUserOrigin; + + /** + * 统一审核终态命令号,用于区分同命令补偿与另一个审核动作. + */ + private String externalReviewCommandId; + + +} diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawMoneyApplyQryCmd.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawMoneyApplyQryCmd.java index 03789075..3278cae7 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawMoneyApplyQryCmd.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawMoneyApplyQryCmd.java @@ -73,6 +73,11 @@ public class UserBankWithdrawMoneyApplyQryCmd implements Serializable { /** * 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策) */ - private String amountType; - -} + private String amountType; + + /** + * 仅查询仍由旧 Console 审核的申请;统一双审托管单不能继续出现在旧审核入口. + */ + private Boolean legacyReviewOnly; + +} diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawalRefundCmd.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawalRefundCmd.java new file mode 100644 index 00000000..81e6f75f --- /dev/null +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/cmd/UserBankWithdrawalRefundCmd.java @@ -0,0 +1,30 @@ +package com.red.circle.wallet.inner.model.cmd; + +import com.red.circle.framework.dto.Command; +import com.red.circle.tool.core.tuple.PennyAmount; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/** + * 主播银行卡提现驳回退款命令;trackId 是原提现派生的稳定资金幂等键. + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +public class UserBankWithdrawalRefundCmd extends Command { + + @NotNull(message = "userId required.") + private Long userId; + + @NotBlank(message = "sysOrigin required.") + private String sysOrigin; + + @NotNull(message = "amount required.") + private PennyAmount amount; + + @NotBlank(message = "trackId required.") + private String trackId; +} diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/dto/UserBankWithdrawMoneyApplyDTO.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/dto/UserBankWithdrawMoneyApplyDTO.java index c7c53ae7..8669d72d 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/dto/UserBankWithdrawMoneyApplyDTO.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/dto/UserBankWithdrawMoneyApplyDTO.java @@ -38,7 +38,12 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable { /** * 银行卡类型. */ - private String bankCardType; + private String bankCardType; + + /** + * 申请提交时的收款地址快照;统一审核回调必须使用该事实,不能回查用户后来修改的银行卡. + */ + private String withdrawAddress; /** * + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod; @@ -56,7 +61,12 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable { /** * 提交金额. */ - private BigDecimal amount; + private BigDecimal amount; + + /** + * 本次提现原子扣款前的可用余额快照. + */ + private BigDecimal balanceBefore; /** * 提交类型 MONEY(美金)、SALARY_DIAMOND(钻石). @@ -83,13 +93,22 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable { */ private String latestApprovalStatus; - private String latestApprovalStatusName; + private String latestApprovalStatusName; + + /** + * 非空表示该申请已纳入 HyApp 统一双审,旧 Console 入口不得直接审核. + */ + private String externalReviewStatus; + + private String externalReviewDecisionCommandId; + + private String externalReviewRefundStatus; /** * 结算结果; */ - private UserBankWithdrawMoneyResult settlementResult; + private UserBankWithdrawMoneyResult settlementResult; /** * 创建时间. diff --git a/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/app/user/UserBankBalanceRestController.java b/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/app/user/UserBankBalanceRestController.java index 067ef694..8fd783fc 100644 --- a/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/app/user/UserBankBalanceRestController.java +++ b/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/app/user/UserBankBalanceRestController.java @@ -212,11 +212,13 @@ public class UserBankBalanceRestController extends BaseController { * 审核用户银行卡提现现金申请. */ @PostMapping("/approval-money-apply") - public void approvalMoneyApply( - @RequestBody @Validated ApprovalMoneyApplyCmd param) { - - param.setUpdateUser(param.getReqUserId()); - userBankBalanceBackService.approvalMoneyApply(param); + public void approvalMoneyApply( + @RequestBody @Validated ApprovalMoneyApplyCmd param) { + + param.setUpdateUser(param.getReqUserId()); + // 登录态旧 Console 永远不能伪造统一审核命令;外部 commandId 只允许专用 Bearer 回调入口写入. + param.setExternalReviewCommandId(null); + userBankBalanceBackService.approvalMoneyApply(param); } diff --git a/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/integration/WithdrawalReviewIntegrationController.java b/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/integration/WithdrawalReviewIntegrationController.java new file mode 100644 index 00000000..8df795b1 --- /dev/null +++ b/rc-service/rc-service-console/console-adapter/src/main/java/com/red/circle/console/adapter/integration/WithdrawalReviewIntegrationController.java @@ -0,0 +1,110 @@ +package com.red.circle.console.adapter.integration; + +import com.red.circle.console.app.service.app.user.UserBankBalanceBackService; +import com.red.circle.wallet.inner.model.cmd.ApprovalMoneyApplyCmd; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * hyapp-server 统一提现双审对 ChatApp3/Yumi 钱包的终态回调入口. + * + *

该接口不依赖 Console 登录态,但使用与提交令牌分离的资金回调令牌;真正的幂等与退款门禁仍在钱包 SUBMIT 条件更新中完成. + */ +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/integration/withdrawal-review", produces = MediaType.APPLICATION_JSON_VALUE) +public class WithdrawalReviewIntegrationController { + + private final UserBankBalanceBackService userBankBalanceBackService; + + @Value("${hyapp.withdrawal-review.callback-token:}") + private String callbackToken; + + @PostMapping("/decision") + public void applyDecision( + @RequestHeader(value = "Authorization", required = false) String authorization, + @RequestBody @Valid DecisionRequest request) { + verifyToken(authorization); + if (!"PASS".equals(request.getDecision()) && !"NOT_PASS".equals(request.getDecision())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid withdrawal decision"); + } + if ("PASS".equals(request.getDecision()) + && (request.getCredentialUrls() == null || request.getCredentialUrls().isEmpty())) { + // 财务通过必须带打款凭证;来源钱包再次校验,不能只依赖 Admin 前端门禁。 + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "withdrawal approval credential is required"); + } + + long sourceApplicationId; + try { + sourceApplicationId = Long.parseLong(request.getSourceApplicationId()); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "invalid source withdrawal application id", exception); + } + userBankBalanceBackService.approvalMoneyApply(new ApprovalMoneyApplyCmd() + .setId(sourceApplicationId) + .setCredential(request.getCredentialUrls()) + .setApprovalStatus(request.getDecision()) + .setRemark(request.getRemark()) + .setExternalReviewCommandId(request.getCommandId()) + .setUpdateUser(request.getReviewerUserId())); + } + + private void verifyToken(String authorization) { + boolean bearer = authorization != null && authorization.startsWith("Bearer "); + String provided = bearer ? authorization.substring("Bearer ".length()).trim() : ""; + String expected = callbackToken == null ? "" : callbackToken.trim(); + boolean valid = !provided.isEmpty() && !expected.isEmpty() + && MessageDigest.isEqual(provided.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + if (!valid) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "withdrawal callback authentication failed"); + } + } + + @Data + public static class DecisionRequest { + + @NotBlank + private String sourceApplicationId; + + @NotBlank + private String decision; + + private List credentialUrls; + + private String remark; + + @NotNull + @Positive + private Long reviewerUserId; + + private String reviewerName; + + /** + * Admin 每个终态动作的唯一命令号;当前钱包以申请 SUBMIT 原子门禁完成幂等,保留字段便于跨系统审计关联. + */ + @NotBlank + private String commandId; + } +} diff --git a/rc-service/rc-service-console/console-application/src/main/java/com/red/circle/console/app/service/app/user/UserBankBalanceBackServiceImpl.java b/rc-service/rc-service-console/console-application/src/main/java/com/red/circle/console/app/service/app/user/UserBankBalanceBackServiceImpl.java index 40ccb430..7341013e 100644 --- a/rc-service/rc-service-console/console-application/src/main/java/com/red/circle/console/app/service/app/user/UserBankBalanceBackServiceImpl.java +++ b/rc-service/rc-service-console/console-application/src/main/java/com/red/circle/console/app/service/app/user/UserBankBalanceBackServiceImpl.java @@ -74,9 +74,12 @@ import java.util.stream.Stream; */ @Service @RequiredArgsConstructor -public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService { - - private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class); +public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService { + + private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class); + private static final Set EXTERNAL_REVIEW_STATUSES = + Set.of("PENDING", "FAILED", "SUBMITTED"); + private static final String WITHDRAWAL_REFUND_TRACK_PREFIX = "withdraw-refund:"; private final UserService userService; private final RedisService redisService; private final BankAppConvertor bankAppConvertor; @@ -589,10 +592,13 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic } @Override - public List pageUserBankWithdrawMoneyApply( - UserBankWithdrawMoneyApplyQryCmd query) { - - List applyList = ResponseAssert.requiredSuccess( + public List pageUserBankWithdrawMoneyApply( + UserBankWithdrawMoneyApplyQryCmd query) { + + // 旧 Console 只保留历史审核 owner;进入 HyApp 双审的申请必须从该列表剔除,避免两个后台争抢终态. + query.setLegacyReviewOnly(true); + + List applyList = ResponseAssert.requiredSuccess( bankRunningWaterClient .listBankWithdrawMoneyApply(query)); @@ -682,14 +688,17 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic } @Override - public void exportUserBankWithdrawMoneyApply( - HttpServletResponse response, - UserBankWithdrawMoneyApplyExportQryCmd query) { - - List applies = ResponseAssert.requiredSuccess( - bankRunningWaterClient - .listBankWithdrawMoneyApply( - bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query))); + public void exportUserBankWithdrawMoneyApply( + HttpServletResponse response, + UserBankWithdrawMoneyApplyExportQryCmd query) { + + UserBankWithdrawMoneyApplyQryCmd walletQuery = + bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query); + walletQuery.setLegacyReviewOnly(true); + + List applies = ResponseAssert.requiredSuccess( + bankRunningWaterClient + .listBankWithdrawMoneyApply(walletQuery)); if (CollectionUtils.isEmpty(applies)) { return; @@ -763,10 +772,11 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic if (CollectionUtils.isEmpty(userProfileMap)) { return new UserBankWithdrawMoneyApplyCO(); } - if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(), - WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) { - UserBankCardDTO userBankCard = ResponseAssert.requiredSuccess( - userBankCardClient.getById(apply.getBankCardId())); + if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(), + WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) { + UserBankCardDTO userBankCard = isExternalReviewManaged(apply) + ? toWithdrawalBankCardSnapshot(apply) + : ResponseAssert.requiredSuccess(userBankCardClient.getById(apply.getBankCardId())); apply.setSettlementResult(Optional.ofNullable(apply.getSettlementResult()) .map(settlement -> { settlement.setAcceptBankCard(userBankCard); @@ -778,29 +788,54 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic return toUserBankWithdrawMoneyApplyVO(apply, userProfileMap); } - @Override - public void approvalMoneyApply(ApprovalMoneyApplyCmd param) { - - WithdrawMoneyApprovalProcessStatus status = WithdrawMoneyApprovalProcessStatus - .valueOf(param.getApprovalStatus()); - ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects - .equals(status, WithdrawMoneyApprovalProcessStatus.SUBMIT)); - - UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess( - bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId())); - ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply); - ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, Objects - .equals(apply.getLatestApprovalStatus(), WithdrawMoneyApprovalProcessStatus.SUBMIT.name())); - - UserBankCardDTO bankCard = ResponseAssert.requiredSuccess( - userBankCardClient.getById(apply.getBankCardId())); - ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard); - - bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd() - .setId(apply.getId()) - .setUpdateUserOrigin(1) - .setUpdateUser(param.getUpdateUser()) - .setSettlementResult(new UserBankWithdrawMoneyResult() + @Override + public void approvalMoneyApply(ApprovalMoneyApplyCmd param) { + + WithdrawMoneyApprovalProcessStatus status = WithdrawMoneyApprovalProcessStatus + .valueOf(param.getApprovalStatus()); + ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects + .equals(status, WithdrawMoneyApprovalProcessStatus.SUBMIT)); + + // 同一申请的终态、退款和完成标记必须串行;锁失败让 Admin 重试,不能并发执行两次资金补偿. + String lockKey = "withdrawal-review-approval:" + param.getId(); + ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(lockKey, 60)); + try { + approvalMoneyApplyLocked(param); + } finally { + redisService.unlock(lockKey); + } + } + + private void approvalMoneyApplyLocked(ApprovalMoneyApplyCmd param) { + + UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess( + bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId())); + ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply); + + boolean externalManaged = isExternalReviewManaged(apply); + boolean externalCommand = StringUtils.isNotBlank(param.getExternalReviewCommandId()); + // 旧登录态 Controller 会清空 commandId;外部托管单只能走独立 Bearer 回调,回调也不能接管历史旧单. + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + externalManaged == externalCommand); + + if (!Objects.equals(apply.getLatestApprovalStatus(), + WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) { + completeTerminalReview(param, apply, externalManaged); + return; + } + + UserBankCardDTO bankCard = externalManaged + ? toWithdrawalBankCardSnapshot(apply) + : ResponseAssert.requiredSuccess(userBankCardClient.getById(apply.getBankCardId())); + ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard); + + Boolean updated = ResponseAssert.requiredSuccess( + bankRunningWaterClient.approvalMoneyApplyCas(new UserBankApprovalMoneyApplyCmd() + .setId(apply.getId()) + .setUpdateUserOrigin(1) + .setUpdateUser(param.getUpdateUser()) + .setExternalReviewCommandId(param.getExternalReviewCommandId()) + .setSettlementResult(new UserBankWithdrawMoneyResult() .setAcceptBankCard(bankCard) .setCredential(Optional.ofNullable(param.getCredential()) .map(imgUrls -> imgUrls.stream() @@ -817,12 +852,72 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic .setStatus(param.getApprovalStatus()) .setRemark(param.getRemark()) .setCreateTime(TimestampUtils.now()) - ) - ); - - // 如果是驳回则将金额还给用户银行卡账户且插入返还日志 - saveUserBankBalance(param, apply); - } + ) + )); + + if (!Boolean.TRUE.equals(updated)) { + // Mongo 以 latestApprovalStatus=SUBMIT 做条件更新;并发重复审核只有一个调用者可以进入后续退款。 + UserBankWithdrawMoneyApplyDTO latest = ResponseAssert.requiredSuccess( + bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId())); + ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, latest); + completeTerminalReview(param, latest, externalManaged); + return; + } + + if (Objects.equals(param.getApprovalStatus(), + WithdrawMoneyApprovalProcessStatus.NOT_PASS.name())) { + // 终态先落库,退款失败时 Admin 用相同 commandId 重试;资金动作自身也必须按申请号幂等. + saveUserBankBalance(param, apply); + if (externalManaged) { + markExternalReviewRefundCompleted(apply, param); + } + } + } + + private void completeTerminalReview(ApprovalMoneyApplyCmd param, + UserBankWithdrawMoneyApplyDTO apply, boolean externalManaged) { + // Admin 可能在来源处理成功但自身事务提交失败后重试;相反终态永远不能覆盖已落资金事实. + ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, + Objects.equals(apply.getLatestApprovalStatus(), param.getApprovalStatus())); + if (!externalManaged) { + return; + } + ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, + Objects.equals(apply.getExternalReviewDecisionCommandId(), + param.getExternalReviewCommandId())); + if (!Objects.equals(param.getApprovalStatus(), + WithdrawMoneyApprovalProcessStatus.NOT_PASS.name()) + || Objects.equals(apply.getExternalReviewRefundStatus(), "COMPLETED")) { + return; + } + // NOT_PASS 与退款完成分开持久化;这里补偿响应丢失或上次退款中断的同一命令. + saveUserBankBalance(param, apply); + markExternalReviewRefundCompleted(apply, param); + } + + private void markExternalReviewRefundCompleted(UserBankWithdrawMoneyApplyDTO apply, + ApprovalMoneyApplyCmd param) { + Boolean marked = ResponseAssert.requiredSuccess( + bankRunningWaterClient.markExternalReviewRefundCompleted( + apply.getId(), param.getExternalReviewCommandId())); + ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, Boolean.TRUE.equals(marked)); + } + + private boolean isExternalReviewManaged(UserBankWithdrawMoneyApplyDTO apply) { + return apply != null && EXTERNAL_REVIEW_STATUSES.contains(apply.getExternalReviewStatus()); + } + + private UserBankCardDTO toWithdrawalBankCardSnapshot(UserBankWithdrawMoneyApplyDTO apply) { + ResponseAssert.isTrue(BankErrorCode.INVALID_BANK_CARD_ERROR, + StringUtils.isNotBlank(apply.getBankCardType()) + && StringUtils.isNotBlank(apply.getWithdrawAddress())); + return new UserBankCardDTO() + .setId(apply.getBankCardId()) + .setUserId(apply.getSubmitUserId()) + .setSysOrigin(apply.getSysOrigin()) + .setCardType(apply.getBankCardType()) + .setCardNo(apply.getWithdrawAddress()); + } @Override public void importUserBankBalance(MultipartFile file, CommonCommand cmd) { @@ -894,8 +989,8 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic } } - private void saveUserBankBalance(ApprovalMoneyApplyCmd param, - UserBankWithdrawMoneyApplyDTO apply) { + private void saveUserBankBalance(ApprovalMoneyApplyCmd param, + UserBankWithdrawMoneyApplyDTO apply) { // 非驳回则不做操作. if (!Objects @@ -903,45 +998,55 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic return; } - // 非主播工资进 salary account账户 - if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) { - SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd(); + String refundTrackId = WITHDRAWAL_REFUND_TRACK_PREFIX + apply.getId(); + + // 非主播工资进 salary account 账户;独立退款 trackId 不能与原 WITHDRAW 流水共用申请号. + if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) { + SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd(); withdrawInnerCmd.setUserId(apply.getSubmitUserId()); withdrawInnerCmd.setOpUserId(param.getUpdateUser()); - withdrawInnerCmd.setSysOrigin(apply.getSysOrigin()); - withdrawInnerCmd.setRemark(param.getRemark()); - withdrawInnerCmd.setSalaryType(apply.getAmountType()); - withdrawInnerCmd.setAmount(apply.getAmount()); - userSalaryAccountClient.withdrawRefund(withdrawInnerCmd); - return; + withdrawInnerCmd.setSysOrigin(apply.getSysOrigin()); + withdrawInnerCmd.setRemark(param.getRemark()); + withdrawInnerCmd.setSalaryType(apply.getAmountType()); + withdrawInnerCmd.setAmount(apply.getAmount()); + withdrawInnerCmd.setTrackId(refundTrackId); + ResponseAssert.requiredSuccess(userSalaryAccountClient.withdrawRefund(withdrawInnerCmd)); + // 驳回后用户应能重新提交;HOST 与其他工资类型都必须清理同一月度提现限制。 + ResponseAssert.requiredSuccess( + userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId())); + return; } - ResponseAssert.requiredSuccess(userBankBalanceClient.incr( - new UserBankBalanceIncrCmd() - .setUserId(apply.getSubmitUserId()) - .setSysOrigin(apply.getSysOrigin()) - .setAmount(PennyAmount.ofDollar(apply.getAmount()))) - ); - - bankRunningWaterClient.add(new UserBankRunningWaterDTO() - .setId(IdWorkerUtils.getId()) - .setUserId(apply.getSubmitUserId()) - .setSysOrigin(apply.getSysOrigin()) - .setType(0) - .setTrackId(apply.getId().toString()) - .setAmount(apply.getAmount()) - .setEvent(UserBankWaterEvent.WITHDRAW.name()) - .setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe()) - .setRemark(param.getRemark()) - .setBalance( - ResponseAssert.requiredSuccess( - userBankBalanceClient.getBalance(apply.getSubmitUserId())).getAccurateBalance()) - .setCreateTime(TimestampUtils.now()) - .setCreateUser(param.getUpdateUser()) - .setCreateUserOrigin(1) - ); - - userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()); + BankBalanceDTO refundedBalance = ResponseAssert.requiredSuccess( + userBankBalanceClient.refundWithdrawalOnce(new UserBankWithdrawalRefundCmd() + .setUserId(apply.getSubmitUserId()) + .setSysOrigin(apply.getSysOrigin()) + .setAmount(PennyAmount.ofDollar(apply.getAmount())) + .setTrackId(refundTrackId))); + ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, refundedBalance); + + Boolean refundWaterExists = ResponseAssert.requiredSuccess( + bankRunningWaterClient.existsTrackId(apply.getSubmitUserId(), + UserBankWaterEvent.REFUND.name(), refundTrackId)); + if (!Boolean.TRUE.equals(refundWaterExists)) { + // 余额退款标记先于流水;若插入失败,Admin 重试会跳过加钱并只补齐同一业务号的退款流水. + ResponseAssert.requiredSuccess(bankRunningWaterClient.add(new UserBankRunningWaterDTO() + .setId(IdWorkerUtils.getId()) + .setUserId(apply.getSubmitUserId()) + .setSysOrigin(apply.getSysOrigin()) + .setType(ReceiptType.INCOME.getType()) + .setTrackId(refundTrackId) + .setAmount(apply.getAmount()) + .setEvent(UserBankWaterEvent.REFUND.name()) + .setEventDescribe(UserBankWaterEvent.REFUND.getDescribe()) + .setRemark(param.getRemark()) + .setBalance(refundedBalance.getAccurateBalance()) + .setCreateTime(TimestampUtils.now()) + .setCreateUser(param.getUpdateUser()) + .setCreateUserOrigin(1))); + } + ResponseAssert.requiredSuccess( + userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId())); } private void saveUserBankDiamondBalance(ApprovalMoneyApplyCmd param, @@ -978,6 +1083,7 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic .setCreateUserOrigin(0) ); - userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()); + ResponseAssert.requiredSuccess( + userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId())); } } diff --git a/rc-service/rc-service-console/console-start/src/main/resources/application.yml b/rc-service/rc-service-console/console-start/src/main/resources/application.yml index 6580ec6c..04741aa1 100644 --- a/rc-service/rc-service-console/console-start/src/main/resources/application.yml +++ b/rc-service/rc-service-console/console-start/src/main/resources/application.yml @@ -43,10 +43,15 @@ management: enabled: false info: enabled: true - health: - show-details: always - -framework: + health: + show-details: always + +# 与 hyapp-server 的 Yumi withdrawal source callback_token 保持一致,只允许由生产运行环境注入。 +hyapp: + withdrawal-review: + callback-token: ${HYAPP_WITHDRAWAL_REVIEW_CALLBACK_TOKEN:} + +framework: nacos: subscribeServices: - rc-auth @@ -78,3 +83,5 @@ red-circle: - /console/datav/country-dashboard/game-metrics/games - /console/datav/country-dashboard/game-metrics/country-rank - /console/user/base/info/im/sig + # 该入口不走 Console 登录态;控制器使用独立资金回调令牌做常量时间鉴权。 + - /console/integration/withdrawal-review/decision diff --git a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/command/bank/UserBankWithdrawCmdExe.java b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/command/bank/UserBankWithdrawCmdExe.java index 924ed92f..bb8bf4cb 100644 --- a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/command/bank/UserBankWithdrawCmdExe.java +++ b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/command/bank/UserBankWithdrawCmdExe.java @@ -18,8 +18,9 @@ import com.red.circle.tool.core.regex.RegexConstant; import com.red.circle.tool.core.sequence.IdWorkerUtils; import com.red.circle.tool.core.text.StringUtils; import com.red.circle.tool.core.tuple.PennyAmount; -import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO; -import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd; +import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO; +import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd; +import com.red.circle.wallet.app.scheduler.WithdrawalReviewSubmissionTask; import com.red.circle.wallet.domain.gateway.SalaryAccountGateway; import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankRunningWater; import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService; @@ -40,21 +41,28 @@ import com.red.circle.wallet.inner.error.BankErrorCode; import com.red.circle.wallet.inner.error.WalletErrorCode; import com.red.circle.wallet.inner.model.enums.UserBankWaterEvent; import com.red.circle.wallet.inner.model.enums.WithdrawMethod; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.List; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; /** * 用户银行卡提现. * * @author pengliang on 2023/2/20 */ -@Component -@RequiredArgsConstructor -public class UserBankWithdrawCmdExe { +@Component +@RequiredArgsConstructor +@Slf4j +public class UserBankWithdrawCmdExe { + + private static final int PREPARING_RECOVERY_BATCH_SIZE = 50; + private static final long PREPARING_RECOVERY_AGE_MS = 30_000L; private final RedisService redisService; private final RegionConfigClient regionConfigClient; @@ -64,97 +72,332 @@ public class UserBankWithdrawCmdExe { private final UserBankWithdrawMoneyApplyService userBankWithdrawMoneyApplyService; private final SalaryAccountGateway salaryAccountGateway; private final UserBankBalanceService userBankBalanceService; - private final UserBankRunningWaterService userBankRunningWaterService; - private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe; + private final UserBankRunningWaterService userBankRunningWaterService; + private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe; + private final WithdrawalReviewSubmissionTask withdrawalReviewSubmissionTask; - public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) { - ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, ArithmeticUtils.gtZero(cmd.getAmount())); - - // 检查是否允许提现 - checkHostSalaryWithdrawEnabled(cmd); - - // 上锁 - String key = "UBWithdraw:" + cmd.requiredReqUserId(); - ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60)); - - SalaryType salaryType = SalaryType.of(cmd.getSalaryType()); - - BigDecimal balance = getUserBalance(cmd, salaryType); + public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) { + normalizeAndValidateAmount(cmd); + + if (!withdrawalReviewSubmissionTask.shouldRouteNewApplication()) { + // future cutover 前必须保持旧 SUBMIT writer 协议;否则滚动期间旧 Wallet 节点不认识 PREPARING, + // 请求重试可能在旧节点创建第二个申请并扣款,随后新节点又恢复第一笔造成双扣. + return executeLegacy(cmd); + } + + // 上锁 + String key = "UBWithdraw:" + cmd.requiredReqUserId(); + ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60)); + try { + return executeLocked(cmd); + } finally { + // 任意余额、持久化或统一审核分支失败都必须释放用户锁,不能让异常路径等待 TTL 才恢复提现. + redisService.unlock(key); + } + } + + private UserBankWithdrawResultCO executeLegacy(UserBankWithdrawCmd cmd) { + checkHostSalaryWithdrawEnabled(cmd); + String key = "UBWithdraw:" + cmd.requiredReqUserId(); + ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60)); + try { + SalaryType salaryType = SalaryType.of(cmd.getSalaryType()); + ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType); + BigDecimal balance = getUserBalance(cmd, salaryType); + ResponseAssert.isTrue(WalletErrorCode.INSUFFICIENT_BALANCE, + ArithmeticUtils.gte(balance, cmd.getAmount())); + + UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId()); + ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard); + Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId()); + validateServiceCharge(serviceCharge); + BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge); + ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, + ArithmeticUtils.gtZero(actualAmount)); + + Long applyId = IdWorkerUtils.getId(); + Timestamp now = TimestampUtils.now(); + UserBankWithdrawMoneyApply application = new UserBankWithdrawMoneyApply() + .setId(applyId) + .setSysOrigin(cmd.requireReqSysOrigin()) + .setAcceptMethod(WithdrawMethod.ONESELF.name()) + .setSubmitUserId(cmd.requiredReqUserId()) + .setBankCardId(bankCard.getId()) + .setBankCardType(bankCard.getCardType()) + .setWithdrawAddress(bankCard.getCardNo()) + .setAmount(cmd.getAmount()) + .setBalanceBefore(balance) + .setAmountType(salaryType.getCode()) + .setApprovalProcesses(List.of( + new UserBankWithdrawMoneyApprovalProcess() + .setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) + .setRemark("Application submitted") + .setCreateTime(now))) + .setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) + .setServiceCharge(serviceCharge) + .setActualAmount(actualAmount) + .setCreateTime(now) + .setUpdateTime(now) + .setCreateUser(cmd.requiredReqUserId()) + .setUpdateUser(cmd.requiredReqUserId()) + .setCreateUserOrigin(OpUserType.APP.getType()) + .setUpdateUserOrigin(OpUserType.APP.getType()); + + BigDecimal afterBalance = changeBalance(application); + ensureHostWithdrawalRunningWater(application, afterBalance); + // 切流前仍直接生成旧 Console 可见的 SUBMIT 单,不写任何 externalReviewStatus. + userBankWithdrawMoneyApplyService.add(application + .setBalanceBefore(afterBalance.add(application.getAmount()))); + walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId()); + return withdrawalResult(afterBalance, cmd.getAmount()); + } finally { + // 原实现只在成功末尾解锁;这里补齐失败分支,避免滚动前旧流程异常后锁满 60 秒. + redisService.unlock(key); + } + } + + private UserBankWithdrawResultCO executeLocked(UserBankWithdrawCmd cmd) { + + UserBankWithdrawMoneyApply preparing = + userBankWithdrawMoneyApplyService.getLatestPreparingByUser(cmd.requiredReqUserId()); + if (preparing != null) { + // 成功响应丢失或进程中断后的客户端重试续跑同一申请,绝不能重新生成 applyId 再扣一次. + try { + return completePreparedApplication(preparing); + } catch (RuntimeException exception) { + if (handlePreparationFailure(preparing, exception)) { + throw exception; + } + // durable intent 仍可恢复时对 H5 表达“已受理”;失败响应会与 30 秒后自动扣款的事实冲突. + return acceptedWithdrawalResult(preparing); + } + } + + // 月度标记在准备单转 SUBMIT 前已经写入;响应丢失后的再次请求必须在后端阻断,而不能只依赖 H5 按钮状态. + ResponseAssert.isFalse(CommonErrorCode.REQUEST_LIMITING, + walletBankCacheService.checkMonthWithdrawal(cmd.requiredReqUserId())); + + checkHostSalaryWithdrawEnabled(cmd); + + SalaryType salaryType = SalaryType.of(cmd.getSalaryType()); + ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType); + + BigDecimal balance = getUserBalance(cmd, salaryType); // 3. 校验提现金额 ResponseAssert.isTrue(WalletErrorCode.INSUFFICIENT_BALANCE, ArithmeticUtils.gte(balance, cmd.getAmount())); - UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId()); - ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard); - - Long applyId = IdWorkerUtils.getId(); - - // 扣钱 - BigDecimal afterBalance = changeBalance(cmd, salaryType, applyId); - - // 手续费 - Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId()); - BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge); - - - if (SalaryType.HOST_SALARY == salaryType) { - // 银行卡流水 - userBankRunningWaterService.add( - new UserBankRunningWater() - .setId(IdWorkerUtils.getId()) - .setUserId(cmd.requiredReqUserId()) - .setSysOrigin(cmd.requireReqSysOrigin()) - .setType(ReceiptType.EXPENDITURE.getType()) - .setAmount(cmd.getAmount()) - .setServiceCharge(serviceCharge) - .setActualAmount(actualAmount) - .setEvent(UserBankWaterEvent.WITHDRAW.name()) - .setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe()) - .setRemark("Cash withdrawal") - .setTrackId(applyId.toString()) - .setBalance(afterBalance) - .setCreateTime(TimestampUtils.now()) - .setUpdateTime(TimestampUtils.now()) - .setCreateUser(cmd.requiredReqUserId()) - .setCreateUserOrigin(OpUserType.APP.getType()) - ); - } - - // 申请详情 - userBankWithdrawMoneyApplyService.add( - new UserBankWithdrawMoneyApply() - .setId(applyId) + UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId()); + ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard); + + // 所有可能失败的区域/手续费读取都在扣款前完成,准备单一旦持久化便成为后续恢复的唯一业务事实. + Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId()); + validateServiceCharge(serviceCharge); + BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge); + ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, + ArithmeticUtils.gtZero(actualAmount)); + Long applyId = IdWorkerUtils.getId(); + Timestamp now = TimestampUtils.now(); + + // PREPARING 先于资金动作落库;进程即使在任意后续步骤退出,补偿任务也能按同一 applyId 幂等续跑. + UserBankWithdrawMoneyApply withdrawalApply = + new UserBankWithdrawMoneyApply() + .setId(applyId) .setSysOrigin(cmd.requireReqSysOrigin()) .setAcceptMethod(WithdrawMethod.ONESELF.name()) .setSubmitUserId(cmd.requiredReqUserId()) .setBankCardId(bankCard.getId()) - .setBankCardType(bankCard.getCardType()) - .setAmount(cmd.getAmount()) - .setAmountType(salaryType.getCode()) - .setApprovalProcesses(List.of( - new UserBankWithdrawMoneyApprovalProcess() - .setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) - .setRemark("Application submitted") - .setCreateTime(TimestampUtils.now()) - )) - .setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) - .setServiceCharge(serviceCharge) - .setActualAmount(actualAmount) - .setCreateTime(TimestampUtils.now()) - .setUpdateTime(TimestampUtils.now()) + .setBankCardType(bankCard.getCardType()) + // 收款地址是申请时资金事实;用户之后修改或删除银行卡不能改变已进入审核的目标地址。 + .setWithdrawAddress(bankCard.getCardNo()) + .setAmount(cmd.getAmount()) + .setBalanceBefore(balance) + .setAmountType(salaryType.getCode()) + .setApprovalProcesses(List.of( + new UserBankWithdrawMoneyApprovalProcess() + .setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) + .setRemark("Application submitted") + .setCreateTime(now) + )) + .setLatestApprovalStatus("PREPARING") + .setServiceCharge(serviceCharge) + .setActualAmount(actualAmount) + .setCreateTime(now) + .setUpdateTime(now) .setCreateUser(cmd.requiredReqUserId()) .setUpdateUser(cmd.requiredReqUserId()) - .setCreateUserOrigin(OpUserType.APP.getType()) - .setUpdateUserOrigin(OpUserType.APP.getType()) - ); - - walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId()); - redisService.unlock(key); - return new UserBankWithdrawResultCO() - .setBankBalance(afterBalance) - .setWithdrawAmount(Objects.toString(cmd.getAmount())); - } + .setCreateUserOrigin(OpUserType.APP.getType()) + .setUpdateUserOrigin(OpUserType.APP.getType()); + if (withdrawalReviewSubmissionTask.shouldRouteNewApplication()) { + // 只有切源开关开启后的新申请才归统一双审;预发布阶段和历史单仍由旧 Console 处理,避免两套入口同时有权审核。 + withdrawalApply.setExternalReviewStatus("PENDING") + .setExternalReviewUpdatedTime(TimestampUtils.now()); + } + userBankWithdrawMoneyApplyService.add(withdrawalApply); + + try { + return completePreparedApplication(withdrawalApply); + } catch (RuntimeException exception) { + if (handlePreparationFailure(withdrawalApply, exception)) { + // 只有确认无扣款事实且准备单已终止,才能向客户端返回明确失败并允许下一笔申请. + throw exception; + } + return acceptedWithdrawalResult(withdrawalApply); + } + } + + private UserBankWithdrawResultCO completePreparedApplication( + UserBankWithdrawMoneyApply application) { + BigDecimal afterBalance = changeBalance(application); + ensureHostWithdrawalRunningWater(application, afterBalance); + // 月度限制必须晚于可靠扣款事实;余额不足等确定失败不能先把用户锁死一个月. + walletBankCacheService.setMonthWithdrawal(application.getSubmitUserId()); + + // HOST 幂等 marker 与工资流水都会回放首次扣款后的精确余额;由此还原的才是资金动作发生前余额, + // 而不是 PREPARING 落库时可能已被其他账变影响的早期查询值. + BigDecimal balanceBefore = afterBalance.add(application.getAmount()); + boolean ready = userBankWithdrawMoneyApplyService.markReadyForReview( + application.getId(), balanceBefore); + if (!ready) { + UserBankWithdrawMoneyApply latest = + userBankWithdrawMoneyApplyService.getById(application.getId()); + ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, + latest != null && Objects.equals(latest.getLatestApprovalStatus(), + WithdrawMoneyApprovalProcessStatus.SUBMIT.name())); + application = latest; + } else { + application.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name()) + .setBalanceBefore(balanceBefore) + .setUpdateTime(TimestampUtils.now()); + } + + // 首次网络失败只记录 FAILED,之后由统一补投任务重放同一来源申请号. + withdrawalReviewSubmissionTask.submit(application); + return withdrawalResult(afterBalance, application.getAmount()); + } + + private void ensureHostWithdrawalRunningWater(UserBankWithdrawMoneyApply application, + BigDecimal afterBalance) { + if (SalaryType.HOST_SALARY != SalaryType.of(application.getAmountType())) { + return; + } + String trackId = application.getId().toString(); + if (userBankRunningWaterService.existsTrackId(application.getSubmitUserId(), + UserBankWaterEvent.WITHDRAW.name(), trackId)) { + return; + } + // 流水在 PREPARING 内补齐并使用 partial unique 业务索引;崩溃重试不会重复写同一提现事实. + userBankRunningWaterService.add(new UserBankRunningWater() + .setId(IdWorkerUtils.getId()) + .setUserId(application.getSubmitUserId()) + .setSysOrigin(application.getSysOrigin()) + .setType(ReceiptType.EXPENDITURE.getType()) + .setAmount(application.getAmount()) + .setServiceCharge(application.getServiceCharge()) + .setActualAmount(application.getActualAmount()) + .setEvent(UserBankWaterEvent.WITHDRAW.name()) + .setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe()) + .setRemark("Cash withdrawal") + .setTrackId(trackId) + .setBalance(afterBalance) + .setCreateTime(application.getCreateTime()) + .setUpdateTime(TimestampUtils.now()) + .setCreateUser(application.getSubmitUserId()) + .setCreateUserOrigin(OpUserType.APP.getType())); + } + + @Scheduled(fixedDelayString = "${hyapp.withdrawal-review.creation-retry-delay-ms:10000}") + public void retryPreparingApplications() { + Timestamp olderThan = new Timestamp( + System.currentTimeMillis() - PREPARING_RECOVERY_AGE_MS); + for (UserBankWithdrawMoneyApply application : + userBankWithdrawMoneyApplyService.listPreparingForRecovery( + olderThan, PREPARING_RECOVERY_BATCH_SIZE)) { + String lockKey = "UBWithdraw:" + application.getSubmitUserId(); + if (!redisService.lock(lockKey, 60)) { + continue; + } + try { + completePreparedApplication(application); + } catch (Exception exception) { + handlePreparationFailure(application, exception); + // 结果未知时保持 PREPARING 使下一轮继续;不能删除准备单或改用新申请号补扣. + log.error("Recover prepared withdrawal {} failed", application.getId(), exception); + } finally { + redisService.unlock(lockKey); + } + } + } + + private UserBankWithdrawResultCO withdrawalResult(BigDecimal balance, BigDecimal amount) { + return new UserBankWithdrawResultCO() + .setBankBalance(balance) + .setWithdrawAmount(Objects.toString(amount)); + } + + private UserBankWithdrawResultCO acceptedWithdrawalResult( + UserBankWithdrawMoneyApply application) { + // H5 只消费 2xx 并展示“申请已提交”;这里保留原响应结构。初始快照仅用于兼容旧客户端展示, + // 真正财务 balanceBefore 会在幂等扣款完成后由首次 post-balance 精确覆盖. + BigDecimal expectedBalance = application.getBalanceBefore() == null ? null + : application.getBalanceBefore().subtract(application.getAmount()); + return withdrawalResult(expectedBalance, application.getAmount()); + } + + private boolean handlePreparationFailure(UserBankWithdrawMoneyApply application, + Exception cause) { + try { + if (hasDebitFact(application)) { + // 已存在扣款事实时只能继续补流水/投递,任何异常都不得把申请终止后允许用户再创建一笔. + return false; + } + SalaryType salaryType = SalaryType.of(application.getAmountType()); + BigDecimal currentBalance = SalaryType.HOST_SALARY == salaryType + ? userBankBalanceService.getBalance(application.getSubmitUserId()).getAccurateBalance() + : salaryAccountGateway.getAvailableBalance(application.getSubmitUserId(), salaryType) + .getDollarAmount(); + if (currentBalance.compareTo(application.getAmount()) >= 0) { + // 余额仍足够时更可能是数据库、Redis 或网络暂态;保留 PREPARING 由同申请号恢复. + return false; + } + if (userBankWithdrawMoneyApplyService.markPreparationFailed( + application.getId(), cause.getMessage())) { + walletBankCacheService.removeMonthWithdrawal(application.getSubmitUserId()); + return true; + } + UserBankWithdrawMoneyApply latest = + userBankWithdrawMoneyApplyService.getById(application.getId()); + return latest != null && "CREATE_FAILED".equals(latest.getLatestApprovalStatus()); + } catch (Exception classificationError) { + // 无法可靠证明“未扣款且余额不足”时宁可保持 PREPARING,不能开放第二笔申请造成双扣风险. + log.error("Classify prepared withdrawal {} failure failed; keep it recoverable", + application.getId(), classificationError); + return false; + } + } + + private boolean hasDebitFact(UserBankWithdrawMoneyApply application) { + String trackId = application.getId().toString(); + SalaryType salaryType = SalaryType.of(application.getAmountType()); + return SalaryType.HOST_SALARY == salaryType + ? userBankBalanceService.hasWithdrawalDebit(application.getSubmitUserId(), trackId) + : salaryAccountGateway.hasReceipt(trackId); + } + + private void normalizeAndValidateAmount(UserBankWithdrawCmd cmd) { + BigDecimal normalized = null; + try { + normalized = cmd.getAmount().setScale(2, RoundingMode.UNNECESSARY); + normalized.movePointRight(2).longValueExact(); + } catch (RuntimeException ignored) { + // 统一落到标准参数错误;不能让 PennyAmount 四舍五入扣款后,Admin 又因原始高精度金额无法转 minor unit 而卡单. + } + boolean valid = normalized != null && ArithmeticUtils.gtZero(normalized) + && normalized.precision() - normalized.scale() <= 16; + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, valid); + cmd.setAmount(normalized); + } private void checkHostSalaryWithdrawEnabled(UserBankWithdrawCmd cmd) { if (SalaryType.HOST_SALARY != SalaryType.of(cmd.getSalaryType())) { @@ -163,27 +406,31 @@ public class UserBankWithdrawCmdExe { hostSalaryWithdrawEnabledQryExe.check(cmd.requiredReqUserId()); } - private BigDecimal changeBalance(UserBankWithdrawCmd cmd, SalaryType salaryType, Long applyId) { - // 余额 - BigDecimal balance; - - if (SalaryType.HOST_SALARY == salaryType) { - BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.decr(cmd.requiredReqUserId(), PennyAmount.ofDollar(cmd.getAmount())); - ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO); - balance = decrUserBankBalanceDTO.getAccurateBalance(); - } else { - SalaryReceipt receipt = new SalaryReceipt() - .setReceiptType(ReceiptType.EXPENDITURE) - .setUserId(cmd.getReqUserId()) - .setAcceptUserId(cmd.getReqUserId()) - .setSysOrigin(cmd.getReqSysOrigin().getOrigin()) - .setSalaryType(salaryType) - .setSalaryEvent(SalaryEvent.WITHDRAW) - .setAmount(PennyAmount.ofDollar(cmd.getAmount())) - .setTrackId(String.valueOf(applyId)) - .setEventDesc("提现") - .setOpUserType(OpUserType.APP) - .setOpUserId(cmd.getReqUserId()); + private BigDecimal changeBalance(UserBankWithdrawMoneyApply application) { + // 余额 + BigDecimal balance; + SalaryType salaryType = SalaryType.of(application.getAmountType()); + ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType); + + if (SalaryType.HOST_SALARY == salaryType) { + BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.withdrawOnce( + application.getSubmitUserId(), PennyAmount.ofDollar(application.getAmount()), + application.getId().toString()); + ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO); + balance = decrUserBankBalanceDTO.getAccurateBalance(); + } else { + SalaryReceipt receipt = new SalaryReceipt() + .setReceiptType(ReceiptType.EXPENDITURE) + .setUserId(application.getSubmitUserId()) + .setAcceptUserId(application.getSubmitUserId()) + .setSysOrigin(application.getSysOrigin()) + .setSalaryType(salaryType) + .setSalaryEvent(SalaryEvent.WITHDRAW) + .setAmount(PennyAmount.ofDollar(application.getAmount())) + .setTrackId(String.valueOf(application.getId())) + .setEventDesc("提现") + .setOpUserType(OpUserType.APP) + .setOpUserId(application.getSubmitUserId()); SalaryReceiptRes salaryReceiptRes = salaryAccountGateway.changeBalance(receipt); ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, salaryReceiptRes); @@ -202,11 +449,17 @@ public class UserBankWithdrawCmdExe { return balance; } - private BigDecimal getActualAmount(BigDecimal amount, Long serviceCharge) { + private BigDecimal getActualAmount(BigDecimal amount, Long serviceCharge) { BigDecimal charge = new BigDecimal(serviceCharge).divide(new BigDecimal(100)); BigDecimal serviceChargeAmount = amount.multiply(charge).setScale(0, RoundingMode.CEILING); return amount.subtract(serviceChargeAmount); - } + } + + private void validateServiceCharge(Long serviceCharge) { + // 配置值语义是百分数;超过 100% 会产生零或负打款额,必须在落申请和扣款前阻断. + ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, + serviceCharge != null && serviceCharge >= 0 && serviceCharge <= 100); + } private Long getAssistDiamondConfig(Long userId) { diff --git a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/convertor/SalaryAccountConvertor.java b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/convertor/SalaryAccountConvertor.java index 8eb27c89..e1938573 100644 --- a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/convertor/SalaryAccountConvertor.java +++ b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/convertor/SalaryAccountConvertor.java @@ -55,7 +55,8 @@ public class SalaryAccountConvertor { .setSalaryType(SalaryType.of(cmd.getSalaryType())) .setSalaryEvent(SalaryEvent.WITHDRAW_REFUND) .setAmount(PennyAmount.ofDollar(cmd.getAmount())) - .setTrackId(IdWorkerUtils.getIdStr()) + // 退款流水使用原提现申请号;Admin 重试同一终态时不能生成第二个资金业务号。 + .setTrackId(cmd.getTrackId()) .setEventDesc("提现退回") .setRemark(cmd.getRemark()) .setOpUserType(OpUserType.APP) diff --git a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/integration/HyAppWithdrawalReviewClient.java b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/integration/HyAppWithdrawalReviewClient.java new file mode 100644 index 00000000..856c93e8 --- /dev/null +++ b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/integration/HyAppWithdrawalReviewClient.java @@ -0,0 +1,157 @@ +package com.red.circle.wallet.app.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * ChatApp3 钱包到 hyapp-server 统一运营/财务双审的提交客户端. + * + *

客户端只提交已经在本地扣款并持久化的申请事实;网络失败由同一来源申请 ID 重试,不能在这里再次扣款. + */ +@Component +public class HyAppWithdrawalReviewClient { + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final boolean enabled; + private final String submitUrl; + private final String submitToken; + private final String appCode; + private final String sourceSystem; + private final Duration requestTimeout; + private final long cutoverAtMs; + + public HyAppWithdrawalReviewClient( + ObjectMapper objectMapper, + @Value("${hyapp.withdrawal-review.enabled:false}") boolean enabled, + @Value("${hyapp.withdrawal-review.submit-url:}") String submitUrl, + @Value("${hyapp.withdrawal-review.submit-token:}") String submitToken, + @Value("${hyapp.withdrawal-review.app-code:yumi}") String appCode, + @Value("${hyapp.withdrawal-review.source-system:chatapp3}") String sourceSystem, + @Value("${hyapp.withdrawal-review.cutover-at-ms:0}") long cutoverAtMs, + @Value("${hyapp.withdrawal-review.request-timeout-ms:5000}") long requestTimeoutMs) { + this.objectMapper = objectMapper; + this.enabled = enabled; + this.submitUrl = submitUrl.trim(); + this.submitToken = submitToken.trim(); + this.appCode = appCode.trim().toLowerCase(); + this.sourceSystem = sourceSystem.trim().toLowerCase(); + this.cutoverAtMs = Math.max(0, cutoverAtMs); + this.requestTimeout = Duration.ofMillis(Math.max(1000, requestTimeoutMs)); + validateConfiguration(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(this.requestTimeout) + .build(); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean shouldRouteNewApplication() { + // 双节点先携带 enabled 配置滚动完成,再到统一 UTC 毫秒自动切 owner,避免一台走旧 Console、一台走 HyApp. + // cutover=0 明确表示只预部署不切流;禁止仅误开 enabled 就立即启用新资金状态机. + return enabled && cutoverAtMs > 0 && System.currentTimeMillis() >= cutoverAtMs; + } + + public String submit(UserBankWithdrawMoneyApply apply) { + if (!enabled) { + throw new IllegalStateException("hyapp withdrawal review is disabled"); + } + if (submitUrl.isBlank() || submitToken.isBlank()) { + throw new IllegalStateException("hyapp withdrawal review submit config is incomplete"); + } + if (apply == null || apply.getAmount() == null || apply.getCreateTime() == null + || apply.getActualAmount() == null || apply.getServiceCharge() == null + || apply.getWithdrawAddress() == null || apply.getWithdrawAddress().isBlank()) { + throw new IllegalArgumentException("withdrawal review application is incomplete"); + } + + BigDecimal normalizedAmount = apply.getAmount().stripTrailingZeros(); + BigDecimal normalizedActualAmount = apply.getActualAmount().stripTrailingZeros(); + BigDecimal normalizedServiceCharge = normalizedAmount.subtract(normalizedActualAmount) + .stripTrailingZeros(); + // Admin 同时校验 decimal 和 minor-unit 两种表示;longValueExact 防止超精度或溢出金额被静默截断。 + long amountMinor = normalizedAmount.movePointRight(2).longValueExact(); + long actualAmountMinor = normalizedActualAmount.movePointRight(2).longValueExact(); + Map payload = new LinkedHashMap<>(); + payload.put("appCode", appCode); + payload.put("sourceSystem", sourceSystem); + payload.put("sourceApplicationId", String.valueOf(apply.getId())); + payload.put("userId", String.valueOf(apply.getSubmitUserId())); + payload.put("salaryAssetType", apply.getAmountType()); + payload.put("withdrawAmount", normalizedAmount.toPlainString()); + payload.put("withdrawAmountMinor", amountMinor); + // ChatApp3 历史字段 serviceCharge 实际保存“百分比整数”;跨系统合同分别发送费率、手续费金额和净打款额, + // 财务只能按申请时不可变净额打款,不能把费率误当金额或在审核时用当前配置重算。 + payload.put("serviceChargeRatio", String.valueOf(apply.getServiceCharge())); + payload.put("serviceCharge", normalizedServiceCharge.toPlainString()); + payload.put("actualAmount", normalizedActualAmount.toPlainString()); + payload.put("actualAmountMinor", actualAmountMinor); + if (apply.getBalanceBefore() != null) { + // 审核列表必须展示申请时扣款前快照;补偿重试不得重新查询已经变化的实时余额。 + payload.put("balanceBefore", apply.getBalanceBefore().stripTrailingZeros().toPlainString()); + } + payload.put("withdrawMethod", apply.getBankCardType()); + payload.put("withdrawAddress", apply.getWithdrawAddress()); + payload.put("createdAtMs", apply.getCreateTime().getTime()); + + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(submitUrl)) + .timeout(requestTimeout) + .header("Authorization", "Bearer " + submitToken) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(objectMapper.writeValueAsBytes(payload))) + .build(); + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException( + "hyapp withdrawal review returned " + response.statusCode() + ": " + response.body()); + } + JsonNode data = objectMapper.readTree(response.body()).path("data"); + String reviewApplicationId = data.path("id").asText("").trim(); + if (reviewApplicationId.isBlank()) { + throw new IllegalStateException("hyapp withdrawal review response id is empty"); + } + return reviewApplicationId; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("hyapp withdrawal review request interrupted", exception); + } catch (Exception exception) { + throw new IllegalStateException("submit hyapp withdrawal review failed", exception); + } + } + + private void validateConfiguration() { + if (!enabled) { + return; + } + if (submitUrl.isBlank() || submitToken.isBlank() || appCode.isBlank() + || sourceSystem.isBlank() || cutoverAtMs <= 0) { + // 资金 owner 切换配置缺失时让 Wallet 启动失败,不能等到切流后才把已扣款申请隐藏在 FAILED 队列. + throw new IllegalStateException("hyapp withdrawal review configuration is incomplete"); + } + try { + URI uri = URI.create(submitUrl); + boolean supportedScheme = "http".equalsIgnoreCase(uri.getScheme()) + || "https".equalsIgnoreCase(uri.getScheme()); + if (!supportedScheme || uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("unsupported submit URL"); + } + } catch (RuntimeException exception) { + throw new IllegalStateException("hyapp withdrawal review submit URL is invalid", exception); + } + } +} diff --git a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WithdrawalReviewSubmissionTask.java b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WithdrawalReviewSubmissionTask.java new file mode 100644 index 00000000..410fa31b --- /dev/null +++ b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WithdrawalReviewSubmissionTask.java @@ -0,0 +1,64 @@ +package com.red.circle.wallet.app.scheduler; + +import com.red.circle.wallet.app.integration.HyAppWithdrawalReviewClient; +import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply; +import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankWithdrawMoneyApplyService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 将 ChatApp3 提现申请可靠投递到 hyapp-server 的统一运营/财务双审队列. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WithdrawalReviewSubmissionTask { + + private static final int BATCH_SIZE = 50; + + private final HyAppWithdrawalReviewClient reviewClient; + private final UserBankWithdrawMoneyApplyService withdrawApplyService; + + public boolean shouldRouteNewApplication() { + return reviewClient.shouldRouteNewApplication(); + } + + @Scheduled(fixedDelayString = "${hyapp.withdrawal-review.retry-delay-ms:30000}") + public void retryPendingApplications() { + if (!reviewClient.isEnabled()) { + return; + } + List applications = + withdrawApplyService.listPendingExternalReview(BATCH_SIZE); + for (UserBankWithdrawMoneyApply application : applications) { + submit(application); + } + } + + /** + * 首次提交与补偿共用同一来源申请 ID;失败只写原申请文档,不能回滚或重复用户扣款. + */ + public void submit(UserBankWithdrawMoneyApply application) { + if (!reviewClient.isEnabled() || application == null + || !("PENDING".equals(application.getExternalReviewStatus()) + || "FAILED".equals(application.getExternalReviewStatus()))) { + return; + } + try { + String reviewApplicationId = reviewClient.submit(application); + withdrawApplyService.markExternalReviewSubmitted(application.getId(), reviewApplicationId); + } catch (Exception exception) { + try { + withdrawApplyService.markExternalReviewFailed(application.getId(), exception.getMessage()); + } catch (Exception recordException) { + // 提交失败事实写回也失败时仍不能把异常抛回 H5:原申请保持 PENDING,下一轮查询会继续补投。 + log.error("Record failed hyapp review submission for withdrawal {} failed", + application.getId(), recordException); + } + log.error("Submit withdrawal {} to hyapp review failed", application.getId(), exception); + } + } +} diff --git a/rc-service/rc-service-wallet/wallet-client/src/main/java/com/red/circle/wallet/app/dto/cmd/SalaryWithdrawCmd.java b/rc-service/rc-service-wallet/wallet-client/src/main/java/com/red/circle/wallet/app/dto/cmd/SalaryWithdrawCmd.java index b4c5b597..49061a8b 100644 --- a/rc-service/rc-service-wallet/wallet-client/src/main/java/com/red/circle/wallet/app/dto/cmd/SalaryWithdrawCmd.java +++ b/rc-service/rc-service-wallet/wallet-client/src/main/java/com/red/circle/wallet/app/dto/cmd/SalaryWithdrawCmd.java @@ -13,9 +13,14 @@ import lombok.EqualsAndHashCode; */ @Data @EqualsAndHashCode(callSuper = false) -public class SalaryWithdrawCmd extends AppExtCommand { - - /** +public class SalaryWithdrawCmd extends AppExtCommand { + + /** + * 来源业务号;提现驳回退款沿用原申请 ID,确保跨服务重试能识别同一笔资金动作. + */ + private String trackId; + + /** * 工资类型. */ @NotBlank(message = "salaryType required.") diff --git a/rc-service/rc-service-wallet/wallet-domain/src/main/java/com/red/circle/wallet/domain/gateway/SalaryAccountGateway.java b/rc-service/rc-service-wallet/wallet-domain/src/main/java/com/red/circle/wallet/domain/gateway/SalaryAccountGateway.java index 5e6c0b6b..ff024526 100644 --- a/rc-service/rc-service-wallet/wallet-domain/src/main/java/com/red/circle/wallet/domain/gateway/SalaryAccountGateway.java +++ b/rc-service/rc-service-wallet/wallet-domain/src/main/java/com/red/circle/wallet/domain/gateway/SalaryAccountGateway.java @@ -34,5 +34,10 @@ public interface SalaryAccountGateway { * @param receipt 工资凭证 * @return 凭证响应 */ - SalaryReceiptRes changeBalance(SalaryReceipt receipt); -} + SalaryReceiptRes changeBalance(SalaryReceipt receipt); + + /** + * 判断稳定业务号对应的工资资金事实是否已提交;用于崩溃恢复时区分已扣款与确定未扣款. + */ + boolean hasReceipt(String trackId); +} diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankBalance.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankBalance.java index e8070311..5c4a74da 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankBalance.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankBalance.java @@ -4,7 +4,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.io.Serial; import java.io.Serializable; -import java.sql.Timestamp; +import java.sql.Timestamp; +import java.util.Map; import lombok.Data; import lombok.experimental.Accessors; import org.springframework.data.annotation.Id; @@ -51,7 +52,19 @@ public class UserBankBalance implements Serializable { * 余额. */ @JsonSerialize(using = ToStringSerializer.class) - private Long balance; + private Long balance; + + /** + * 提现退款幂等事实,key 为稳定业务号摘要,value 为首次退款后的余额(分). + * 余额增量和该快照由同一个 Mongo 更新管道原子写入,重放既不会重复加钱,也不会用更晚的余额污染退款流水. + */ + private Map processedWithdrawalRefundBalances; + + /** + * 提现扣款幂等事实,key 为申请号摘要,value 为首次扣款后的余额(分). + * 准备单恢复只回读该快照,不能因进程中断重复扣减或把后续入账后的余额写进原提现流水. + */ + private Map processedWithdrawalDebitBalances; /** * 创建时间. diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankWithdrawMoneyApply.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankWithdrawMoneyApply.java index ebb80072..60ff8c15 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankWithdrawMoneyApply.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/entity/bank/UserBankWithdrawMoneyApply.java @@ -48,7 +48,12 @@ public class UserBankWithdrawMoneyApply implements Serializable { /** * 银行卡类型. */ - private String bankCardType; + private String bankCardType; + + /** + * 申请时的收款地址快照;可靠投递只读该字段,不能在重试时回查可变银行卡资料. + */ + private String withdrawAddress; /** * + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod; @@ -64,7 +69,12 @@ public class UserBankWithdrawMoneyApply implements Serializable { /** * 提交金额. */ - private BigDecimal amount; + private BigDecimal amount; + + /** + * 提交提现前的可用余额快照;这是申请时财务事实,不能用审核或重试时的当前余额替代. + */ + private BigDecimal balanceBefore; /** * 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策) @@ -90,7 +100,42 @@ public class UserBankWithdrawMoneyApply implements Serializable { /** * WithdrawMoneyApprovalProcessStatus SUBMIT:提交状态,PASS:审核通过,NOT_PASS:审核不通过; 最新审核状态. */ - private String latestApprovalStatus; + private String latestApprovalStatus; + + /** + * 统一提现双审投递状态:PENDING/FAILED/SUBMITTED. + */ + private String externalReviewStatus; + + /** + * hyapp-server 生成的统一审核申请 ID;来源申请 ID 仍使用当前文档 id 保证提交幂等. + */ + private String externalReviewApplicationId; + + /** + * Admin 终态回调命令号;同一命令允许重试,其他命令不能覆盖既有资金终态. + */ + private String externalReviewDecisionCommandId; + + /** + * 驳回退款状态:PENDING/COMPLETED;与 NOT_PASS 终态分离,退款失败后仍可沿原命令补偿. + */ + private String externalReviewRefundStatus; + + /** + * 最近一次投递错误,仅供排障,不参与提现和退款的业务判断. + */ + private String externalReviewLastError; + + /** + * 创建阶段的终止原因;只有确认资金事实不存在时才能从 PREPARING 转 CREATE_FAILED. + */ + private String preparationError; + + /** + * 最近一次统一审核投递状态更新时间. + */ + private Timestamp externalReviewUpdatedTime; /** * 结算结果; diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankBalanceService.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankBalanceService.java index f474898a..d17c9a18 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankBalanceService.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankBalanceService.java @@ -32,7 +32,13 @@ public interface UserBankBalanceService { * @param amount 金额 * @return 是否操作成功 */ - boolean incr(Long userId, String sysOrigin, PennyAmount amount); + boolean incr(Long userId, String sysOrigin, PennyAmount amount); + + /** + * 按稳定业务号原子退款;已经处理过的 trackId 返回首次退款后的余额快照而不重复入账. + */ + BankBalanceDTO refundWithdrawalOnce(Long userId, String sysOrigin, PennyAmount amount, + String trackId); /** * 类型消费累计. @@ -41,7 +47,17 @@ public interface UserBankBalanceService { * @param amount 金额 * @return 是否操作成功 */ - BankBalanceDTO decr(Long userId, PennyAmount amount); + BankBalanceDTO decr(Long userId, PennyAmount amount); + + /** + * 按提现申请号原子扣款;同一 trackId 重放返回首次扣款后的余额快照,不再重复扣减. + */ + BankBalanceDTO withdrawOnce(Long userId, PennyAmount amount, String trackId); + + /** + * 判断申请号是否已经完成 HOST 余额扣款;只用于区分确定未扣款的业务失败与结果未知的暂态错误. + */ + boolean hasWithdrawalDebit(Long userId, String trackId); /** * 获取余额. diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankWithdrawMoneyApplyService.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankWithdrawMoneyApplyService.java index 220ef8c3..b7ca24a0 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankWithdrawMoneyApplyService.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/UserBankWithdrawMoneyApplyService.java @@ -2,8 +2,10 @@ package com.red.circle.wallet.infra.database.mongo.service.bank; import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply; -import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd; +import java.math.BigDecimal; +import java.sql.Timestamp; import java.util.List; import java.util.Map; import java.util.Set; @@ -40,11 +42,51 @@ public interface UserBankWithdrawMoneyApplyService { /** * 获得记录. */ - UserBankWithdrawMoneyApply getById(Long id); + UserBankWithdrawMoneyApply getById(Long id); + + /** + * 返回用户尚未完成扣款状态机的准备单,客户端重试必须续跑同一申请而不是生成第二次扣款. + */ + UserBankWithdrawMoneyApply getLatestPreparingByUser(Long userId); + + /** + * 有界扫描超时准备单,恢复进程中断在“申请已持久化、资金状态未收口”之间的请求. + */ + List listPreparingForRecovery(Timestamp olderThan, int limit); + + /** + * 扣款、原始流水和月度门禁完成后,原子开放给旧 Console 或 HyApp 审核. + */ + boolean markReadyForReview(Long id, BigDecimal balanceBefore); + + /** + * 在确认没有扣款事实后终止准备单,使用户修正余额后可以重新申请;不能用于结果未知的异常. + */ + boolean markPreparationFailed(Long id, String reason); + + /** + * 固定小批量获取尚未成功进入统一双审中心的申请,避免补偿任务无界加载历史数据. + */ + List listPendingExternalReview(int limit); + + /** + * 记录统一审核申请 ID;仅 SUBMIT 状态允许更新,终态提现不会被补偿任务重新激活. + */ + void markExternalReviewSubmitted(Long id, String reviewApplicationId); + + /** + * 保存可重试失败,不改变本地提现的 SUBMIT/终态资金状态. + */ + void markExternalReviewFailed(Long id, String errorMessage); /** * 审核申请记录. */ - void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply); - -} + boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply); + + /** + * 驳回退款完成后提交持久门禁;相同 commandId 重试返回成功,其他命令不能冒充完成. + */ + boolean markExternalReviewRefundCompleted(Long id, String commandId); + +} diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankBalanceServiceImpl.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankBalanceServiceImpl.java index 5b6cf808..1b1973b1 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankBalanceServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankBalanceServiceImpl.java @@ -1,6 +1,8 @@ package com.red.circle.wallet.infra.database.mongo.service.bank.impl; -import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.FindOneAndUpdateOptions; +import com.mongodb.client.model.ReturnDocument; import com.mongodb.client.result.UpdateResult; import com.red.circle.component.mongodb.config.MongoPrimaryService; import com.red.circle.tool.core.collection.CollectionUtils; @@ -13,7 +15,12 @@ import com.red.circle.wallet.inner.model.dto.BankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.Collection; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; @@ -21,6 +28,7 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.bson.Document; +import org.bson.conversions.Bson; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.FindAndModifyOptions; @@ -47,7 +55,7 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService { private final MongoPrimaryService mongoPrimaryService; @Override - public boolean incr(Long userId, String sysOrigin, PennyAmount amount) { + public boolean incr(Long userId, String sysOrigin, PennyAmount amount) { if (existsPrimary(userId)) { UpdateResult updateResult = mongoTemplate.updateFirst( @@ -73,7 +81,7 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService { } @Override - public BankBalanceDTO decr(Long userId, PennyAmount amount) { + public BankBalanceDTO decr(Long userId, PennyAmount amount) { Query query = Query.query(Criteria.where("id").is(userId)); FindAndModifyOptions options = new FindAndModifyOptions(); options.returnNew(true); @@ -85,8 +93,21 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService { .set("updateTime", TimestampUtils.now()) , options , UserBankBalance.class); - return Objects.isNull(bankBalance) ? null : BankBalanceDTO.of(bankBalance.getBalance()); - } + return Objects.isNull(bankBalance) ? null : BankBalanceDTO.of(bankBalance.getBalance()); + } + + @Override + public BankBalanceDTO withdrawOnce(Long userId, PennyAmount amount, String trackId) { + return mutateWithdrawalBalanceOnce(userId, amount.getPennyAmount(), trackId, + false, "processedWithdrawalDebitBalances", "consumptionPoints"); + } + + @Override + public boolean hasWithdrawalDebit(Long userId, String trackId) { + String markerField = mutationMarkerField("processedWithdrawalDebitBalances", trackId); + return mongoTemplate.exists(Query.query(Criteria.where("id").is(userId) + .and(markerField).exists(true)), UserBankBalance.class); + } @Override public BankBalanceDTO getBalance(Long userId) { @@ -96,6 +117,71 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService { : getLongValue(documents.get(0), "balance"))); } + @Override + public BankBalanceDTO refundWithdrawalOnce(Long userId, String sysOrigin, PennyAmount amount, + String trackId) { + return mutateWithdrawalBalanceOnce(userId, amount.getPennyAmount(), trackId, + true, "processedWithdrawalRefundBalances", "earnPoints"); + } + + private BankBalanceDTO mutateWithdrawalBalanceOnce(Long userId, long amount, String trackId, + boolean income, String markerMap, String cumulativeField) { + String markerKey = mutationMarkerKey(trackId); + String markerField = markerMap + "." + markerKey; + long delta = income ? amount : -amount; + + Document filter = new Document("_id", userId) + .append(markerField, new Document("$exists", false)); + if (!income) { + // 条件扣款与幂等快照在同一个原子 findOneAndUpdate 中完成,不能先查余额再扣款. + filter.append("balance", new Document("$gte", amount)); + } + + Document newBalance = new Document("$add", List.of("$balance", delta)); + Document newCumulative = new Document("$add", List.of( + new Document("$ifNull", List.of("$" + cumulativeField, 0L)), amount)); + Document setFields = new Document("balance", newBalance) + .append(cumulativeField, newCumulative) + // 同一 $set 阶段都读取更新前的 balance,因此 marker 精确等于本次资金动作后的余额. + .append(markerField, newBalance) + // 这里直接调用 Mongo Driver,使用 BSON 原生 Date,避免绕过 MongoTemplate 转换器后无法编码 java.sql.Timestamp. + .append("updateTime", new Date(TimestampUtils.now().getTime())); + List pipeline = List.of(new Document("$set", setFields)); + Document updated = mongoTemplate.getCollection( + mongoTemplate.getCollectionName(UserBankBalance.class)) + .findOneAndUpdate(filter, pipeline, + new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); + if (updated != null) { + return BankBalanceDTO.of(getLongValue(updated, "balance")); + } + + // 未更新时只有“同业务号已执行”才算成功;回读原子保存的 post-balance,而不是账户当前余额. + UserBankBalance existing = mongoTemplate.findOne(Query.query(Criteria.where("id").is(userId) + .and(markerField).exists(true)), + UserBankBalance.class); + Map balances = existing == null ? null + : (income ? existing.getProcessedWithdrawalRefundBalances() + : existing.getProcessedWithdrawalDebitBalances()); + Long originalPostBalance = balances == null ? null : balances.get(markerKey); + return originalPostBalance == null ? null : BankBalanceDTO.of(originalPostBalance); + } + + private String mutationMarkerField(String markerMap, String trackId) { + return markerMap + "." + mutationMarkerKey(trackId); + } + + private String mutationMarkerKey(String trackId) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(trackId.getBytes(StandardCharsets.UTF_8)); + // URL-safe Base64 不含 Mongo 路径分隔符 '.' 或保留前缀 '$',可安全作为嵌套 map key. + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + // SHA-256 是 JRE 必备算法;若运行时缺失必须停止资金动作,不能退化为可能冲突的 hashCode. + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + static Long getLongValue(Document document, String key) { Object value = document.get(key); if (Objects.isNull(value)) { diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankWithdrawMoneyApplyServiceImpl.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankWithdrawMoneyApplyServiceImpl.java index c36b4fe7..7da2b401 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankWithdrawMoneyApplyServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/mongo/service/bank/impl/UserBankWithdrawMoneyApplyServiceImpl.java @@ -1,13 +1,16 @@ -package com.red.circle.wallet.infra.database.mongo.service.bank.impl; - +package com.red.circle.wallet.infra.database.mongo.service.bank.impl; + +import com.mongodb.client.result.UpdateResult; import com.red.circle.tool.core.collection.CollectionUtils; import com.red.circle.tool.core.date.TimestampUtils; import com.red.circle.tool.core.date.ZonedDateTimeUtils; import com.red.circle.tool.core.text.StringUtils; import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply; import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankWithdrawMoneyApplyService; -import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd; +import java.math.BigDecimal; +import java.sql.Timestamp; import java.util.List; import java.util.Map; import java.util.Objects; @@ -97,9 +100,18 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo criteria.and("amountType").ne("SALARY_DIAMOND"); } - if (StringUtils.isNotBlank(query.getLatestApprovalStatus())) { - criteria.and("latestApprovalStatus").is(query.getLatestApprovalStatus()); - } + if (StringUtils.isNotBlank(query.getLatestApprovalStatus())) { + criteria.and("latestApprovalStatus").is(query.getLatestApprovalStatus()); + } + + if (Boolean.TRUE.equals(query.getLegacyReviewOnly())) { + // $nin 会保留历史缺字段申请,同时排除本版本明确交给 HyApp 的全部非终态投递状态. + criteria.and("externalReviewStatus").nin("PENDING", "FAILED", "SUBMITTED"); + if (StringUtils.isBlank(query.getLatestApprovalStatus())) { + // PREPARING 是资金状态机内部态,扣款和流水未收口前绝不能泄露给人工审核. + criteria.and("latestApprovalStatus").in("SUBMIT", "PASS", "NOT_PASS"); + } + } if (Objects.nonNull(query.getStartTime()) && Objects.nonNull(query.getEndTime())) { criteria.andOperator( @@ -115,25 +127,141 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo } @Override - public UserBankWithdrawMoneyApply getById(Long id) { + public UserBankWithdrawMoneyApply getById(Long id) { return mongoTemplate.findOne(Query.query(Criteria.where("id").is(id)), UserBankWithdrawMoneyApply.class); - } - - @Override - public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { - - mongoTemplate.updateFirst(Query.query(Criteria.where("id").in(apply.getId())), - new Update() - .set("updateTime", TimestampUtils.now()) - .set("updateUser", apply.getUpdateUser()) - .set("updateUserOrigin", apply.getUpdateUserOrigin()) - .set("latestApprovalStatus", apply.getLatestApprovalStatus()) - .set("settlementResult", apply.getSettlementResult()) - .push("approvalProcesses", apply.getProcess()), - UserBankWithdrawMoneyApply.class); - - } + } + + @Override + public List listPendingExternalReview(int limit) { + // 只扫描仍在等待审核且未成功投递的申请,并限制单批上限;按最近尝试时间轮转,避免一个永久坏单占住批次饿死新申请。 + Criteria criteria = Criteria.where("latestApprovalStatus").is("SUBMIT") + // 只补投本版本明确写入 outbox 状态的新申请;历史缺字段 SUBMIT 单继续留在旧 Console,不能在开关开启时批量迁移。 + .and("externalReviewStatus").in("PENDING", "FAILED"); + return mongoTemplate.find(Query.query(criteria) + .with(Sort.by(Sort.Order.asc("externalReviewUpdatedTime"), + Sort.Order.asc("createTime"))) + .limit(Math.max(1, Math.min(limit, 100))), + UserBankWithdrawMoneyApply.class); + } + + @Override + public UserBankWithdrawMoneyApply getLatestPreparingByUser(Long userId) { + return mongoTemplate.findOne(Query.query(Criteria.where("submitUserId").is(userId) + .and("latestApprovalStatus").is("PREPARING")) + .with(Sort.by(Sort.Order.desc("createTime"))), UserBankWithdrawMoneyApply.class); + } + + @Override + public List listPreparingForRecovery(Timestamp olderThan, int limit) { + return mongoTemplate.find(Query.query(Criteria.where("latestApprovalStatus").is("PREPARING") + .and("createTime").lte(olderThan)) + .with(Sort.by(Sort.Order.asc("createTime"))) + .limit(Math.max(1, Math.min(limit, 100))), UserBankWithdrawMoneyApply.class); + } + + @Override + public boolean markReadyForReview(Long id, BigDecimal balanceBefore) { + UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(id) + .and("latestApprovalStatus").is("PREPARING")), + new Update() + .set("latestApprovalStatus", "SUBMIT") + .set("balanceBefore", balanceBefore) + .set("updateTime", TimestampUtils.now()), + UserBankWithdrawMoneyApply.class); + return result.getModifiedCount() == 1; + } + + @Override + public boolean markPreparationFailed(Long id, String reason) { + String normalizedReason = StringUtils.isBlank(reason) ? "withdrawal debit rejected" : reason; + if (normalizedReason.length() > 500) { + normalizedReason = normalizedReason.substring(0, 500); + } + UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(id) + .and("latestApprovalStatus").is("PREPARING")), + new Update() + .set("latestApprovalStatus", "CREATE_FAILED") + .set("preparationError", normalizedReason) + .set("updateTime", TimestampUtils.now()), + UserBankWithdrawMoneyApply.class); + return result.getModifiedCount() == 1; + } + + @Override + public void markExternalReviewSubmitted(Long id, String reviewApplicationId) { + mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(id) + .and("latestApprovalStatus").is("SUBMIT")), + new Update() + .set("externalReviewStatus", "SUBMITTED") + .set("externalReviewApplicationId", reviewApplicationId) + .set("externalReviewLastError", "") + .set("externalReviewUpdatedTime", TimestampUtils.now()), + UserBankWithdrawMoneyApply.class); + } + + @Override + public void markExternalReviewFailed(Long id, String errorMessage) { + String normalizedError = StringUtils.isBlank(errorMessage) ? "unknown error" : errorMessage; + if (normalizedError.length() > 500) { + normalizedError = normalizedError.substring(0, 500); + } + mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(id) + .and("latestApprovalStatus").is("SUBMIT") + .and("externalReviewStatus").ne("SUBMITTED")), + new Update() + .set("externalReviewStatus", "FAILED") + .set("externalReviewLastError", normalizedError) + .set("externalReviewUpdatedTime", TimestampUtils.now()), + UserBankWithdrawMoneyApply.class); + } + + @Override + public boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { + + // 终态更新以 SUBMIT 为原子前置条件;重复回调不能再次进入驳回退款逻辑,也不能用相反终态覆盖已结算结果。 + Update update = new Update() + .set("updateTime", TimestampUtils.now()) + .set("updateUser", apply.getUpdateUser()) + .set("updateUserOrigin", apply.getUpdateUserOrigin()) + .set("latestApprovalStatus", apply.getLatestApprovalStatus()) + .set("settlementResult", apply.getSettlementResult()) + .push("approvalProcesses", apply.getProcess()); + if (StringUtils.isNotBlank(apply.getExternalReviewCommandId())) { + // 外部终态与退款完成分离:NOT_PASS 先落 PENDING,退款失败后 Admin 仍可用同一 commandId 补偿。 + update.set("externalReviewDecisionCommandId", apply.getExternalReviewCommandId()) + .set("externalReviewRefundStatus", + "NOT_PASS".equals(apply.getLatestApprovalStatus()) ? "PENDING" : "NOT_REQUIRED"); + } + UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(apply.getId()) + .and("latestApprovalStatus").is("SUBMIT")), + update, + UserBankWithdrawMoneyApply.class); + return result.getModifiedCount() == 1; + } + + @Override + public boolean markExternalReviewRefundCompleted(Long id, String commandId) { + if (id == null || StringUtils.isBlank(commandId)) { + return false; + } + Criteria identity = Criteria.where("id").is(id) + .and("latestApprovalStatus").is("NOT_PASS") + .and("externalReviewDecisionCommandId").is(commandId); + UpdateResult result = mongoTemplate.updateFirst(Query.query(identity) + .addCriteria(Criteria.where("externalReviewRefundStatus").ne("COMPLETED")), + new Update() + .set("externalReviewRefundStatus", "COMPLETED") + .set("updateTime", TimestampUtils.now()), + UserBankWithdrawMoneyApply.class); + if (result.getModifiedCount() == 1) { + return true; + } + // 回调响应丢失后的同命令重试应幂等成功;只有同一申请、同一终态命令已经完成才返回 true。 + return mongoTemplate.exists(Query.query(identity) + .addCriteria(Criteria.where("externalReviewRefundStatus").is("COMPLETED")), + UserBankWithdrawMoneyApply.class); + } } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/gateway/SalaryAccountGatewayImpl.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/gateway/SalaryAccountGatewayImpl.java index 62c75c28..02c19d85 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/gateway/SalaryAccountGatewayImpl.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/gateway/SalaryAccountGatewayImpl.java @@ -22,8 +22,7 @@ import java.math.BigDecimal; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** @@ -49,10 +48,20 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway { @Override @Transactional(rollbackFor = Exception.class) - public SalaryReceiptRes changeBalance(SalaryReceipt receipt) { - if (!ValidatorUtils.validateFastPass(receipt)) { - throw new IllegalArgumentException("changeBalance required param error."); - } + public SalaryReceiptRes changeBalance(SalaryReceipt receipt) { + if (!ValidatorUtils.validateFastPass(receipt)) { + throw new IllegalArgumentException("changeBalance required param error."); + } + + // 统一审核可能在退款已提交、响应或完成标记丢失后重试;先查稳定 trackId,避免再次修改账户余额。 + SalaryAccountRunningWater existingReceipt = receipt.getTrackId() == null + || receipt.getTrackId().isBlank() + ? null + : getRunningWaterByTrackId(receipt.getTrackId()); + if (existingReceipt != null) { + validateIdempotentReceipt(existingReceipt, receipt); + return toExistingReceiptResult(existingReceipt, receipt); + } // 检查账户是否存在,不存在则初始化 SalaryAccountBalance balance = salaryAccountBalanceService.getByUserIdAndType( @@ -91,27 +100,10 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway { // 插入流水记录 Long recordId = IdWorkerUtils.getId(); - try { - SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId, - newBalance, newAvailableBalance); - salaryAccountRunningWaterService.save(runningWater); - } catch (DuplicateKeyException e) { - // 幂等性:trackId重复,查询已有记录 - LambdaQueryWrapper eq = Wrappers.lambdaQuery(SalaryAccountRunningWater.class) - .eq(SalaryAccountRunningWater::getTrackId, receipt.getTrackId()); - SalaryAccountRunningWater existingWater = salaryAccountRunningWaterService.getOne(eq); - if (Objects.nonNull(existingWater)) { - return SalaryReceiptRes.of( - existingWater.getId(), - PennyAmount.ofPenny(existingWater.getBalance() - .multiply(new BigDecimal("100")).longValue()), - PennyAmount.ofPenny(existingWater.getAvailableBalance() - .multiply(new BigDecimal("100")).longValue()), - receipt.getAmount() - ); - } - throw e; - } + SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId, + newBalance, newAvailableBalance); + // track_id 唯一冲突必须向外抛出,让 @Transactional 回滚本次余额增量;调用方随后用同一命令重试并回读赢家流水. + salaryAccountRunningWaterService.save(runningWater); return SalaryReceiptRes.of( recordId, @@ -119,9 +111,50 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway { PennyAmount.ofPenny(newAvailableBalance), receipt.getAmount() ); - } - - private SalaryAccountRunningWater buildRunningWater(SalaryReceipt receipt, Long recordId, + } + + @Override + public boolean hasReceipt(String trackId) { + return trackId != null && !trackId.isBlank() && getRunningWaterByTrackId(trackId) != null; + } + + private SalaryAccountRunningWater getRunningWaterByTrackId(String trackId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery( + SalaryAccountRunningWater.class) + .eq(SalaryAccountRunningWater::getTrackId, trackId); + return salaryAccountRunningWaterService.getOne(query); + } + + private void validateIdempotentReceipt(SalaryAccountRunningWater existing, + SalaryReceipt requested) { + BigDecimal requestedAmount = requested.getAmount().getDollarAmount(); + boolean sameFact = Objects.equals(existing.getUserId(), requested.getUserId()) + && Objects.equals(existing.getSysOrigin(), requested.getSysOrigin()) + && Objects.equals(existing.getType(), requested.getReceiptType().getType()) + && Objects.equals(existing.getSalaryType(), requested.getSalaryType().getCode()) + && Objects.equals(existing.getSalaryEvent(), requested.getSalaryEvent().getCode()) + && existing.getAmount() != null + && existing.getAmount().compareTo(requestedAmount) == 0; + if (!sameFact) { + // 相同幂等键若代表不同用户、资产、事件或金额,继续返回成功会把资金串单隐藏掉. + throw new IllegalStateException( + "salary receipt idempotency key conflicts with an existing financial fact"); + } + } + + private SalaryReceiptRes toExistingReceiptResult(SalaryAccountRunningWater existingWater, + SalaryReceipt receipt) { + return SalaryReceiptRes.of( + existingWater.getId(), + PennyAmount.ofPenny(existingWater.getBalance() + .multiply(new BigDecimal("100")).longValue()), + PennyAmount.ofPenny(existingWater.getAvailableBalance() + .multiply(new BigDecimal("100")).longValue()), + receipt.getAmount() + ); + } + + private SalaryAccountRunningWater buildRunningWater(SalaryReceipt receipt, Long recordId, Long balance, Long availableBalance) { SalaryAccountRunningWater water = new SalaryAccountRunningWater() .setId(recordId) diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/convertor/SalaryAccountInnerConvertor.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/convertor/SalaryAccountInnerConvertor.java index 83b0215b..785680fc 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/convertor/SalaryAccountInnerConvertor.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/convertor/SalaryAccountInnerConvertor.java @@ -51,9 +51,11 @@ public class SalaryAccountInnerConvertor { if (Objects.isNull(innerCmd)) { return null; } - SalaryWithdrawCmd cmd = new SalaryWithdrawCmd(); - cmd.setAmount(innerCmd.getAmount()); - cmd.setRemark(innerCmd.getRemark()); + SalaryWithdrawCmd cmd = new SalaryWithdrawCmd(); + cmd.setAmount(innerCmd.getAmount()); + // 外部审核重试必须沿用来源提现号;不能在转换层丢失退款幂等键。 + cmd.setTrackId(innerCmd.getTrackId()); + cmd.setRemark(innerCmd.getRemark()); cmd.setSalaryType(innerCmd.getSalaryType()); // 设置请求来源信息 diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/bank/UserBankRunningWaterClientEndpoint.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/bank/UserBankRunningWaterClientEndpoint.java index 19cc81c0..607dc476 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/bank/UserBankRunningWaterClientEndpoint.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/bank/UserBankRunningWaterClientEndpoint.java @@ -97,10 +97,21 @@ public class UserBankRunningWaterClientEndpoint implements UserBankRunningWaterC @Override - public ResultResponse approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { - userBankRunningWaterClientService.approvalMoneyApply(apply); - return ResultResponse.success(); - } + public ResultResponse approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { + userBankRunningWaterClientService.approvalMoneyApply(apply); + return ResultResponse.success(); + } + + @Override + public ResultResponse approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply) { + return ResultResponse.success(userBankRunningWaterClientService.approvalMoneyApplyCas(apply)); + } + + @Override + public ResultResponse markExternalReviewRefundCompleted(Long id, String commandId) { + return ResultResponse.success( + userBankRunningWaterClientService.markExternalReviewRefundCompleted(id, commandId)); + } @Override public ResultResponse> getMonthSalaryRunningWater(List userIds, String event, String month) { diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/wallet/UserBankBalanceClientEndpoint.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/wallet/UserBankBalanceClientEndpoint.java index dba89f83..70d0cad5 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/wallet/UserBankBalanceClientEndpoint.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/endpoint/wallet/UserBankBalanceClientEndpoint.java @@ -5,7 +5,8 @@ import com.red.circle.wallet.infra.database.rds.service.WalletGoldBalanceService import com.red.circle.wallet.inner.endpoint.bank.api.UserBankBalanceClientApi; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd; import com.red.circle.wallet.inner.model.dto.BankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO; @@ -38,9 +39,14 @@ public class UserBankBalanceClientEndpoint implements UserBankBalanceClientApi { } @Override - public ResultResponse incr(UserBankBalanceIncrCmd cmd) { - return ResultResponse.success(userBankBalanceClientService.incr(cmd)); - } + public ResultResponse incr(UserBankBalanceIncrCmd cmd) { + return ResultResponse.success(userBankBalanceClientService.incr(cmd)); + } + + @Override + public ResultResponse refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd) { + return ResultResponse.success(userBankBalanceClientService.refundWithdrawalOnce(cmd)); + } @Override public ResultResponse decr(UserBankBalanceDecrCmd cmd) { diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/UserBankRunningWaterClientService.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/UserBankRunningWaterClientService.java index 1aef6da8..6966e410 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/UserBankRunningWaterClientService.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/UserBankRunningWaterClientService.java @@ -73,7 +73,11 @@ public interface UserBankRunningWaterClientService { /** * 审核申请记录. */ - void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply); + void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply); + + boolean approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply); + + boolean markExternalReviewRefundCompleted(Long id, String commandId); /** * 月用户工资流水. diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/impl/UserBankRunningWaterClientServiceImpl.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/impl/UserBankRunningWaterClientServiceImpl.java index 4d912ddd..d581610a 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/impl/UserBankRunningWaterClientServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/bank/impl/UserBankRunningWaterClientServiceImpl.java @@ -93,9 +93,19 @@ public class UserBankRunningWaterClientServiceImpl implements UserBankRunningWat } @Override - public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { - bankWithdrawMoneyApplyService.approvalMoneyApply(apply); - } + public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) { + bankWithdrawMoneyApplyService.approvalMoneyApply(apply); + } + + @Override + public boolean approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply) { + return bankWithdrawMoneyApplyService.approvalMoneyApply(apply); + } + + @Override + public boolean markExternalReviewRefundCompleted(Long id, String commandId) { + return bankWithdrawMoneyApplyService.markExternalReviewRefundCompleted(id, commandId); + } @Override public List getMonthSalaryRunningWater(List userIds, String event, String month) { diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/UserBankBalanceClientService.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/UserBankBalanceClientService.java index 97f9a1cf..850eff5b 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/UserBankBalanceClientService.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/UserBankBalanceClientService.java @@ -2,7 +2,8 @@ package com.red.circle.wallet.inner.service.wallet; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd; import com.red.circle.wallet.inner.model.dto.BankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO; @@ -32,7 +33,9 @@ public interface UserBankBalanceClientService { * * @return 是否操作成功 */ - boolean incr(UserBankBalanceIncrCmd cmd); + boolean incr(UserBankBalanceIncrCmd cmd); + + BankBalanceDTO refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd); /** * 类型消费累计. diff --git a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/impl/UserBankBalanceClientServiceImpl.java b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/impl/UserBankBalanceClientServiceImpl.java index 7db59984..6696f461 100644 --- a/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/impl/UserBankBalanceClientServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-inner-endpoint/src/main/java/com/red/circle/wallet/inner/service/wallet/impl/UserBankBalanceClientServiceImpl.java @@ -3,7 +3,8 @@ package com.red.circle.wallet.inner.service.wallet.impl; import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd; import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd; -import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd; +import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd; import com.red.circle.wallet.inner.model.dto.BankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO; import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO; @@ -34,9 +35,15 @@ public class UserBankBalanceClientServiceImpl implements UserBankBalanceClientSe } @Override - public boolean incr(UserBankBalanceIncrCmd cmd) { - return userBankBalanceService.incr(cmd.getUserId(), cmd.getSysOrigin(), cmd.getAmount()); - } + public boolean incr(UserBankBalanceIncrCmd cmd) { + return userBankBalanceService.incr(cmd.getUserId(), cmd.getSysOrigin(), cmd.getAmount()); + } + + @Override + public BankBalanceDTO refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd) { + return userBankBalanceService.refundWithdrawalOnce(cmd.getUserId(), cmd.getSysOrigin(), + cmd.getAmount(), cmd.getTrackId()); + } @Override public BankBalanceDTO decr(UserBankBalanceDecrCmd cmd) { diff --git a/rc-service/rc-service-wallet/wallet-start/src/main/resources/application.yml b/rc-service/rc-service-wallet/wallet-start/src/main/resources/application.yml index b1fdcd07..46b449c9 100644 --- a/rc-service/rc-service-wallet/wallet-start/src/main/resources/application.yml +++ b/rc-service/rc-service-wallet/wallet-start/src/main/resources/application.yml @@ -26,7 +26,19 @@ management: enabled: false info: enabled: true - health: - show-details: always - - + health: + show-details: always + +# 默认关闭,生产只通过运行环境开启并注入双审地址/令牌,避免本地申请误投真实财务工作台。 +hyapp: + withdrawal-review: + enabled: ${HYAPP_WITHDRAWAL_REVIEW_ENABLED:false} + submit-url: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_URL:} + submit-token: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_TOKEN:} + app-code: ${HYAPP_WITHDRAWAL_REVIEW_APP_CODE:yumi} + source-system: ${HYAPP_WITHDRAWAL_REVIEW_SOURCE_SYSTEM:chatapp3} + # 双节点必须先完成同一版本滚动,再使用未来 UTC epoch ms 同时切换审核 owner;0 仅预部署、不切流。 + cutover-at-ms: ${HYAPP_WITHDRAWAL_REVIEW_CUTOVER_AT_MS:0} + request-timeout-ms: ${HYAPP_WITHDRAWAL_REVIEW_TIMEOUT_MS:5000} + retry-delay-ms: ${HYAPP_WITHDRAWAL_REVIEW_RETRY_DELAY_MS:30000} + creation-retry-delay-ms: ${HYAPP_WITHDRAWAL_CREATION_RETRY_DELAY_MS:10000}