feat(withdrawal): route Yumi reviews through HyApp
This commit is contained in:
parent
69300b45f6
commit
4ebfb3cc6b
@ -4,6 +4,7 @@ import com.red.circle.framework.dto.ResultResponse;
|
|||||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
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.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.BankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||||
@ -40,6 +41,13 @@ public interface UserBankBalanceClientApi {
|
|||||||
@PostMapping("/incr")
|
@PostMapping("/incr")
|
||||||
ResultResponse<Boolean> incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd);
|
ResultResponse<Boolean> incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提现驳回按 trackId 原子退款,供统一审核回调安全重试.
|
||||||
|
*/
|
||||||
|
@PostMapping("/refundWithdrawalOnce")
|
||||||
|
ResultResponse<BankBalanceDTO> refundWithdrawalOnce(
|
||||||
|
@RequestBody @Validated UserBankWithdrawalRefundCmd cmd);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 类型消费累计.
|
* 类型消费累计.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -78,6 +78,18 @@ public interface UserBankRunningWaterClientApi {
|
|||||||
@PostMapping("/approvalMoneyApply")
|
@PostMapping("/approvalMoneyApply")
|
||||||
ResultResponse<Void> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
ResultResponse<Void> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一双审专用 CAS 接口;保留旧接口响应协议,确保 Wallet 先发、Console 后发的滚动发布兼容.
|
||||||
|
*/
|
||||||
|
@PostMapping("/approvalMoneyApplyCas")
|
||||||
|
ResultResponse<Boolean> approvalMoneyApplyCas(
|
||||||
|
@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
|
@PostMapping("/markExternalReviewRefundCompleted")
|
||||||
|
ResultResponse<Boolean> markExternalReviewRefundCompleted(
|
||||||
|
@RequestParam("id") Long id,
|
||||||
|
@RequestParam("commandId") String commandId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月用户工资流水
|
* 月用户工资流水
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -46,4 +46,9 @@ public class ApprovalMoneyApplyCmd extends CommonCommand {
|
|||||||
|
|
||||||
private Long updateUser;
|
private Long updateUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一审核终态命令号;普通旧 Console 审核为空,外部双审回调必须携带并持久化.
|
||||||
|
*/
|
||||||
|
private String externalReviewCommandId;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,5 +48,10 @@ public class UserBankApprovalMoneyApplyCmd extends CommonCommand {
|
|||||||
|
|
||||||
private Integer updateUserOrigin;
|
private Integer updateUserOrigin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一审核终态命令号,用于区分同命令补偿与另一个审核动作.
|
||||||
|
*/
|
||||||
|
private String externalReviewCommandId;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,4 +75,9 @@ public class UserBankWithdrawMoneyApplyQryCmd implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String amountType;
|
private String amountType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅查询仍由旧 Console 审核的申请;统一双审托管单不能继续出现在旧审核入口.
|
||||||
|
*/
|
||||||
|
private Boolean legacyReviewOnly;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -40,6 +40,11 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String bankCardType;
|
private String bankCardType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请提交时的收款地址快照;统一审核回调必须使用该事实,不能回查用户后来修改的银行卡.
|
||||||
|
*/
|
||||||
|
private String withdrawAddress;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
||||||
*/
|
*/
|
||||||
@ -58,6 +63,11 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本次提现原子扣款前的可用余额快照.
|
||||||
|
*/
|
||||||
|
private BigDecimal balanceBefore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交类型 MONEY(美金)、SALARY_DIAMOND(钻石).
|
* 提交类型 MONEY(美金)、SALARY_DIAMOND(钻石).
|
||||||
*/
|
*/
|
||||||
@ -85,6 +95,15 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
|||||||
|
|
||||||
private String latestApprovalStatusName;
|
private String latestApprovalStatusName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非空表示该申请已纳入 HyApp 统一双审,旧 Console 入口不得直接审核.
|
||||||
|
*/
|
||||||
|
private String externalReviewStatus;
|
||||||
|
|
||||||
|
private String externalReviewDecisionCommandId;
|
||||||
|
|
||||||
|
private String externalReviewRefundStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算结果;
|
* 结算结果;
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -216,6 +216,8 @@ public class UserBankBalanceRestController extends BaseController {
|
|||||||
@RequestBody @Validated ApprovalMoneyApplyCmd param) {
|
@RequestBody @Validated ApprovalMoneyApplyCmd param) {
|
||||||
|
|
||||||
param.setUpdateUser(param.getReqUserId());
|
param.setUpdateUser(param.getReqUserId());
|
||||||
|
// 登录态旧 Console 永远不能伪造统一审核命令;外部 commandId 只允许专用 Bearer 回调入口写入.
|
||||||
|
param.setExternalReviewCommandId(null);
|
||||||
userBankBalanceBackService.approvalMoneyApply(param);
|
userBankBalanceBackService.approvalMoneyApply(param);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 钱包的终态回调入口.
|
||||||
|
*
|
||||||
|
* <p>该接口不依赖 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<String> credentialUrls;
|
||||||
|
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private Long reviewerUserId;
|
||||||
|
|
||||||
|
private String reviewerName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin 每个终态动作的唯一命令号;当前钱包以申请 SUBMIT 原子门禁完成幂等,保留字段便于跨系统审计关联.
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
private String commandId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,6 +77,9 @@ import java.util.stream.Stream;
|
|||||||
public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService {
|
public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class);
|
||||||
|
private static final Set<String> EXTERNAL_REVIEW_STATUSES =
|
||||||
|
Set.of("PENDING", "FAILED", "SUBMITTED");
|
||||||
|
private static final String WITHDRAWAL_REFUND_TRACK_PREFIX = "withdraw-refund:";
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final RedisService redisService;
|
private final RedisService redisService;
|
||||||
private final BankAppConvertor bankAppConvertor;
|
private final BankAppConvertor bankAppConvertor;
|
||||||
@ -592,6 +595,9 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
public List<UserBankWithdrawMoneyApplyCO> pageUserBankWithdrawMoneyApply(
|
public List<UserBankWithdrawMoneyApplyCO> pageUserBankWithdrawMoneyApply(
|
||||||
UserBankWithdrawMoneyApplyQryCmd query) {
|
UserBankWithdrawMoneyApplyQryCmd query) {
|
||||||
|
|
||||||
|
// 旧 Console 只保留历史审核 owner;进入 HyApp 双审的申请必须从该列表剔除,避免两个后台争抢终态.
|
||||||
|
query.setLegacyReviewOnly(true);
|
||||||
|
|
||||||
List<UserBankWithdrawMoneyApplyDTO> applyList = ResponseAssert.requiredSuccess(
|
List<UserBankWithdrawMoneyApplyDTO> applyList = ResponseAssert.requiredSuccess(
|
||||||
bankRunningWaterClient
|
bankRunningWaterClient
|
||||||
.listBankWithdrawMoneyApply(query));
|
.listBankWithdrawMoneyApply(query));
|
||||||
@ -686,10 +692,13 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
UserBankWithdrawMoneyApplyExportQryCmd query) {
|
UserBankWithdrawMoneyApplyExportQryCmd query) {
|
||||||
|
|
||||||
|
UserBankWithdrawMoneyApplyQryCmd walletQuery =
|
||||||
|
bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query);
|
||||||
|
walletQuery.setLegacyReviewOnly(true);
|
||||||
|
|
||||||
List<UserBankWithdrawMoneyApplyDTO> applies = ResponseAssert.requiredSuccess(
|
List<UserBankWithdrawMoneyApplyDTO> applies = ResponseAssert.requiredSuccess(
|
||||||
bankRunningWaterClient
|
bankRunningWaterClient
|
||||||
.listBankWithdrawMoneyApply(
|
.listBankWithdrawMoneyApply(walletQuery));
|
||||||
bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query)));
|
|
||||||
|
|
||||||
if (CollectionUtils.isEmpty(applies)) {
|
if (CollectionUtils.isEmpty(applies)) {
|
||||||
return;
|
return;
|
||||||
@ -765,8 +774,9 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
}
|
}
|
||||||
if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(),
|
if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(),
|
||||||
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
||||||
UserBankCardDTO userBankCard = ResponseAssert.requiredSuccess(
|
UserBankCardDTO userBankCard = isExternalReviewManaged(apply)
|
||||||
userBankCardClient.getById(apply.getBankCardId()));
|
? toWithdrawalBankCardSnapshot(apply)
|
||||||
|
: ResponseAssert.requiredSuccess(userBankCardClient.getById(apply.getBankCardId()));
|
||||||
apply.setSettlementResult(Optional.ofNullable(apply.getSettlementResult())
|
apply.setSettlementResult(Optional.ofNullable(apply.getSettlementResult())
|
||||||
.map(settlement -> {
|
.map(settlement -> {
|
||||||
settlement.setAcceptBankCard(userBankCard);
|
settlement.setAcceptBankCard(userBankCard);
|
||||||
@ -786,20 +796,45 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects
|
ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects
|
||||||
.equals(status, WithdrawMoneyApprovalProcessStatus.SUBMIT));
|
.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(
|
UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess(
|
||||||
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||||
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
||||||
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, Objects
|
|
||||||
.equals(apply.getLatestApprovalStatus(), WithdrawMoneyApprovalProcessStatus.SUBMIT.name()));
|
|
||||||
|
|
||||||
UserBankCardDTO bankCard = ResponseAssert.requiredSuccess(
|
boolean externalManaged = isExternalReviewManaged(apply);
|
||||||
userBankCardClient.getById(apply.getBankCardId()));
|
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);
|
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
|
||||||
|
|
||||||
bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd()
|
Boolean updated = ResponseAssert.requiredSuccess(
|
||||||
|
bankRunningWaterClient.approvalMoneyApplyCas(new UserBankApprovalMoneyApplyCmd()
|
||||||
.setId(apply.getId())
|
.setId(apply.getId())
|
||||||
.setUpdateUserOrigin(1)
|
.setUpdateUserOrigin(1)
|
||||||
.setUpdateUser(param.getUpdateUser())
|
.setUpdateUser(param.getUpdateUser())
|
||||||
|
.setExternalReviewCommandId(param.getExternalReviewCommandId())
|
||||||
.setSettlementResult(new UserBankWithdrawMoneyResult()
|
.setSettlementResult(new UserBankWithdrawMoneyResult()
|
||||||
.setAcceptBankCard(bankCard)
|
.setAcceptBankCard(bankCard)
|
||||||
.setCredential(Optional.ofNullable(param.getCredential())
|
.setCredential(Optional.ofNullable(param.getCredential())
|
||||||
@ -818,10 +853,70 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
.setRemark(param.getRemark())
|
.setRemark(param.getRemark())
|
||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(TimestampUtils.now())
|
||||||
)
|
)
|
||||||
);
|
));
|
||||||
|
|
||||||
// 如果是驳回则将金额还给用户银行卡账户且插入返还日志
|
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);
|
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
|
@Override
|
||||||
@ -903,7 +998,9 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非主播工资进 salary account账户
|
String refundTrackId = WITHDRAWAL_REFUND_TRACK_PREFIX + apply.getId();
|
||||||
|
|
||||||
|
// 非主播工资进 salary account 账户;独立退款 trackId 不能与原 WITHDRAW 流水共用申请号.
|
||||||
if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) {
|
if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) {
|
||||||
SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd();
|
SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd();
|
||||||
withdrawInnerCmd.setUserId(apply.getSubmitUserId());
|
withdrawInnerCmd.setUserId(apply.getSubmitUserId());
|
||||||
@ -912,36 +1009,44 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
withdrawInnerCmd.setRemark(param.getRemark());
|
withdrawInnerCmd.setRemark(param.getRemark());
|
||||||
withdrawInnerCmd.setSalaryType(apply.getAmountType());
|
withdrawInnerCmd.setSalaryType(apply.getAmountType());
|
||||||
withdrawInnerCmd.setAmount(apply.getAmount());
|
withdrawInnerCmd.setAmount(apply.getAmount());
|
||||||
userSalaryAccountClient.withdrawRefund(withdrawInnerCmd);
|
withdrawInnerCmd.setTrackId(refundTrackId);
|
||||||
|
ResponseAssert.requiredSuccess(userSalaryAccountClient.withdrawRefund(withdrawInnerCmd));
|
||||||
|
// 驳回后用户应能重新提交;HOST 与其他工资类型都必须清理同一月度提现限制。
|
||||||
|
ResponseAssert.requiredSuccess(
|
||||||
|
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseAssert.requiredSuccess(userBankBalanceClient.incr(
|
BankBalanceDTO refundedBalance = ResponseAssert.requiredSuccess(
|
||||||
new UserBankBalanceIncrCmd()
|
userBankBalanceClient.refundWithdrawalOnce(new UserBankWithdrawalRefundCmd()
|
||||||
.setUserId(apply.getSubmitUserId())
|
|
||||||
.setSysOrigin(apply.getSysOrigin())
|
|
||||||
.setAmount(PennyAmount.ofDollar(apply.getAmount())))
|
|
||||||
);
|
|
||||||
|
|
||||||
bankRunningWaterClient.add(new UserBankRunningWaterDTO()
|
|
||||||
.setId(IdWorkerUtils.getId())
|
|
||||||
.setUserId(apply.getSubmitUserId())
|
.setUserId(apply.getSubmitUserId())
|
||||||
.setSysOrigin(apply.getSysOrigin())
|
.setSysOrigin(apply.getSysOrigin())
|
||||||
.setType(0)
|
.setAmount(PennyAmount.ofDollar(apply.getAmount()))
|
||||||
.setTrackId(apply.getId().toString())
|
.setTrackId(refundTrackId)));
|
||||||
.setAmount(apply.getAmount())
|
ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, refundedBalance);
|
||||||
.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());
|
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,
|
private void saveUserBankDiamondBalance(ApprovalMoneyApplyCmd param,
|
||||||
@ -978,6 +1083,7 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
.setCreateUserOrigin(0)
|
.setCreateUserOrigin(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId());
|
ResponseAssert.requiredSuccess(
|
||||||
|
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,11 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
|
# 与 hyapp-server 的 Yumi withdrawal source callback_token 保持一致,只允许由生产运行环境注入。
|
||||||
|
hyapp:
|
||||||
|
withdrawal-review:
|
||||||
|
callback-token: ${HYAPP_WITHDRAWAL_REVIEW_CALLBACK_TOKEN:}
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
nacos:
|
nacos:
|
||||||
subscribeServices:
|
subscribeServices:
|
||||||
@ -78,3 +83,5 @@ red-circle:
|
|||||||
- /console/datav/country-dashboard/game-metrics/games
|
- /console/datav/country-dashboard/game-metrics/games
|
||||||
- /console/datav/country-dashboard/game-metrics/country-rank
|
- /console/datav/country-dashboard/game-metrics/country-rank
|
||||||
- /console/user/base/info/im/sig
|
- /console/user/base/info/im/sig
|
||||||
|
# 该入口不走 Console 登录态;控制器使用独立资金回调令牌做常量时间鉴权。
|
||||||
|
- /console/integration/withdrawal-review/decision
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import com.red.circle.tool.core.text.StringUtils;
|
|||||||
import com.red.circle.tool.core.tuple.PennyAmount;
|
import com.red.circle.tool.core.tuple.PennyAmount;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO;
|
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.cmd.UserBankWithdrawCmd;
|
||||||
|
import com.red.circle.wallet.app.scheduler.WithdrawalReviewSubmissionTask;
|
||||||
import com.red.circle.wallet.domain.gateway.SalaryAccountGateway;
|
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.entity.bank.UserBankRunningWater;
|
||||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
||||||
@ -42,9 +43,12 @@ import com.red.circle.wallet.inner.model.enums.UserBankWaterEvent;
|
|||||||
import com.red.circle.wallet.inner.model.enums.WithdrawMethod;
|
import com.red.circle.wallet.inner.model.enums.WithdrawMethod;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.sql.Timestamp;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,8 +58,12 @@ import org.springframework.stereotype.Component;
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class UserBankWithdrawCmdExe {
|
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 RedisService redisService;
|
||||||
private final RegionConfigClient regionConfigClient;
|
private final RegionConfigClient regionConfigClient;
|
||||||
private final UserBankCardService userBankCardService;
|
private final UserBankCardService userBankCardService;
|
||||||
@ -66,19 +74,114 @@ public class UserBankWithdrawCmdExe {
|
|||||||
private final UserBankBalanceService userBankBalanceService;
|
private final UserBankBalanceService userBankBalanceService;
|
||||||
private final UserBankRunningWaterService userBankRunningWaterService;
|
private final UserBankRunningWaterService userBankRunningWaterService;
|
||||||
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
||||||
|
private final WithdrawalReviewSubmissionTask withdrawalReviewSubmissionTask;
|
||||||
|
|
||||||
|
|
||||||
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, ArithmeticUtils.gtZero(cmd.getAmount()));
|
normalizeAndValidateAmount(cmd);
|
||||||
|
|
||||||
// 检查是否允许提现
|
if (!withdrawalReviewSubmissionTask.shouldRouteNewApplication()) {
|
||||||
checkHostSalaryWithdrawEnabled(cmd);
|
// future cutover 前必须保持旧 SUBMIT writer 协议;否则滚动期间旧 Wallet 节点不认识 PREPARING,
|
||||||
|
// 请求重试可能在旧节点创建第二个申请并扣款,随后新节点又恢复第一笔造成双扣.
|
||||||
|
return executeLegacy(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
// 上锁
|
// 上锁
|
||||||
String key = "UBWithdraw:" + cmd.requiredReqUserId();
|
String key = "UBWithdraw:" + cmd.requiredReqUserId();
|
||||||
ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60));
|
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());
|
SalaryType salaryType = SalaryType.of(cmd.getSalaryType());
|
||||||
|
ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType);
|
||||||
|
|
||||||
BigDecimal balance = getUserBalance(cmd, salaryType);
|
BigDecimal balance = getUserBalance(cmd, salaryType);
|
||||||
|
|
||||||
@ -88,41 +191,17 @@ public class UserBankWithdrawCmdExe {
|
|||||||
UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId());
|
UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId());
|
||||||
ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard);
|
ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard);
|
||||||
|
|
||||||
Long applyId = IdWorkerUtils.getId();
|
// 所有可能失败的区域/手续费读取都在扣款前完成,准备单一旦持久化便成为后续恢复的唯一业务事实.
|
||||||
|
|
||||||
// 扣钱
|
|
||||||
BigDecimal afterBalance = changeBalance(cmd, salaryType, applyId);
|
|
||||||
|
|
||||||
// 手续费
|
|
||||||
Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId());
|
Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId());
|
||||||
|
validateServiceCharge(serviceCharge);
|
||||||
BigDecimal actualAmount = getActualAmount(cmd.getAmount(), 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 幂等续跑.
|
||||||
if (SalaryType.HOST_SALARY == salaryType) {
|
UserBankWithdrawMoneyApply withdrawalApply =
|
||||||
// 银行卡流水
|
|
||||||
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()
|
new UserBankWithdrawMoneyApply()
|
||||||
.setId(applyId)
|
.setId(applyId)
|
||||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
.setSysOrigin(cmd.requireReqSysOrigin())
|
||||||
@ -130,30 +209,194 @@ public class UserBankWithdrawCmdExe {
|
|||||||
.setSubmitUserId(cmd.requiredReqUserId())
|
.setSubmitUserId(cmd.requiredReqUserId())
|
||||||
.setBankCardId(bankCard.getId())
|
.setBankCardId(bankCard.getId())
|
||||||
.setBankCardType(bankCard.getCardType())
|
.setBankCardType(bankCard.getCardType())
|
||||||
|
// 收款地址是申请时资金事实;用户之后修改或删除银行卡不能改变已进入审核的目标地址。
|
||||||
|
.setWithdrawAddress(bankCard.getCardNo())
|
||||||
.setAmount(cmd.getAmount())
|
.setAmount(cmd.getAmount())
|
||||||
|
.setBalanceBefore(balance)
|
||||||
.setAmountType(salaryType.getCode())
|
.setAmountType(salaryType.getCode())
|
||||||
.setApprovalProcesses(List.of(
|
.setApprovalProcesses(List.of(
|
||||||
new UserBankWithdrawMoneyApprovalProcess()
|
new UserBankWithdrawMoneyApprovalProcess()
|
||||||
.setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
.setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||||
.setRemark("Application submitted")
|
.setRemark("Application submitted")
|
||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(now)
|
||||||
))
|
))
|
||||||
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
.setLatestApprovalStatus("PREPARING")
|
||||||
.setServiceCharge(serviceCharge)
|
.setServiceCharge(serviceCharge)
|
||||||
.setActualAmount(actualAmount)
|
.setActualAmount(actualAmount)
|
||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(now)
|
||||||
.setUpdateTime(TimestampUtils.now())
|
.setUpdateTime(now)
|
||||||
.setCreateUser(cmd.requiredReqUserId())
|
.setCreateUser(cmd.requiredReqUserId())
|
||||||
.setUpdateUser(cmd.requiredReqUserId())
|
.setUpdateUser(cmd.requiredReqUserId())
|
||||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||||
.setUpdateUserOrigin(OpUserType.APP.getType())
|
.setUpdateUserOrigin(OpUserType.APP.getType());
|
||||||
);
|
if (withdrawalReviewSubmissionTask.shouldRouteNewApplication()) {
|
||||||
|
// 只有切源开关开启后的新申请才归统一双审;预发布阶段和历史单仍由旧 Console 处理,避免两套入口同时有权审核。
|
||||||
|
withdrawalApply.setExternalReviewStatus("PENDING")
|
||||||
|
.setExternalReviewUpdatedTime(TimestampUtils.now());
|
||||||
|
}
|
||||||
|
userBankWithdrawMoneyApplyService.add(withdrawalApply);
|
||||||
|
|
||||||
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
|
try {
|
||||||
redisService.unlock(key);
|
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()
|
return new UserBankWithdrawResultCO()
|
||||||
.setBankBalance(afterBalance)
|
.setBankBalance(balance)
|
||||||
.setWithdrawAmount(Objects.toString(cmd.getAmount()));
|
.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) {
|
private void checkHostSalaryWithdrawEnabled(UserBankWithdrawCmd cmd) {
|
||||||
@ -163,27 +406,31 @@ public class UserBankWithdrawCmdExe {
|
|||||||
hostSalaryWithdrawEnabledQryExe.check(cmd.requiredReqUserId());
|
hostSalaryWithdrawEnabledQryExe.check(cmd.requiredReqUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal changeBalance(UserBankWithdrawCmd cmd, SalaryType salaryType, Long applyId) {
|
private BigDecimal changeBalance(UserBankWithdrawMoneyApply application) {
|
||||||
// 余额
|
// 余额
|
||||||
BigDecimal balance;
|
BigDecimal balance;
|
||||||
|
SalaryType salaryType = SalaryType.of(application.getAmountType());
|
||||||
|
ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType);
|
||||||
|
|
||||||
if (SalaryType.HOST_SALARY == salaryType) {
|
if (SalaryType.HOST_SALARY == salaryType) {
|
||||||
BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.decr(cmd.requiredReqUserId(), PennyAmount.ofDollar(cmd.getAmount()));
|
BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.withdrawOnce(
|
||||||
|
application.getSubmitUserId(), PennyAmount.ofDollar(application.getAmount()),
|
||||||
|
application.getId().toString());
|
||||||
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO);
|
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO);
|
||||||
balance = decrUserBankBalanceDTO.getAccurateBalance();
|
balance = decrUserBankBalanceDTO.getAccurateBalance();
|
||||||
} else {
|
} else {
|
||||||
SalaryReceipt receipt = new SalaryReceipt()
|
SalaryReceipt receipt = new SalaryReceipt()
|
||||||
.setReceiptType(ReceiptType.EXPENDITURE)
|
.setReceiptType(ReceiptType.EXPENDITURE)
|
||||||
.setUserId(cmd.getReqUserId())
|
.setUserId(application.getSubmitUserId())
|
||||||
.setAcceptUserId(cmd.getReqUserId())
|
.setAcceptUserId(application.getSubmitUserId())
|
||||||
.setSysOrigin(cmd.getReqSysOrigin().getOrigin())
|
.setSysOrigin(application.getSysOrigin())
|
||||||
.setSalaryType(salaryType)
|
.setSalaryType(salaryType)
|
||||||
.setSalaryEvent(SalaryEvent.WITHDRAW)
|
.setSalaryEvent(SalaryEvent.WITHDRAW)
|
||||||
.setAmount(PennyAmount.ofDollar(cmd.getAmount()))
|
.setAmount(PennyAmount.ofDollar(application.getAmount()))
|
||||||
.setTrackId(String.valueOf(applyId))
|
.setTrackId(String.valueOf(application.getId()))
|
||||||
.setEventDesc("提现")
|
.setEventDesc("提现")
|
||||||
.setOpUserType(OpUserType.APP)
|
.setOpUserType(OpUserType.APP)
|
||||||
.setOpUserId(cmd.getReqUserId());
|
.setOpUserId(application.getSubmitUserId());
|
||||||
SalaryReceiptRes salaryReceiptRes = salaryAccountGateway.changeBalance(receipt);
|
SalaryReceiptRes salaryReceiptRes = salaryAccountGateway.changeBalance(receipt);
|
||||||
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, salaryReceiptRes);
|
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, salaryReceiptRes);
|
||||||
|
|
||||||
@ -208,6 +455,12 @@ public class UserBankWithdrawCmdExe {
|
|||||||
return amount.subtract(serviceChargeAmount);
|
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) {
|
private Long getAssistDiamondConfig(Long userId) {
|
||||||
|
|
||||||
RegionConfigDTO regionConfig = ResponseAssert.requiredSuccess(
|
RegionConfigDTO regionConfig = ResponseAssert.requiredSuccess(
|
||||||
|
|||||||
@ -55,7 +55,8 @@ public class SalaryAccountConvertor {
|
|||||||
.setSalaryType(SalaryType.of(cmd.getSalaryType()))
|
.setSalaryType(SalaryType.of(cmd.getSalaryType()))
|
||||||
.setSalaryEvent(SalaryEvent.WITHDRAW_REFUND)
|
.setSalaryEvent(SalaryEvent.WITHDRAW_REFUND)
|
||||||
.setAmount(PennyAmount.ofDollar(cmd.getAmount()))
|
.setAmount(PennyAmount.ofDollar(cmd.getAmount()))
|
||||||
.setTrackId(IdWorkerUtils.getIdStr())
|
// 退款流水使用原提现申请号;Admin 重试同一终态时不能生成第二个资金业务号。
|
||||||
|
.setTrackId(cmd.getTrackId())
|
||||||
.setEventDesc("提现退回")
|
.setEventDesc("提现退回")
|
||||||
.setRemark(cmd.getRemark())
|
.setRemark(cmd.getRemark())
|
||||||
.setOpUserType(OpUserType.APP)
|
.setOpUserType(OpUserType.APP)
|
||||||
|
|||||||
@ -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 统一运营/财务双审的提交客户端.
|
||||||
|
*
|
||||||
|
* <p>客户端只提交已经在本地扣款并持久化的申请事实;网络失败由同一来源申请 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<String, Object> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<UserBankWithdrawMoneyApply> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,11 @@ import lombok.EqualsAndHashCode;
|
|||||||
@EqualsAndHashCode(callSuper = false)
|
@EqualsAndHashCode(callSuper = false)
|
||||||
public class SalaryWithdrawCmd extends AppExtCommand {
|
public class SalaryWithdrawCmd extends AppExtCommand {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 来源业务号;提现驳回退款沿用原申请 ID,确保跨服务重试能识别同一笔资金动作.
|
||||||
|
*/
|
||||||
|
private String trackId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工资类型.
|
* 工资类型.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -35,4 +35,9 @@ public interface SalaryAccountGateway {
|
|||||||
* @return 凭证响应
|
* @return 凭证响应
|
||||||
*/
|
*/
|
||||||
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
|
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断稳定业务号对应的工资资金事实是否已提交;用于崩溃恢复时区分已扣款与确定未扣款.
|
||||||
|
*/
|
||||||
|
boolean hasReceipt(String trackId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
|||||||
import java.io.Serial;
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
||||||
@ -53,6 +54,18 @@ public class UserBankBalance implements Serializable {
|
|||||||
@JsonSerialize(using = ToStringSerializer.class)
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
private Long balance;
|
private Long balance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提现退款幂等事实,key 为稳定业务号摘要,value 为首次退款后的余额(分).
|
||||||
|
* 余额增量和该快照由同一个 Mongo 更新管道原子写入,重放既不会重复加钱,也不会用更晚的余额污染退款流水.
|
||||||
|
*/
|
||||||
|
private Map<String, Long> processedWithdrawalRefundBalances;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提现扣款幂等事实,key 为申请号摘要,value 为首次扣款后的余额(分).
|
||||||
|
* 准备单恢复只回读该快照,不能因进程中断重复扣减或把后续入账后的余额写进原提现流水.
|
||||||
|
*/
|
||||||
|
private Map<String, Long> processedWithdrawalDebitBalances;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间.
|
* 创建时间.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -50,6 +50,11 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String bankCardType;
|
private String bankCardType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请时的收款地址快照;可靠投递只读该字段,不能在重试时回查可变银行卡资料.
|
||||||
|
*/
|
||||||
|
private String withdrawAddress;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
||||||
*/
|
*/
|
||||||
@ -66,6 +71,11 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交提现前的可用余额快照;这是申请时财务事实,不能用审核或重试时的当前余额替代.
|
||||||
|
*/
|
||||||
|
private BigDecimal balanceBefore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策)
|
* 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策)
|
||||||
*/
|
*/
|
||||||
@ -92,6 +102,41 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算结果;
|
* 结算结果;
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -34,6 +34,12 @@ public interface UserBankBalanceService {
|
|||||||
*/
|
*/
|
||||||
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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 类型消费累计.
|
* 类型消费累计.
|
||||||
*
|
*
|
||||||
@ -43,6 +49,16 @@ public interface UserBankBalanceService {
|
|||||||
*/
|
*/
|
||||||
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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取余额.
|
* 获取余额.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply;
|
||||||
import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd;
|
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.UserBankWithdrawMoneyApplyQryCmd;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Timestamp;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -42,9 +44,49 @@ public interface UserBankWithdrawMoneyApplyService {
|
|||||||
*/
|
*/
|
||||||
UserBankWithdrawMoneyApply getById(Long id);
|
UserBankWithdrawMoneyApply getById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回用户尚未完成扣款状态机的准备单,客户端重试必须续跑同一申请而不是生成第二次扣款.
|
||||||
|
*/
|
||||||
|
UserBankWithdrawMoneyApply getLatestPreparingByUser(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界扫描超时准备单,恢复进程中断在“申请已持久化、资金状态未收口”之间的请求.
|
||||||
|
*/
|
||||||
|
List<UserBankWithdrawMoneyApply> listPreparingForRecovery(Timestamp olderThan, int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扣款、原始流水和月度门禁完成后,原子开放给旧 Console 或 HyApp 审核.
|
||||||
|
*/
|
||||||
|
boolean markReadyForReview(Long id, BigDecimal balanceBefore);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在确认没有扣款事实后终止准备单,使用户修正余额后可以重新申请;不能用于结果未知的异常.
|
||||||
|
*/
|
||||||
|
boolean markPreparationFailed(Long id, String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定小批量获取尚未成功进入统一双审中心的申请,避免补偿任务无界加载历史数据.
|
||||||
|
*/
|
||||||
|
List<UserBankWithdrawMoneyApply> 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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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.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.mongodb.client.result.UpdateResult;
|
||||||
import com.red.circle.component.mongodb.config.MongoPrimaryService;
|
import com.red.circle.component.mongodb.config.MongoPrimaryService;
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
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.UserBankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||||
import java.math.BigDecimal;
|
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.Collection;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -21,6 +28,7 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.bson.Document;
|
import org.bson.Document;
|
||||||
|
import org.bson.conversions.Bson;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.mongodb.core.aggregation.Aggregation;
|
import org.springframework.data.mongodb.core.aggregation.Aggregation;
|
||||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||||
@ -88,6 +96,19 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
|||||||
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
|
@Override
|
||||||
public BankBalanceDTO getBalance(Long userId) {
|
public BankBalanceDTO getBalance(Long userId) {
|
||||||
return BankBalanceDTO.of(mongoPrimaryService.find(UserBankBalance.class,
|
return BankBalanceDTO.of(mongoPrimaryService.find(UserBankBalance.class,
|
||||||
@ -96,6 +117,71 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
|||||||
: getLongValue(documents.get(0), "balance")));
|
: 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<Bson> 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<String, Long> 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) {
|
static Long getLongValue(Document document, String key) {
|
||||||
Object value = document.get(key);
|
Object value = document.get(key);
|
||||||
if (Objects.isNull(value)) {
|
if (Objects.isNull(value)) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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.collection.CollectionUtils;
|
||||||
import com.red.circle.tool.core.date.TimestampUtils;
|
import com.red.circle.tool.core.date.TimestampUtils;
|
||||||
import com.red.circle.tool.core.date.ZonedDateTimeUtils;
|
import com.red.circle.tool.core.date.ZonedDateTimeUtils;
|
||||||
@ -8,6 +9,8 @@ import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMo
|
|||||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankWithdrawMoneyApplyService;
|
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.UserBankApprovalMoneyApplyCmd;
|
||||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Timestamp;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -101,6 +104,15 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
|||||||
criteria.and("latestApprovalStatus").is(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())) {
|
if (Objects.nonNull(query.getStartTime()) && Objects.nonNull(query.getEndTime())) {
|
||||||
criteria.andOperator(
|
criteria.andOperator(
|
||||||
Criteria.where("createTime").gte(TimestampUtils.convert(query.getStartTime())),
|
Criteria.where("createTime").gte(TimestampUtils.convert(query.getStartTime())),
|
||||||
@ -122,18 +134,134 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
public List<UserBankWithdrawMoneyApply> listPendingExternalReview(int limit) {
|
||||||
|
// 只扫描仍在等待审核且未成功投递的申请,并限制单批上限;按最近尝试时间轮转,避免一个永久坏单占住批次饿死新申请。
|
||||||
mongoTemplate.updateFirst(Query.query(Criteria.where("id").in(apply.getId())),
|
Criteria criteria = Criteria.where("latestApprovalStatus").is("SUBMIT")
|
||||||
new Update()
|
// 只补投本版本明确写入 outbox 状态的新申请;历史缺字段 SUBMIT 单继续留在旧 Console,不能在开关开启时批量迁移。
|
||||||
.set("updateTime", TimestampUtils.now())
|
.and("externalReviewStatus").in("PENDING", "FAILED");
|
||||||
.set("updateUser", apply.getUpdateUser())
|
return mongoTemplate.find(Query.query(criteria)
|
||||||
.set("updateUserOrigin", apply.getUpdateUserOrigin())
|
.with(Sort.by(Sort.Order.asc("externalReviewUpdatedTime"),
|
||||||
.set("latestApprovalStatus", apply.getLatestApprovalStatus())
|
Sort.Order.asc("createTime")))
|
||||||
.set("settlementResult", apply.getSettlementResult())
|
.limit(Math.max(1, Math.min(limit, 100))),
|
||||||
.push("approvalProcesses", apply.getProcess()),
|
|
||||||
UserBankWithdrawMoneyApply.class);
|
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<UserBankWithdrawMoneyApply> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import java.math.BigDecimal;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -54,6 +53,16 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
|||||||
throw new IllegalArgumentException("changeBalance required param error.");
|
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(
|
SalaryAccountBalance balance = salaryAccountBalanceService.getByUserIdAndType(
|
||||||
receipt.getUserId(), receipt.getSalaryType());
|
receipt.getUserId(), receipt.getSalaryType());
|
||||||
@ -91,27 +100,10 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
|||||||
|
|
||||||
// 插入流水记录
|
// 插入流水记录
|
||||||
Long recordId = IdWorkerUtils.getId();
|
Long recordId = IdWorkerUtils.getId();
|
||||||
try {
|
SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId,
|
||||||
SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId,
|
newBalance, newAvailableBalance);
|
||||||
newBalance, newAvailableBalance);
|
// track_id 唯一冲突必须向外抛出,让 @Transactional 回滚本次余额增量;调用方随后用同一命令重试并回读赢家流水.
|
||||||
salaryAccountRunningWaterService.save(runningWater);
|
salaryAccountRunningWaterService.save(runningWater);
|
||||||
} catch (DuplicateKeyException e) {
|
|
||||||
// 幂等性:trackId重复,查询已有记录
|
|
||||||
LambdaQueryWrapper<SalaryAccountRunningWater> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return SalaryReceiptRes.of(
|
return SalaryReceiptRes.of(
|
||||||
recordId,
|
recordId,
|
||||||
@ -121,6 +113,47 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasReceipt(String trackId) {
|
||||||
|
return trackId != null && !trackId.isBlank() && getRunningWaterByTrackId(trackId) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SalaryAccountRunningWater getRunningWaterByTrackId(String trackId) {
|
||||||
|
LambdaQueryWrapper<SalaryAccountRunningWater> 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,
|
private SalaryAccountRunningWater buildRunningWater(SalaryReceipt receipt, Long recordId,
|
||||||
Long balance, Long availableBalance) {
|
Long balance, Long availableBalance) {
|
||||||
SalaryAccountRunningWater water = new SalaryAccountRunningWater()
|
SalaryAccountRunningWater water = new SalaryAccountRunningWater()
|
||||||
|
|||||||
@ -53,6 +53,8 @@ public class SalaryAccountInnerConvertor {
|
|||||||
}
|
}
|
||||||
SalaryWithdrawCmd cmd = new SalaryWithdrawCmd();
|
SalaryWithdrawCmd cmd = new SalaryWithdrawCmd();
|
||||||
cmd.setAmount(innerCmd.getAmount());
|
cmd.setAmount(innerCmd.getAmount());
|
||||||
|
// 外部审核重试必须沿用来源提现号;不能在转换层丢失退款幂等键。
|
||||||
|
cmd.setTrackId(innerCmd.getTrackId());
|
||||||
cmd.setRemark(innerCmd.getRemark());
|
cmd.setRemark(innerCmd.getRemark());
|
||||||
cmd.setSalaryType(innerCmd.getSalaryType());
|
cmd.setSalaryType(innerCmd.getSalaryType());
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,17 @@ public class UserBankRunningWaterClientEndpoint implements UserBankRunningWaterC
|
|||||||
return ResultResponse.success();
|
return ResultResponse.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultResponse<Boolean> approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply) {
|
||||||
|
return ResultResponse.success(userBankRunningWaterClientService.approvalMoneyApplyCas(apply));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultResponse<Boolean> markExternalReviewRefundCompleted(Long id, String commandId) {
|
||||||
|
return ResultResponse.success(
|
||||||
|
userBankRunningWaterClientService.markExternalReviewRefundCompleted(id, commandId));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResultResponse<List<UserBankRunningWaterDTO>> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
public ResultResponse<List<UserBankRunningWaterDTO>> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
||||||
return ResultResponse.success(userBankRunningWaterClientService.getMonthSalaryRunningWater(userIds, event, month));
|
return ResultResponse.success(userBankRunningWaterClientService.getMonthSalaryRunningWater(userIds, event, month));
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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.UserBankBalanceDecrCmd;
|
||||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
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.BankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||||
@ -42,6 +43,11 @@ public class UserBankBalanceClientEndpoint implements UserBankBalanceClientApi {
|
|||||||
return ResultResponse.success(userBankBalanceClientService.incr(cmd));
|
return ResultResponse.success(userBankBalanceClientService.incr(cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultResponse<BankBalanceDTO> refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd) {
|
||||||
|
return ResultResponse.success(userBankBalanceClientService.refundWithdrawalOnce(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResultResponse<BankBalanceDTO> decr(UserBankBalanceDecrCmd cmd) {
|
public ResultResponse<BankBalanceDTO> decr(UserBankBalanceDecrCmd cmd) {
|
||||||
return ResultResponse.success(userBankBalanceClientService.decr(cmd));
|
return ResultResponse.success(userBankBalanceClientService.decr(cmd));
|
||||||
|
|||||||
@ -75,6 +75,10 @@ public interface UserBankRunningWaterClientService {
|
|||||||
*/
|
*/
|
||||||
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
|
boolean approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
|
boolean markExternalReviewRefundCompleted(Long id, String commandId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月用户工资流水.
|
* 月用户工资流水.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -97,6 +97,16 @@ public class UserBankRunningWaterClientServiceImpl implements UserBankRunningWat
|
|||||||
bankWithdrawMoneyApplyService.approvalMoneyApply(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
|
@Override
|
||||||
public List<UserBankRunningWaterDTO> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
public List<UserBankRunningWaterDTO> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
||||||
return userBankInnerConvertor.toListUserBankRunningWaterDTO(
|
return userBankInnerConvertor.toListUserBankRunningWaterDTO(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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.UserBankBalanceDecrCmd;
|
||||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
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.BankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||||
@ -34,6 +35,8 @@ public interface UserBankBalanceClientService {
|
|||||||
*/
|
*/
|
||||||
boolean incr(UserBankBalanceIncrCmd cmd);
|
boolean incr(UserBankBalanceIncrCmd cmd);
|
||||||
|
|
||||||
|
BankBalanceDTO refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 类型消费累计.
|
* 类型消费累计.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceSe
|
|||||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
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.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.BankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||||
@ -38,6 +39,12 @@ public class UserBankBalanceClientServiceImpl implements UserBankBalanceClientSe
|
|||||||
return userBankBalanceService.incr(cmd.getUserId(), cmd.getSysOrigin(), cmd.getAmount());
|
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
|
@Override
|
||||||
public BankBalanceDTO decr(UserBankBalanceDecrCmd cmd) {
|
public BankBalanceDTO decr(UserBankBalanceDecrCmd cmd) {
|
||||||
return userBankBalanceService.decr(cmd.getUserId(), cmd.getAmount());
|
return userBankBalanceService.decr(cmd.getUserId(), cmd.getAmount());
|
||||||
|
|||||||
@ -29,4 +29,16 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user