feat(withdrawal): route Yumi reviews through HyApp
This commit is contained in:
parent
69300b45f6
commit
4ebfb3cc6b
@ -3,7 +3,8 @@ package com.red.circle.wallet.inner.endpoint.bank.api;
|
||||
import com.red.circle.framework.dto.ResultResponse;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd;
|
||||
import com.red.circle.wallet.inner.model.dto.BankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||
@ -38,7 +39,14 @@ public interface UserBankBalanceClientApi {
|
||||
* @return 是否操作成功
|
||||
*/
|
||||
@PostMapping("/incr")
|
||||
ResultResponse<Boolean> incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd);
|
||||
ResultResponse<Boolean> incr(@RequestBody @Validated UserBankBalanceIncrCmd cmd);
|
||||
|
||||
/**
|
||||
* 提现驳回按 trackId 原子退款,供统一审核回调安全重试.
|
||||
*/
|
||||
@PostMapping("/refundWithdrawalOnce")
|
||||
ResultResponse<BankBalanceDTO> refundWithdrawalOnce(
|
||||
@RequestBody @Validated UserBankWithdrawalRefundCmd cmd);
|
||||
|
||||
/**
|
||||
* 类型消费累计.
|
||||
|
||||
@ -75,8 +75,20 @@ public interface UserBankRunningWaterClientApi {
|
||||
@GetMapping("/getWithdrawMoneyApplyById")
|
||||
ResultResponse<UserBankWithdrawMoneyApplyDTO> getWithdrawMoneyApplyById(@RequestParam("id") Long id);
|
||||
|
||||
@PostMapping("/approvalMoneyApply")
|
||||
ResultResponse<Void> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
||||
@PostMapping("/approvalMoneyApply")
|
||||
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);
|
||||
|
||||
/**
|
||||
* 月用户工资流水
|
||||
|
||||
@ -44,6 +44,11 @@ public class ApprovalMoneyApplyCmd extends CommonCommand {
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
private Long updateUser;
|
||||
|
||||
}
|
||||
private Long updateUser;
|
||||
|
||||
/**
|
||||
* 统一审核终态命令号;普通旧 Console 审核为空,外部双审回调必须携带并持久化.
|
||||
*/
|
||||
private String externalReviewCommandId;
|
||||
|
||||
}
|
||||
|
||||
@ -46,7 +46,12 @@ public class UserBankApprovalMoneyApplyCmd extends CommonCommand {
|
||||
*/
|
||||
private Long updateUser;
|
||||
|
||||
private Integer updateUserOrigin;
|
||||
|
||||
|
||||
}
|
||||
private Integer updateUserOrigin;
|
||||
|
||||
/**
|
||||
* 统一审核终态命令号,用于区分同命令补偿与另一个审核动作.
|
||||
*/
|
||||
private String externalReviewCommandId;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -73,6 +73,11 @@ public class UserBankWithdrawMoneyApplyQryCmd implements Serializable {
|
||||
/**
|
||||
* 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@ -38,7 +38,12 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
||||
/**
|
||||
* 银行卡类型.
|
||||
*/
|
||||
private String bankCardType;
|
||||
private String bankCardType;
|
||||
|
||||
/**
|
||||
* 申请提交时的收款地址快照;统一审核回调必须使用该事实,不能回查用户后来修改的银行卡.
|
||||
*/
|
||||
private String withdrawAddress;
|
||||
|
||||
/**
|
||||
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
||||
@ -56,7 +61,12 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
||||
/**
|
||||
* 提交金额.
|
||||
*/
|
||||
private BigDecimal amount;
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* 本次提现原子扣款前的可用余额快照.
|
||||
*/
|
||||
private BigDecimal balanceBefore;
|
||||
|
||||
/**
|
||||
* 提交类型 MONEY(美金)、SALARY_DIAMOND(钻石).
|
||||
@ -83,13 +93,22 @@ public class UserBankWithdrawMoneyApplyDTO implements Serializable {
|
||||
*/
|
||||
private String latestApprovalStatus;
|
||||
|
||||
private String latestApprovalStatusName;
|
||||
private String latestApprovalStatusName;
|
||||
|
||||
/**
|
||||
* 非空表示该申请已纳入 HyApp 统一双审,旧 Console 入口不得直接审核.
|
||||
*/
|
||||
private String externalReviewStatus;
|
||||
|
||||
private String externalReviewDecisionCommandId;
|
||||
|
||||
private String externalReviewRefundStatus;
|
||||
|
||||
/**
|
||||
* 结算结果;
|
||||
*/
|
||||
|
||||
private UserBankWithdrawMoneyResult settlementResult;
|
||||
private UserBankWithdrawMoneyResult settlementResult;
|
||||
|
||||
/**
|
||||
* 创建时间.
|
||||
|
||||
@ -212,11 +212,13 @@ public class UserBankBalanceRestController extends BaseController {
|
||||
* 审核用户银行卡提现现金申请.
|
||||
*/
|
||||
@PostMapping("/approval-money-apply")
|
||||
public void approvalMoneyApply(
|
||||
@RequestBody @Validated ApprovalMoneyApplyCmd param) {
|
||||
|
||||
param.setUpdateUser(param.getReqUserId());
|
||||
userBankBalanceBackService.approvalMoneyApply(param);
|
||||
public void approvalMoneyApply(
|
||||
@RequestBody @Validated ApprovalMoneyApplyCmd param) {
|
||||
|
||||
param.setUpdateUser(param.getReqUserId());
|
||||
// 登录态旧 Console 永远不能伪造统一审核命令;外部 commandId 只允许专用 Bearer 回调入口写入.
|
||||
param.setExternalReviewCommandId(null);
|
||||
userBankBalanceBackService.approvalMoneyApply(param);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -74,9 +74,12 @@ import java.util.stream.Stream;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class);
|
||||
public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserBankBalanceBackServiceImpl.class);
|
||||
private static final Set<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 RedisService redisService;
|
||||
private final BankAppConvertor bankAppConvertor;
|
||||
@ -589,10 +592,13 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserBankWithdrawMoneyApplyCO> pageUserBankWithdrawMoneyApply(
|
||||
UserBankWithdrawMoneyApplyQryCmd query) {
|
||||
|
||||
List<UserBankWithdrawMoneyApplyDTO> applyList = ResponseAssert.requiredSuccess(
|
||||
public List<UserBankWithdrawMoneyApplyCO> pageUserBankWithdrawMoneyApply(
|
||||
UserBankWithdrawMoneyApplyQryCmd query) {
|
||||
|
||||
// 旧 Console 只保留历史审核 owner;进入 HyApp 双审的申请必须从该列表剔除,避免两个后台争抢终态.
|
||||
query.setLegacyReviewOnly(true);
|
||||
|
||||
List<UserBankWithdrawMoneyApplyDTO> applyList = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient
|
||||
.listBankWithdrawMoneyApply(query));
|
||||
|
||||
@ -682,14 +688,17 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportUserBankWithdrawMoneyApply(
|
||||
HttpServletResponse response,
|
||||
UserBankWithdrawMoneyApplyExportQryCmd query) {
|
||||
|
||||
List<UserBankWithdrawMoneyApplyDTO> applies = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient
|
||||
.listBankWithdrawMoneyApply(
|
||||
bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query)));
|
||||
public void exportUserBankWithdrawMoneyApply(
|
||||
HttpServletResponse response,
|
||||
UserBankWithdrawMoneyApplyExportQryCmd query) {
|
||||
|
||||
UserBankWithdrawMoneyApplyQryCmd walletQuery =
|
||||
bankAppConvertor.toUserBankWithdrawMoneyApplyQryCmd(query);
|
||||
walletQuery.setLegacyReviewOnly(true);
|
||||
|
||||
List<UserBankWithdrawMoneyApplyDTO> applies = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient
|
||||
.listBankWithdrawMoneyApply(walletQuery));
|
||||
|
||||
if (CollectionUtils.isEmpty(applies)) {
|
||||
return;
|
||||
@ -763,10 +772,11 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
if (CollectionUtils.isEmpty(userProfileMap)) {
|
||||
return new UserBankWithdrawMoneyApplyCO();
|
||||
}
|
||||
if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
||||
UserBankCardDTO userBankCard = ResponseAssert.requiredSuccess(
|
||||
userBankCardClient.getById(apply.getBankCardId()));
|
||||
if (Objects.nonNull(apply.getBankCardId()) && Objects.equals(apply.getLatestApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
||||
UserBankCardDTO userBankCard = isExternalReviewManaged(apply)
|
||||
? toWithdrawalBankCardSnapshot(apply)
|
||||
: ResponseAssert.requiredSuccess(userBankCardClient.getById(apply.getBankCardId()));
|
||||
apply.setSettlementResult(Optional.ofNullable(apply.getSettlementResult())
|
||||
.map(settlement -> {
|
||||
settlement.setAcceptBankCard(userBankCard);
|
||||
@ -778,29 +788,54 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
return toUserBankWithdrawMoneyApplyVO(apply, userProfileMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void approvalMoneyApply(ApprovalMoneyApplyCmd param) {
|
||||
|
||||
WithdrawMoneyApprovalProcessStatus status = WithdrawMoneyApprovalProcessStatus
|
||||
.valueOf(param.getApprovalStatus());
|
||||
ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects
|
||||
.equals(status, WithdrawMoneyApprovalProcessStatus.SUBMIT));
|
||||
|
||||
UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
||||
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, Objects
|
||||
.equals(apply.getLatestApprovalStatus(), WithdrawMoneyApprovalProcessStatus.SUBMIT.name()));
|
||||
|
||||
UserBankCardDTO bankCard = ResponseAssert.requiredSuccess(
|
||||
userBankCardClient.getById(apply.getBankCardId()));
|
||||
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
|
||||
|
||||
bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd()
|
||||
.setId(apply.getId())
|
||||
.setUpdateUserOrigin(1)
|
||||
.setUpdateUser(param.getUpdateUser())
|
||||
.setSettlementResult(new UserBankWithdrawMoneyResult()
|
||||
@Override
|
||||
public void approvalMoneyApply(ApprovalMoneyApplyCmd param) {
|
||||
|
||||
WithdrawMoneyApprovalProcessStatus status = WithdrawMoneyApprovalProcessStatus
|
||||
.valueOf(param.getApprovalStatus());
|
||||
ResponseAssert.isFalse(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects
|
||||
.equals(status, WithdrawMoneyApprovalProcessStatus.SUBMIT));
|
||||
|
||||
// 同一申请的终态、退款和完成标记必须串行;锁失败让 Admin 重试,不能并发执行两次资金补偿.
|
||||
String lockKey = "withdrawal-review-approval:" + param.getId();
|
||||
ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(lockKey, 60));
|
||||
try {
|
||||
approvalMoneyApplyLocked(param);
|
||||
} finally {
|
||||
redisService.unlock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void approvalMoneyApplyLocked(ApprovalMoneyApplyCmd param) {
|
||||
|
||||
UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
||||
|
||||
boolean externalManaged = isExternalReviewManaged(apply);
|
||||
boolean externalCommand = StringUtils.isNotBlank(param.getExternalReviewCommandId());
|
||||
// 旧登录态 Controller 会清空 commandId;外部托管单只能走独立 Bearer 回调,回调也不能接管历史旧单.
|
||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
||||
externalManaged == externalCommand);
|
||||
|
||||
if (!Objects.equals(apply.getLatestApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
||||
completeTerminalReview(param, apply, externalManaged);
|
||||
return;
|
||||
}
|
||||
|
||||
UserBankCardDTO bankCard = externalManaged
|
||||
? toWithdrawalBankCardSnapshot(apply)
|
||||
: ResponseAssert.requiredSuccess(userBankCardClient.getById(apply.getBankCardId()));
|
||||
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
|
||||
|
||||
Boolean updated = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.approvalMoneyApplyCas(new UserBankApprovalMoneyApplyCmd()
|
||||
.setId(apply.getId())
|
||||
.setUpdateUserOrigin(1)
|
||||
.setUpdateUser(param.getUpdateUser())
|
||||
.setExternalReviewCommandId(param.getExternalReviewCommandId())
|
||||
.setSettlementResult(new UserBankWithdrawMoneyResult()
|
||||
.setAcceptBankCard(bankCard)
|
||||
.setCredential(Optional.ofNullable(param.getCredential())
|
||||
.map(imgUrls -> imgUrls.stream()
|
||||
@ -817,12 +852,72 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
.setStatus(param.getApprovalStatus())
|
||||
.setRemark(param.getRemark())
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
)
|
||||
);
|
||||
|
||||
// 如果是驳回则将金额还给用户银行卡账户且插入返还日志
|
||||
saveUserBankBalance(param, apply);
|
||||
}
|
||||
)
|
||||
));
|
||||
|
||||
if (!Boolean.TRUE.equals(updated)) {
|
||||
// Mongo 以 latestApprovalStatus=SUBMIT 做条件更新;并发重复审核只有一个调用者可以进入后续退款。
|
||||
UserBankWithdrawMoneyApplyDTO latest = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, latest);
|
||||
completeTerminalReview(param, latest, externalManaged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Objects.equals(param.getApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.NOT_PASS.name())) {
|
||||
// 终态先落库,退款失败时 Admin 用相同 commandId 重试;资金动作自身也必须按申请号幂等.
|
||||
saveUserBankBalance(param, apply);
|
||||
if (externalManaged) {
|
||||
markExternalReviewRefundCompleted(apply, param);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTerminalReview(ApprovalMoneyApplyCmd param,
|
||||
UserBankWithdrawMoneyApplyDTO apply, boolean externalManaged) {
|
||||
// Admin 可能在来源处理成功但自身事务提交失败后重试;相反终态永远不能覆盖已落资金事实.
|
||||
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
|
||||
Objects.equals(apply.getLatestApprovalStatus(), param.getApprovalStatus()));
|
||||
if (!externalManaged) {
|
||||
return;
|
||||
}
|
||||
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
|
||||
Objects.equals(apply.getExternalReviewDecisionCommandId(),
|
||||
param.getExternalReviewCommandId()));
|
||||
if (!Objects.equals(param.getApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.NOT_PASS.name())
|
||||
|| Objects.equals(apply.getExternalReviewRefundStatus(), "COMPLETED")) {
|
||||
return;
|
||||
}
|
||||
// NOT_PASS 与退款完成分开持久化;这里补偿响应丢失或上次退款中断的同一命令.
|
||||
saveUserBankBalance(param, apply);
|
||||
markExternalReviewRefundCompleted(apply, param);
|
||||
}
|
||||
|
||||
private void markExternalReviewRefundCompleted(UserBankWithdrawMoneyApplyDTO apply,
|
||||
ApprovalMoneyApplyCmd param) {
|
||||
Boolean marked = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.markExternalReviewRefundCompleted(
|
||||
apply.getId(), param.getExternalReviewCommandId()));
|
||||
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE, Boolean.TRUE.equals(marked));
|
||||
}
|
||||
|
||||
private boolean isExternalReviewManaged(UserBankWithdrawMoneyApplyDTO apply) {
|
||||
return apply != null && EXTERNAL_REVIEW_STATUSES.contains(apply.getExternalReviewStatus());
|
||||
}
|
||||
|
||||
private UserBankCardDTO toWithdrawalBankCardSnapshot(UserBankWithdrawMoneyApplyDTO apply) {
|
||||
ResponseAssert.isTrue(BankErrorCode.INVALID_BANK_CARD_ERROR,
|
||||
StringUtils.isNotBlank(apply.getBankCardType())
|
||||
&& StringUtils.isNotBlank(apply.getWithdrawAddress()));
|
||||
return new UserBankCardDTO()
|
||||
.setId(apply.getBankCardId())
|
||||
.setUserId(apply.getSubmitUserId())
|
||||
.setSysOrigin(apply.getSysOrigin())
|
||||
.setCardType(apply.getBankCardType())
|
||||
.setCardNo(apply.getWithdrawAddress());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void importUserBankBalance(MultipartFile file, CommonCommand cmd) {
|
||||
@ -894,8 +989,8 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
}
|
||||
}
|
||||
|
||||
private void saveUserBankBalance(ApprovalMoneyApplyCmd param,
|
||||
UserBankWithdrawMoneyApplyDTO apply) {
|
||||
private void saveUserBankBalance(ApprovalMoneyApplyCmd param,
|
||||
UserBankWithdrawMoneyApplyDTO apply) {
|
||||
|
||||
// 非驳回则不做操作.
|
||||
if (!Objects
|
||||
@ -903,45 +998,55 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
return;
|
||||
}
|
||||
|
||||
// 非主播工资进 salary account账户
|
||||
if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) {
|
||||
SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd();
|
||||
String refundTrackId = WITHDRAWAL_REFUND_TRACK_PREFIX + apply.getId();
|
||||
|
||||
// 非主播工资进 salary account 账户;独立退款 trackId 不能与原 WITHDRAW 流水共用申请号.
|
||||
if (SalaryType.HOST_SALARY != SalaryType.of(apply.getAmountType())) {
|
||||
SalaryWithdrawInnerCmd withdrawInnerCmd = new SalaryWithdrawInnerCmd();
|
||||
withdrawInnerCmd.setUserId(apply.getSubmitUserId());
|
||||
withdrawInnerCmd.setOpUserId(param.getUpdateUser());
|
||||
withdrawInnerCmd.setSysOrigin(apply.getSysOrigin());
|
||||
withdrawInnerCmd.setRemark(param.getRemark());
|
||||
withdrawInnerCmd.setSalaryType(apply.getAmountType());
|
||||
withdrawInnerCmd.setAmount(apply.getAmount());
|
||||
userSalaryAccountClient.withdrawRefund(withdrawInnerCmd);
|
||||
return;
|
||||
withdrawInnerCmd.setSysOrigin(apply.getSysOrigin());
|
||||
withdrawInnerCmd.setRemark(param.getRemark());
|
||||
withdrawInnerCmd.setSalaryType(apply.getAmountType());
|
||||
withdrawInnerCmd.setAmount(apply.getAmount());
|
||||
withdrawInnerCmd.setTrackId(refundTrackId);
|
||||
ResponseAssert.requiredSuccess(userSalaryAccountClient.withdrawRefund(withdrawInnerCmd));
|
||||
// 驳回后用户应能重新提交;HOST 与其他工资类型都必须清理同一月度提现限制。
|
||||
ResponseAssert.requiredSuccess(
|
||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()));
|
||||
return;
|
||||
}
|
||||
|
||||
ResponseAssert.requiredSuccess(userBankBalanceClient.incr(
|
||||
new UserBankBalanceIncrCmd()
|
||||
.setUserId(apply.getSubmitUserId())
|
||||
.setSysOrigin(apply.getSysOrigin())
|
||||
.setAmount(PennyAmount.ofDollar(apply.getAmount())))
|
||||
);
|
||||
|
||||
bankRunningWaterClient.add(new UserBankRunningWaterDTO()
|
||||
.setId(IdWorkerUtils.getId())
|
||||
.setUserId(apply.getSubmitUserId())
|
||||
.setSysOrigin(apply.getSysOrigin())
|
||||
.setType(0)
|
||||
.setTrackId(apply.getId().toString())
|
||||
.setAmount(apply.getAmount())
|
||||
.setEvent(UserBankWaterEvent.WITHDRAW.name())
|
||||
.setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe())
|
||||
.setRemark(param.getRemark())
|
||||
.setBalance(
|
||||
ResponseAssert.requiredSuccess(
|
||||
userBankBalanceClient.getBalance(apply.getSubmitUserId())).getAccurateBalance())
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
.setCreateUser(param.getUpdateUser())
|
||||
.setCreateUserOrigin(1)
|
||||
);
|
||||
|
||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId());
|
||||
BankBalanceDTO refundedBalance = ResponseAssert.requiredSuccess(
|
||||
userBankBalanceClient.refundWithdrawalOnce(new UserBankWithdrawalRefundCmd()
|
||||
.setUserId(apply.getSubmitUserId())
|
||||
.setSysOrigin(apply.getSysOrigin())
|
||||
.setAmount(PennyAmount.ofDollar(apply.getAmount()))
|
||||
.setTrackId(refundTrackId)));
|
||||
ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, refundedBalance);
|
||||
|
||||
Boolean refundWaterExists = ResponseAssert.requiredSuccess(
|
||||
bankRunningWaterClient.existsTrackId(apply.getSubmitUserId(),
|
||||
UserBankWaterEvent.REFUND.name(), refundTrackId));
|
||||
if (!Boolean.TRUE.equals(refundWaterExists)) {
|
||||
// 余额退款标记先于流水;若插入失败,Admin 重试会跳过加钱并只补齐同一业务号的退款流水.
|
||||
ResponseAssert.requiredSuccess(bankRunningWaterClient.add(new UserBankRunningWaterDTO()
|
||||
.setId(IdWorkerUtils.getId())
|
||||
.setUserId(apply.getSubmitUserId())
|
||||
.setSysOrigin(apply.getSysOrigin())
|
||||
.setType(ReceiptType.INCOME.getType())
|
||||
.setTrackId(refundTrackId)
|
||||
.setAmount(apply.getAmount())
|
||||
.setEvent(UserBankWaterEvent.REFUND.name())
|
||||
.setEventDescribe(UserBankWaterEvent.REFUND.getDescribe())
|
||||
.setRemark(param.getRemark())
|
||||
.setBalance(refundedBalance.getAccurateBalance())
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
.setCreateUser(param.getUpdateUser())
|
||||
.setCreateUserOrigin(1)));
|
||||
}
|
||||
ResponseAssert.requiredSuccess(
|
||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()));
|
||||
}
|
||||
|
||||
private void saveUserBankDiamondBalance(ApprovalMoneyApplyCmd param,
|
||||
@ -978,6 +1083,7 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
||||
.setCreateUserOrigin(0)
|
||||
);
|
||||
|
||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId());
|
||||
ResponseAssert.requiredSuccess(
|
||||
userBankCardClient.removeMonthWithdrawal(apply.getSubmitUserId()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,10 +43,15 @@ management:
|
||||
enabled: false
|
||||
info:
|
||||
enabled: true
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
framework:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# 与 hyapp-server 的 Yumi withdrawal source callback_token 保持一致,只允许由生产运行环境注入。
|
||||
hyapp:
|
||||
withdrawal-review:
|
||||
callback-token: ${HYAPP_WITHDRAWAL_REVIEW_CALLBACK_TOKEN:}
|
||||
|
||||
framework:
|
||||
nacos:
|
||||
subscribeServices:
|
||||
- rc-auth
|
||||
@ -78,3 +83,5 @@ red-circle:
|
||||
- /console/datav/country-dashboard/game-metrics/games
|
||||
- /console/datav/country-dashboard/game-metrics/country-rank
|
||||
- /console/user/base/info/im/sig
|
||||
# 该入口不走 Console 登录态;控制器使用独立资金回调令牌做常量时间鉴权。
|
||||
- /console/integration/withdrawal-review/decision
|
||||
|
||||
@ -18,8 +18,9 @@ import com.red.circle.tool.core.regex.RegexConstant;
|
||||
import com.red.circle.tool.core.sequence.IdWorkerUtils;
|
||||
import com.red.circle.tool.core.text.StringUtils;
|
||||
import com.red.circle.tool.core.tuple.PennyAmount;
|
||||
import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO;
|
||||
import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd;
|
||||
import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO;
|
||||
import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd;
|
||||
import com.red.circle.wallet.app.scheduler.WithdrawalReviewSubmissionTask;
|
||||
import com.red.circle.wallet.domain.gateway.SalaryAccountGateway;
|
||||
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankRunningWater;
|
||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
||||
@ -40,21 +41,28 @@ import com.red.circle.wallet.inner.error.BankErrorCode;
|
||||
import com.red.circle.wallet.inner.error.WalletErrorCode;
|
||||
import com.red.circle.wallet.inner.model.enums.UserBankWaterEvent;
|
||||
import com.red.circle.wallet.inner.model.enums.WithdrawMethod;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 用户银行卡提现.
|
||||
*
|
||||
* @author pengliang on 2023/2/20
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UserBankWithdrawCmdExe {
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserBankWithdrawCmdExe {
|
||||
|
||||
private static final int PREPARING_RECOVERY_BATCH_SIZE = 50;
|
||||
private static final long PREPARING_RECOVERY_AGE_MS = 30_000L;
|
||||
|
||||
private final RedisService redisService;
|
||||
private final RegionConfigClient regionConfigClient;
|
||||
@ -64,97 +72,332 @@ public class UserBankWithdrawCmdExe {
|
||||
private final UserBankWithdrawMoneyApplyService userBankWithdrawMoneyApplyService;
|
||||
private final SalaryAccountGateway salaryAccountGateway;
|
||||
private final UserBankBalanceService userBankBalanceService;
|
||||
private final UserBankRunningWaterService userBankRunningWaterService;
|
||||
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
||||
private final UserBankRunningWaterService userBankRunningWaterService;
|
||||
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
||||
private final WithdrawalReviewSubmissionTask withdrawalReviewSubmissionTask;
|
||||
|
||||
|
||||
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, ArithmeticUtils.gtZero(cmd.getAmount()));
|
||||
|
||||
// 检查是否允许提现
|
||||
checkHostSalaryWithdrawEnabled(cmd);
|
||||
|
||||
// 上锁
|
||||
String key = "UBWithdraw:" + cmd.requiredReqUserId();
|
||||
ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60));
|
||||
|
||||
SalaryType salaryType = SalaryType.of(cmd.getSalaryType());
|
||||
|
||||
BigDecimal balance = getUserBalance(cmd, salaryType);
|
||||
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
||||
normalizeAndValidateAmount(cmd);
|
||||
|
||||
if (!withdrawalReviewSubmissionTask.shouldRouteNewApplication()) {
|
||||
// future cutover 前必须保持旧 SUBMIT writer 协议;否则滚动期间旧 Wallet 节点不认识 PREPARING,
|
||||
// 请求重试可能在旧节点创建第二个申请并扣款,随后新节点又恢复第一笔造成双扣.
|
||||
return executeLegacy(cmd);
|
||||
}
|
||||
|
||||
// 上锁
|
||||
String key = "UBWithdraw:" + cmd.requiredReqUserId();
|
||||
ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60));
|
||||
try {
|
||||
return executeLocked(cmd);
|
||||
} finally {
|
||||
// 任意余额、持久化或统一审核分支失败都必须释放用户锁,不能让异常路径等待 TTL 才恢复提现.
|
||||
redisService.unlock(key);
|
||||
}
|
||||
}
|
||||
|
||||
private UserBankWithdrawResultCO executeLegacy(UserBankWithdrawCmd cmd) {
|
||||
checkHostSalaryWithdrawEnabled(cmd);
|
||||
String key = "UBWithdraw:" + cmd.requiredReqUserId();
|
||||
ResponseAssert.isTrue(CommonErrorCode.REQUEST_LIMITING, redisService.lock(key, 60));
|
||||
try {
|
||||
SalaryType salaryType = SalaryType.of(cmd.getSalaryType());
|
||||
ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType);
|
||||
BigDecimal balance = getUserBalance(cmd, salaryType);
|
||||
ResponseAssert.isTrue(WalletErrorCode.INSUFFICIENT_BALANCE,
|
||||
ArithmeticUtils.gte(balance, cmd.getAmount()));
|
||||
|
||||
UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId());
|
||||
ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard);
|
||||
Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId());
|
||||
validateServiceCharge(serviceCharge);
|
||||
BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge);
|
||||
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
|
||||
ArithmeticUtils.gtZero(actualAmount));
|
||||
|
||||
Long applyId = IdWorkerUtils.getId();
|
||||
Timestamp now = TimestampUtils.now();
|
||||
UserBankWithdrawMoneyApply application = new UserBankWithdrawMoneyApply()
|
||||
.setId(applyId)
|
||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
||||
.setAcceptMethod(WithdrawMethod.ONESELF.name())
|
||||
.setSubmitUserId(cmd.requiredReqUserId())
|
||||
.setBankCardId(bankCard.getId())
|
||||
.setBankCardType(bankCard.getCardType())
|
||||
.setWithdrawAddress(bankCard.getCardNo())
|
||||
.setAmount(cmd.getAmount())
|
||||
.setBalanceBefore(balance)
|
||||
.setAmountType(salaryType.getCode())
|
||||
.setApprovalProcesses(List.of(
|
||||
new UserBankWithdrawMoneyApprovalProcess()
|
||||
.setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setRemark("Application submitted")
|
||||
.setCreateTime(now)))
|
||||
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setServiceCharge(serviceCharge)
|
||||
.setActualAmount(actualAmount)
|
||||
.setCreateTime(now)
|
||||
.setUpdateTime(now)
|
||||
.setCreateUser(cmd.requiredReqUserId())
|
||||
.setUpdateUser(cmd.requiredReqUserId())
|
||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||
.setUpdateUserOrigin(OpUserType.APP.getType());
|
||||
|
||||
BigDecimal afterBalance = changeBalance(application);
|
||||
ensureHostWithdrawalRunningWater(application, afterBalance);
|
||||
// 切流前仍直接生成旧 Console 可见的 SUBMIT 单,不写任何 externalReviewStatus.
|
||||
userBankWithdrawMoneyApplyService.add(application
|
||||
.setBalanceBefore(afterBalance.add(application.getAmount())));
|
||||
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
|
||||
return withdrawalResult(afterBalance, cmd.getAmount());
|
||||
} finally {
|
||||
// 原实现只在成功末尾解锁;这里补齐失败分支,避免滚动前旧流程异常后锁满 60 秒.
|
||||
redisService.unlock(key);
|
||||
}
|
||||
}
|
||||
|
||||
private UserBankWithdrawResultCO executeLocked(UserBankWithdrawCmd cmd) {
|
||||
|
||||
UserBankWithdrawMoneyApply preparing =
|
||||
userBankWithdrawMoneyApplyService.getLatestPreparingByUser(cmd.requiredReqUserId());
|
||||
if (preparing != null) {
|
||||
// 成功响应丢失或进程中断后的客户端重试续跑同一申请,绝不能重新生成 applyId 再扣一次.
|
||||
try {
|
||||
return completePreparedApplication(preparing);
|
||||
} catch (RuntimeException exception) {
|
||||
if (handlePreparationFailure(preparing, exception)) {
|
||||
throw exception;
|
||||
}
|
||||
// durable intent 仍可恢复时对 H5 表达“已受理”;失败响应会与 30 秒后自动扣款的事实冲突.
|
||||
return acceptedWithdrawalResult(preparing);
|
||||
}
|
||||
}
|
||||
|
||||
// 月度标记在准备单转 SUBMIT 前已经写入;响应丢失后的再次请求必须在后端阻断,而不能只依赖 H5 按钮状态.
|
||||
ResponseAssert.isFalse(CommonErrorCode.REQUEST_LIMITING,
|
||||
walletBankCacheService.checkMonthWithdrawal(cmd.requiredReqUserId()));
|
||||
|
||||
checkHostSalaryWithdrawEnabled(cmd);
|
||||
|
||||
SalaryType salaryType = SalaryType.of(cmd.getSalaryType());
|
||||
ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType);
|
||||
|
||||
BigDecimal balance = getUserBalance(cmd, salaryType);
|
||||
|
||||
// 3. 校验提现金额
|
||||
ResponseAssert.isTrue(WalletErrorCode.INSUFFICIENT_BALANCE, ArithmeticUtils.gte(balance, cmd.getAmount()));
|
||||
|
||||
UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId());
|
||||
ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard);
|
||||
|
||||
Long applyId = IdWorkerUtils.getId();
|
||||
|
||||
// 扣钱
|
||||
BigDecimal afterBalance = changeBalance(cmd, salaryType, applyId);
|
||||
|
||||
// 手续费
|
||||
Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId());
|
||||
BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge);
|
||||
|
||||
|
||||
if (SalaryType.HOST_SALARY == salaryType) {
|
||||
// 银行卡流水
|
||||
userBankRunningWaterService.add(
|
||||
new UserBankRunningWater()
|
||||
.setId(IdWorkerUtils.getId())
|
||||
.setUserId(cmd.requiredReqUserId())
|
||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
||||
.setType(ReceiptType.EXPENDITURE.getType())
|
||||
.setAmount(cmd.getAmount())
|
||||
.setServiceCharge(serviceCharge)
|
||||
.setActualAmount(actualAmount)
|
||||
.setEvent(UserBankWaterEvent.WITHDRAW.name())
|
||||
.setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe())
|
||||
.setRemark("Cash withdrawal")
|
||||
.setTrackId(applyId.toString())
|
||||
.setBalance(afterBalance)
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
.setUpdateTime(TimestampUtils.now())
|
||||
.setCreateUser(cmd.requiredReqUserId())
|
||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||
);
|
||||
}
|
||||
|
||||
// 申请详情
|
||||
userBankWithdrawMoneyApplyService.add(
|
||||
new UserBankWithdrawMoneyApply()
|
||||
.setId(applyId)
|
||||
UserBankCard bankCard = userBankCardService.getById(cmd.getAcceptBankCardId());
|
||||
ResponseAssert.notNull(BankErrorCode.BANK_CARD_NOT_FOUND, bankCard);
|
||||
|
||||
// 所有可能失败的区域/手续费读取都在扣款前完成,准备单一旦持久化便成为后续恢复的唯一业务事实.
|
||||
Long serviceCharge = getAssistDiamondConfig(cmd.requiredReqUserId());
|
||||
validateServiceCharge(serviceCharge);
|
||||
BigDecimal actualAmount = getActualAmount(cmd.getAmount(), serviceCharge);
|
||||
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
|
||||
ArithmeticUtils.gtZero(actualAmount));
|
||||
Long applyId = IdWorkerUtils.getId();
|
||||
Timestamp now = TimestampUtils.now();
|
||||
|
||||
// PREPARING 先于资金动作落库;进程即使在任意后续步骤退出,补偿任务也能按同一 applyId 幂等续跑.
|
||||
UserBankWithdrawMoneyApply withdrawalApply =
|
||||
new UserBankWithdrawMoneyApply()
|
||||
.setId(applyId)
|
||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
||||
.setAcceptMethod(WithdrawMethod.ONESELF.name())
|
||||
.setSubmitUserId(cmd.requiredReqUserId())
|
||||
.setBankCardId(bankCard.getId())
|
||||
.setBankCardType(bankCard.getCardType())
|
||||
.setAmount(cmd.getAmount())
|
||||
.setAmountType(salaryType.getCode())
|
||||
.setApprovalProcesses(List.of(
|
||||
new UserBankWithdrawMoneyApprovalProcess()
|
||||
.setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setRemark("Application submitted")
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
))
|
||||
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setServiceCharge(serviceCharge)
|
||||
.setActualAmount(actualAmount)
|
||||
.setCreateTime(TimestampUtils.now())
|
||||
.setUpdateTime(TimestampUtils.now())
|
||||
.setBankCardType(bankCard.getCardType())
|
||||
// 收款地址是申请时资金事实;用户之后修改或删除银行卡不能改变已进入审核的目标地址。
|
||||
.setWithdrawAddress(bankCard.getCardNo())
|
||||
.setAmount(cmd.getAmount())
|
||||
.setBalanceBefore(balance)
|
||||
.setAmountType(salaryType.getCode())
|
||||
.setApprovalProcesses(List.of(
|
||||
new UserBankWithdrawMoneyApprovalProcess()
|
||||
.setStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setRemark("Application submitted")
|
||||
.setCreateTime(now)
|
||||
))
|
||||
.setLatestApprovalStatus("PREPARING")
|
||||
.setServiceCharge(serviceCharge)
|
||||
.setActualAmount(actualAmount)
|
||||
.setCreateTime(now)
|
||||
.setUpdateTime(now)
|
||||
.setCreateUser(cmd.requiredReqUserId())
|
||||
.setUpdateUser(cmd.requiredReqUserId())
|
||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||
.setUpdateUserOrigin(OpUserType.APP.getType())
|
||||
);
|
||||
|
||||
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
|
||||
redisService.unlock(key);
|
||||
return new UserBankWithdrawResultCO()
|
||||
.setBankBalance(afterBalance)
|
||||
.setWithdrawAmount(Objects.toString(cmd.getAmount()));
|
||||
}
|
||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||
.setUpdateUserOrigin(OpUserType.APP.getType());
|
||||
if (withdrawalReviewSubmissionTask.shouldRouteNewApplication()) {
|
||||
// 只有切源开关开启后的新申请才归统一双审;预发布阶段和历史单仍由旧 Console 处理,避免两套入口同时有权审核。
|
||||
withdrawalApply.setExternalReviewStatus("PENDING")
|
||||
.setExternalReviewUpdatedTime(TimestampUtils.now());
|
||||
}
|
||||
userBankWithdrawMoneyApplyService.add(withdrawalApply);
|
||||
|
||||
try {
|
||||
return completePreparedApplication(withdrawalApply);
|
||||
} catch (RuntimeException exception) {
|
||||
if (handlePreparationFailure(withdrawalApply, exception)) {
|
||||
// 只有确认无扣款事实且准备单已终止,才能向客户端返回明确失败并允许下一笔申请.
|
||||
throw exception;
|
||||
}
|
||||
return acceptedWithdrawalResult(withdrawalApply);
|
||||
}
|
||||
}
|
||||
|
||||
private UserBankWithdrawResultCO completePreparedApplication(
|
||||
UserBankWithdrawMoneyApply application) {
|
||||
BigDecimal afterBalance = changeBalance(application);
|
||||
ensureHostWithdrawalRunningWater(application, afterBalance);
|
||||
// 月度限制必须晚于可靠扣款事实;余额不足等确定失败不能先把用户锁死一个月.
|
||||
walletBankCacheService.setMonthWithdrawal(application.getSubmitUserId());
|
||||
|
||||
// HOST 幂等 marker 与工资流水都会回放首次扣款后的精确余额;由此还原的才是资金动作发生前余额,
|
||||
// 而不是 PREPARING 落库时可能已被其他账变影响的早期查询值.
|
||||
BigDecimal balanceBefore = afterBalance.add(application.getAmount());
|
||||
boolean ready = userBankWithdrawMoneyApplyService.markReadyForReview(
|
||||
application.getId(), balanceBefore);
|
||||
if (!ready) {
|
||||
UserBankWithdrawMoneyApply latest =
|
||||
userBankWithdrawMoneyApplyService.getById(application.getId());
|
||||
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
|
||||
latest != null && Objects.equals(latest.getLatestApprovalStatus(),
|
||||
WithdrawMoneyApprovalProcessStatus.SUBMIT.name()));
|
||||
application = latest;
|
||||
} else {
|
||||
application.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||
.setBalanceBefore(balanceBefore)
|
||||
.setUpdateTime(TimestampUtils.now());
|
||||
}
|
||||
|
||||
// 首次网络失败只记录 FAILED,之后由统一补投任务重放同一来源申请号.
|
||||
withdrawalReviewSubmissionTask.submit(application);
|
||||
return withdrawalResult(afterBalance, application.getAmount());
|
||||
}
|
||||
|
||||
private void ensureHostWithdrawalRunningWater(UserBankWithdrawMoneyApply application,
|
||||
BigDecimal afterBalance) {
|
||||
if (SalaryType.HOST_SALARY != SalaryType.of(application.getAmountType())) {
|
||||
return;
|
||||
}
|
||||
String trackId = application.getId().toString();
|
||||
if (userBankRunningWaterService.existsTrackId(application.getSubmitUserId(),
|
||||
UserBankWaterEvent.WITHDRAW.name(), trackId)) {
|
||||
return;
|
||||
}
|
||||
// 流水在 PREPARING 内补齐并使用 partial unique 业务索引;崩溃重试不会重复写同一提现事实.
|
||||
userBankRunningWaterService.add(new UserBankRunningWater()
|
||||
.setId(IdWorkerUtils.getId())
|
||||
.setUserId(application.getSubmitUserId())
|
||||
.setSysOrigin(application.getSysOrigin())
|
||||
.setType(ReceiptType.EXPENDITURE.getType())
|
||||
.setAmount(application.getAmount())
|
||||
.setServiceCharge(application.getServiceCharge())
|
||||
.setActualAmount(application.getActualAmount())
|
||||
.setEvent(UserBankWaterEvent.WITHDRAW.name())
|
||||
.setEventDescribe(UserBankWaterEvent.WITHDRAW.getDescribe())
|
||||
.setRemark("Cash withdrawal")
|
||||
.setTrackId(trackId)
|
||||
.setBalance(afterBalance)
|
||||
.setCreateTime(application.getCreateTime())
|
||||
.setUpdateTime(TimestampUtils.now())
|
||||
.setCreateUser(application.getSubmitUserId())
|
||||
.setCreateUserOrigin(OpUserType.APP.getType()));
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${hyapp.withdrawal-review.creation-retry-delay-ms:10000}")
|
||||
public void retryPreparingApplications() {
|
||||
Timestamp olderThan = new Timestamp(
|
||||
System.currentTimeMillis() - PREPARING_RECOVERY_AGE_MS);
|
||||
for (UserBankWithdrawMoneyApply application :
|
||||
userBankWithdrawMoneyApplyService.listPreparingForRecovery(
|
||||
olderThan, PREPARING_RECOVERY_BATCH_SIZE)) {
|
||||
String lockKey = "UBWithdraw:" + application.getSubmitUserId();
|
||||
if (!redisService.lock(lockKey, 60)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
completePreparedApplication(application);
|
||||
} catch (Exception exception) {
|
||||
handlePreparationFailure(application, exception);
|
||||
// 结果未知时保持 PREPARING 使下一轮继续;不能删除准备单或改用新申请号补扣.
|
||||
log.error("Recover prepared withdrawal {} failed", application.getId(), exception);
|
||||
} finally {
|
||||
redisService.unlock(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UserBankWithdrawResultCO withdrawalResult(BigDecimal balance, BigDecimal amount) {
|
||||
return new UserBankWithdrawResultCO()
|
||||
.setBankBalance(balance)
|
||||
.setWithdrawAmount(Objects.toString(amount));
|
||||
}
|
||||
|
||||
private UserBankWithdrawResultCO acceptedWithdrawalResult(
|
||||
UserBankWithdrawMoneyApply application) {
|
||||
// H5 只消费 2xx 并展示“申请已提交”;这里保留原响应结构。初始快照仅用于兼容旧客户端展示,
|
||||
// 真正财务 balanceBefore 会在幂等扣款完成后由首次 post-balance 精确覆盖.
|
||||
BigDecimal expectedBalance = application.getBalanceBefore() == null ? null
|
||||
: application.getBalanceBefore().subtract(application.getAmount());
|
||||
return withdrawalResult(expectedBalance, application.getAmount());
|
||||
}
|
||||
|
||||
private boolean handlePreparationFailure(UserBankWithdrawMoneyApply application,
|
||||
Exception cause) {
|
||||
try {
|
||||
if (hasDebitFact(application)) {
|
||||
// 已存在扣款事实时只能继续补流水/投递,任何异常都不得把申请终止后允许用户再创建一笔.
|
||||
return false;
|
||||
}
|
||||
SalaryType salaryType = SalaryType.of(application.getAmountType());
|
||||
BigDecimal currentBalance = SalaryType.HOST_SALARY == salaryType
|
||||
? userBankBalanceService.getBalance(application.getSubmitUserId()).getAccurateBalance()
|
||||
: salaryAccountGateway.getAvailableBalance(application.getSubmitUserId(), salaryType)
|
||||
.getDollarAmount();
|
||||
if (currentBalance.compareTo(application.getAmount()) >= 0) {
|
||||
// 余额仍足够时更可能是数据库、Redis 或网络暂态;保留 PREPARING 由同申请号恢复.
|
||||
return false;
|
||||
}
|
||||
if (userBankWithdrawMoneyApplyService.markPreparationFailed(
|
||||
application.getId(), cause.getMessage())) {
|
||||
walletBankCacheService.removeMonthWithdrawal(application.getSubmitUserId());
|
||||
return true;
|
||||
}
|
||||
UserBankWithdrawMoneyApply latest =
|
||||
userBankWithdrawMoneyApplyService.getById(application.getId());
|
||||
return latest != null && "CREATE_FAILED".equals(latest.getLatestApprovalStatus());
|
||||
} catch (Exception classificationError) {
|
||||
// 无法可靠证明“未扣款且余额不足”时宁可保持 PREPARING,不能开放第二笔申请造成双扣风险.
|
||||
log.error("Classify prepared withdrawal {} failure failed; keep it recoverable",
|
||||
application.getId(), classificationError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasDebitFact(UserBankWithdrawMoneyApply application) {
|
||||
String trackId = application.getId().toString();
|
||||
SalaryType salaryType = SalaryType.of(application.getAmountType());
|
||||
return SalaryType.HOST_SALARY == salaryType
|
||||
? userBankBalanceService.hasWithdrawalDebit(application.getSubmitUserId(), trackId)
|
||||
: salaryAccountGateway.hasReceipt(trackId);
|
||||
}
|
||||
|
||||
private void normalizeAndValidateAmount(UserBankWithdrawCmd cmd) {
|
||||
BigDecimal normalized = null;
|
||||
try {
|
||||
normalized = cmd.getAmount().setScale(2, RoundingMode.UNNECESSARY);
|
||||
normalized.movePointRight(2).longValueExact();
|
||||
} catch (RuntimeException ignored) {
|
||||
// 统一落到标准参数错误;不能让 PennyAmount 四舍五入扣款后,Admin 又因原始高精度金额无法转 minor unit 而卡单.
|
||||
}
|
||||
boolean valid = normalized != null && ArithmeticUtils.gtZero(normalized)
|
||||
&& normalized.precision() - normalized.scale() <= 16;
|
||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, valid);
|
||||
cmd.setAmount(normalized);
|
||||
}
|
||||
|
||||
private void checkHostSalaryWithdrawEnabled(UserBankWithdrawCmd cmd) {
|
||||
if (SalaryType.HOST_SALARY != SalaryType.of(cmd.getSalaryType())) {
|
||||
@ -163,27 +406,31 @@ public class UserBankWithdrawCmdExe {
|
||||
hostSalaryWithdrawEnabledQryExe.check(cmd.requiredReqUserId());
|
||||
}
|
||||
|
||||
private BigDecimal changeBalance(UserBankWithdrawCmd cmd, SalaryType salaryType, Long applyId) {
|
||||
// 余额
|
||||
BigDecimal balance;
|
||||
|
||||
if (SalaryType.HOST_SALARY == salaryType) {
|
||||
BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.decr(cmd.requiredReqUserId(), PennyAmount.ofDollar(cmd.getAmount()));
|
||||
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO);
|
||||
balance = decrUserBankBalanceDTO.getAccurateBalance();
|
||||
} else {
|
||||
SalaryReceipt receipt = new SalaryReceipt()
|
||||
.setReceiptType(ReceiptType.EXPENDITURE)
|
||||
.setUserId(cmd.getReqUserId())
|
||||
.setAcceptUserId(cmd.getReqUserId())
|
||||
.setSysOrigin(cmd.getReqSysOrigin().getOrigin())
|
||||
.setSalaryType(salaryType)
|
||||
.setSalaryEvent(SalaryEvent.WITHDRAW)
|
||||
.setAmount(PennyAmount.ofDollar(cmd.getAmount()))
|
||||
.setTrackId(String.valueOf(applyId))
|
||||
.setEventDesc("提现")
|
||||
.setOpUserType(OpUserType.APP)
|
||||
.setOpUserId(cmd.getReqUserId());
|
||||
private BigDecimal changeBalance(UserBankWithdrawMoneyApply application) {
|
||||
// 余额
|
||||
BigDecimal balance;
|
||||
SalaryType salaryType = SalaryType.of(application.getAmountType());
|
||||
ResponseAssert.notNull(ResponseErrorCode.REQUEST_PARAMETER_ERROR, salaryType);
|
||||
|
||||
if (SalaryType.HOST_SALARY == salaryType) {
|
||||
BankBalanceDTO decrUserBankBalanceDTO = userBankBalanceService.withdrawOnce(
|
||||
application.getSubmitUserId(), PennyAmount.ofDollar(application.getAmount()),
|
||||
application.getId().toString());
|
||||
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, decrUserBankBalanceDTO);
|
||||
balance = decrUserBankBalanceDTO.getAccurateBalance();
|
||||
} else {
|
||||
SalaryReceipt receipt = new SalaryReceipt()
|
||||
.setReceiptType(ReceiptType.EXPENDITURE)
|
||||
.setUserId(application.getSubmitUserId())
|
||||
.setAcceptUserId(application.getSubmitUserId())
|
||||
.setSysOrigin(application.getSysOrigin())
|
||||
.setSalaryType(salaryType)
|
||||
.setSalaryEvent(SalaryEvent.WITHDRAW)
|
||||
.setAmount(PennyAmount.ofDollar(application.getAmount()))
|
||||
.setTrackId(String.valueOf(application.getId()))
|
||||
.setEventDesc("提现")
|
||||
.setOpUserType(OpUserType.APP)
|
||||
.setOpUserId(application.getSubmitUserId());
|
||||
SalaryReceiptRes salaryReceiptRes = salaryAccountGateway.changeBalance(receipt);
|
||||
ResponseAssert.notNull(WalletErrorCode.INSUFFICIENT_BALANCE, salaryReceiptRes);
|
||||
|
||||
@ -202,11 +449,17 @@ public class UserBankWithdrawCmdExe {
|
||||
return balance;
|
||||
}
|
||||
|
||||
private BigDecimal getActualAmount(BigDecimal amount, Long serviceCharge) {
|
||||
private BigDecimal getActualAmount(BigDecimal amount, Long serviceCharge) {
|
||||
BigDecimal charge = new BigDecimal(serviceCharge).divide(new BigDecimal(100));
|
||||
BigDecimal serviceChargeAmount = amount.multiply(charge).setScale(0, RoundingMode.CEILING);
|
||||
return amount.subtract(serviceChargeAmount);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateServiceCharge(Long serviceCharge) {
|
||||
// 配置值语义是百分数;超过 100% 会产生零或负打款额,必须在落申请和扣款前阻断.
|
||||
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
|
||||
serviceCharge != null && serviceCharge >= 0 && serviceCharge <= 100);
|
||||
}
|
||||
|
||||
private Long getAssistDiamondConfig(Long userId) {
|
||||
|
||||
|
||||
@ -55,7 +55,8 @@ public class SalaryAccountConvertor {
|
||||
.setSalaryType(SalaryType.of(cmd.getSalaryType()))
|
||||
.setSalaryEvent(SalaryEvent.WITHDRAW_REFUND)
|
||||
.setAmount(PennyAmount.ofDollar(cmd.getAmount()))
|
||||
.setTrackId(IdWorkerUtils.getIdStr())
|
||||
// 退款流水使用原提现申请号;Admin 重试同一终态时不能生成第二个资金业务号。
|
||||
.setTrackId(cmd.getTrackId())
|
||||
.setEventDesc("提现退回")
|
||||
.setRemark(cmd.getRemark())
|
||||
.setOpUserType(OpUserType.APP)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,9 +13,14 @@ import lombok.EqualsAndHashCode;
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class SalaryWithdrawCmd extends AppExtCommand {
|
||||
|
||||
/**
|
||||
public class SalaryWithdrawCmd extends AppExtCommand {
|
||||
|
||||
/**
|
||||
* 来源业务号;提现驳回退款沿用原申请 ID,确保跨服务重试能识别同一笔资金动作.
|
||||
*/
|
||||
private String trackId;
|
||||
|
||||
/**
|
||||
* 工资类型.
|
||||
*/
|
||||
@NotBlank(message = "salaryType required.")
|
||||
|
||||
@ -34,5 +34,10 @@ public interface SalaryAccountGateway {
|
||||
* @param receipt 工资凭证
|
||||
* @return 凭证响应
|
||||
*/
|
||||
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
|
||||
}
|
||||
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
|
||||
|
||||
/**
|
||||
* 判断稳定业务号对应的工资资金事实是否已提交;用于崩溃恢复时区分已扣款与确定未扣款.
|
||||
*/
|
||||
boolean hasReceipt(String trackId);
|
||||
}
|
||||
|
||||
@ -4,7 +4,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.data.annotation.Id;
|
||||
@ -51,7 +52,19 @@ public class UserBankBalance implements Serializable {
|
||||
* 余额.
|
||||
*/
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long balance;
|
||||
private Long balance;
|
||||
|
||||
/**
|
||||
* 提现退款幂等事实,key 为稳定业务号摘要,value 为首次退款后的余额(分).
|
||||
* 余额增量和该快照由同一个 Mongo 更新管道原子写入,重放既不会重复加钱,也不会用更晚的余额污染退款流水.
|
||||
*/
|
||||
private Map<String, Long> processedWithdrawalRefundBalances;
|
||||
|
||||
/**
|
||||
* 提现扣款幂等事实,key 为申请号摘要,value 为首次扣款后的余额(分).
|
||||
* 准备单恢复只回读该快照,不能因进程中断重复扣减或把后续入账后的余额写进原提现流水.
|
||||
*/
|
||||
private Map<String, Long> processedWithdrawalDebitBalances;
|
||||
|
||||
/**
|
||||
* 创建时间.
|
||||
|
||||
@ -48,7 +48,12 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
||||
/**
|
||||
* 银行卡类型.
|
||||
*/
|
||||
private String bankCardType;
|
||||
private String bankCardType;
|
||||
|
||||
/**
|
||||
* 申请时的收款地址快照;可靠投递只读该字段,不能在重试时回查可变银行卡资料.
|
||||
*/
|
||||
private String withdrawAddress;
|
||||
|
||||
/**
|
||||
* + 接收方式 com.sugartime.app.mongo.user.dto.WithdrawMethod;
|
||||
@ -64,7 +69,12 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
||||
/**
|
||||
* 提交金额.
|
||||
*/
|
||||
private BigDecimal amount;
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* 提交提现前的可用余额快照;这是申请时财务事实,不能用审核或重试时的当前余额替代.
|
||||
*/
|
||||
private BigDecimal balanceBefore;
|
||||
|
||||
/**
|
||||
* 金额类型:MONEY(美金政策)、SALARY_DIAMOND(工资钻石政策)
|
||||
@ -90,7 +100,42 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
||||
/**
|
||||
* WithdrawMoneyApprovalProcessStatus SUBMIT:提交状态,PASS:审核通过,NOT_PASS:审核不通过; 最新审核状态.
|
||||
*/
|
||||
private String latestApprovalStatus;
|
||||
private String latestApprovalStatus;
|
||||
|
||||
/**
|
||||
* 统一提现双审投递状态:PENDING/FAILED/SUBMITTED.
|
||||
*/
|
||||
private String externalReviewStatus;
|
||||
|
||||
/**
|
||||
* hyapp-server 生成的统一审核申请 ID;来源申请 ID 仍使用当前文档 id 保证提交幂等.
|
||||
*/
|
||||
private String externalReviewApplicationId;
|
||||
|
||||
/**
|
||||
* Admin 终态回调命令号;同一命令允许重试,其他命令不能覆盖既有资金终态.
|
||||
*/
|
||||
private String externalReviewDecisionCommandId;
|
||||
|
||||
/**
|
||||
* 驳回退款状态:PENDING/COMPLETED;与 NOT_PASS 终态分离,退款失败后仍可沿原命令补偿.
|
||||
*/
|
||||
private String externalReviewRefundStatus;
|
||||
|
||||
/**
|
||||
* 最近一次投递错误,仅供排障,不参与提现和退款的业务判断.
|
||||
*/
|
||||
private String externalReviewLastError;
|
||||
|
||||
/**
|
||||
* 创建阶段的终止原因;只有确认资金事实不存在时才能从 PREPARING 转 CREATE_FAILED.
|
||||
*/
|
||||
private String preparationError;
|
||||
|
||||
/**
|
||||
* 最近一次统一审核投递状态更新时间.
|
||||
*/
|
||||
private Timestamp externalReviewUpdatedTime;
|
||||
|
||||
/**
|
||||
* 结算结果;
|
||||
|
||||
@ -32,7 +32,13 @@ public interface UserBankBalanceService {
|
||||
* @param amount 金额
|
||||
* @return 是否操作成功
|
||||
*/
|
||||
boolean incr(Long userId, String sysOrigin, PennyAmount amount);
|
||||
boolean incr(Long userId, String sysOrigin, PennyAmount amount);
|
||||
|
||||
/**
|
||||
* 按稳定业务号原子退款;已经处理过的 trackId 返回首次退款后的余额快照而不重复入账.
|
||||
*/
|
||||
BankBalanceDTO refundWithdrawalOnce(Long userId, String sysOrigin, PennyAmount amount,
|
||||
String trackId);
|
||||
|
||||
/**
|
||||
* 类型消费累计.
|
||||
@ -41,7 +47,17 @@ public interface UserBankBalanceService {
|
||||
* @param amount 金额
|
||||
* @return 是否操作成功
|
||||
*/
|
||||
BankBalanceDTO decr(Long userId, PennyAmount amount);
|
||||
BankBalanceDTO decr(Long userId, PennyAmount amount);
|
||||
|
||||
/**
|
||||
* 按提现申请号原子扣款;同一 trackId 重放返回首次扣款后的余额快照,不再重复扣减.
|
||||
*/
|
||||
BankBalanceDTO withdrawOnce(Long userId, PennyAmount amount, String trackId);
|
||||
|
||||
/**
|
||||
* 判断申请号是否已经完成 HOST 余额扣款;只用于区分确定未扣款的业务失败与结果未知的暂态错误.
|
||||
*/
|
||||
boolean hasWithdrawalDebit(Long userId, String trackId);
|
||||
|
||||
/**
|
||||
* 获取余额.
|
||||
|
||||
@ -2,8 +2,10 @@ package com.red.circle.wallet.infra.database.mongo.service.bank;
|
||||
|
||||
|
||||
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@ -40,11 +42,51 @@ public interface UserBankWithdrawMoneyApplyService {
|
||||
/**
|
||||
* 获得记录.
|
||||
*/
|
||||
UserBankWithdrawMoneyApply getById(Long id);
|
||||
UserBankWithdrawMoneyApply getById(Long id);
|
||||
|
||||
/**
|
||||
* 返回用户尚未完成扣款状态机的准备单,客户端重试必须续跑同一申请而不是生成第二次扣款.
|
||||
*/
|
||||
UserBankWithdrawMoneyApply getLatestPreparingByUser(Long userId);
|
||||
|
||||
/**
|
||||
* 有界扫描超时准备单,恢复进程中断在“申请已持久化、资金状态未收口”之间的请求.
|
||||
*/
|
||||
List<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;
|
||||
|
||||
import com.mongodb.client.model.Filters;
|
||||
import com.mongodb.client.model.Filters;
|
||||
import com.mongodb.client.model.FindOneAndUpdateOptions;
|
||||
import com.mongodb.client.model.ReturnDocument;
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import com.red.circle.component.mongodb.config.MongoPrimaryService;
|
||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
@ -13,7 +15,12 @@ import com.red.circle.wallet.inner.model.dto.BankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@ -21,6 +28,7 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.bson.Document;
|
||||
import org.bson.conversions.Bson;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.mongodb.core.aggregation.Aggregation;
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||
@ -47,7 +55,7 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
||||
private final MongoPrimaryService mongoPrimaryService;
|
||||
|
||||
@Override
|
||||
public boolean incr(Long userId, String sysOrigin, PennyAmount amount) {
|
||||
public boolean incr(Long userId, String sysOrigin, PennyAmount amount) {
|
||||
|
||||
if (existsPrimary(userId)) {
|
||||
UpdateResult updateResult = mongoTemplate.updateFirst(
|
||||
@ -73,7 +81,7 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO decr(Long userId, PennyAmount amount) {
|
||||
public BankBalanceDTO decr(Long userId, PennyAmount amount) {
|
||||
Query query = Query.query(Criteria.where("id").is(userId));
|
||||
FindAndModifyOptions options = new FindAndModifyOptions();
|
||||
options.returnNew(true);
|
||||
@ -85,8 +93,21 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
||||
.set("updateTime", TimestampUtils.now())
|
||||
, options
|
||||
, UserBankBalance.class);
|
||||
return Objects.isNull(bankBalance) ? null : BankBalanceDTO.of(bankBalance.getBalance());
|
||||
}
|
||||
return Objects.isNull(bankBalance) ? null : BankBalanceDTO.of(bankBalance.getBalance());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO withdrawOnce(Long userId, PennyAmount amount, String trackId) {
|
||||
return mutateWithdrawalBalanceOnce(userId, amount.getPennyAmount(), trackId,
|
||||
false, "processedWithdrawalDebitBalances", "consumptionPoints");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasWithdrawalDebit(Long userId, String trackId) {
|
||||
String markerField = mutationMarkerField("processedWithdrawalDebitBalances", trackId);
|
||||
return mongoTemplate.exists(Query.query(Criteria.where("id").is(userId)
|
||||
.and(markerField).exists(true)), UserBankBalance.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO getBalance(Long userId) {
|
||||
@ -96,6 +117,71 @@ public class UserBankBalanceServiceImpl implements UserBankBalanceService {
|
||||
: getLongValue(documents.get(0), "balance")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO refundWithdrawalOnce(Long userId, String sysOrigin, PennyAmount amount,
|
||||
String trackId) {
|
||||
return mutateWithdrawalBalanceOnce(userId, amount.getPennyAmount(), trackId,
|
||||
true, "processedWithdrawalRefundBalances", "earnPoints");
|
||||
}
|
||||
|
||||
private BankBalanceDTO mutateWithdrawalBalanceOnce(Long userId, long amount, String trackId,
|
||||
boolean income, String markerMap, String cumulativeField) {
|
||||
String markerKey = mutationMarkerKey(trackId);
|
||||
String markerField = markerMap + "." + markerKey;
|
||||
long delta = income ? amount : -amount;
|
||||
|
||||
Document filter = new Document("_id", userId)
|
||||
.append(markerField, new Document("$exists", false));
|
||||
if (!income) {
|
||||
// 条件扣款与幂等快照在同一个原子 findOneAndUpdate 中完成,不能先查余额再扣款.
|
||||
filter.append("balance", new Document("$gte", amount));
|
||||
}
|
||||
|
||||
Document newBalance = new Document("$add", List.of("$balance", delta));
|
||||
Document newCumulative = new Document("$add", List.of(
|
||||
new Document("$ifNull", List.of("$" + cumulativeField, 0L)), amount));
|
||||
Document setFields = new Document("balance", newBalance)
|
||||
.append(cumulativeField, newCumulative)
|
||||
// 同一 $set 阶段都读取更新前的 balance,因此 marker 精确等于本次资金动作后的余额.
|
||||
.append(markerField, newBalance)
|
||||
// 这里直接调用 Mongo Driver,使用 BSON 原生 Date,避免绕过 MongoTemplate 转换器后无法编码 java.sql.Timestamp.
|
||||
.append("updateTime", new Date(TimestampUtils.now().getTime()));
|
||||
List<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) {
|
||||
Object value = document.get(key);
|
||||
if (Objects.isNull(value)) {
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
package com.red.circle.wallet.infra.database.mongo.service.bank.impl;
|
||||
|
||||
package com.red.circle.wallet.infra.database.mongo.service.bank.impl;
|
||||
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
import com.red.circle.tool.core.date.TimestampUtils;
|
||||
import com.red.circle.tool.core.date.ZonedDateTimeUtils;
|
||||
import com.red.circle.tool.core.text.StringUtils;
|
||||
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply;
|
||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankWithdrawMoneyApplyService;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankApprovalMoneyApplyCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawMoneyApplyQryCmd;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@ -97,9 +100,18 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
||||
criteria.and("amountType").ne("SALARY_DIAMOND");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(query.getLatestApprovalStatus())) {
|
||||
criteria.and("latestApprovalStatus").is(query.getLatestApprovalStatus());
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getLatestApprovalStatus())) {
|
||||
criteria.and("latestApprovalStatus").is(query.getLatestApprovalStatus());
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(query.getLegacyReviewOnly())) {
|
||||
// $nin 会保留历史缺字段申请,同时排除本版本明确交给 HyApp 的全部非终态投递状态.
|
||||
criteria.and("externalReviewStatus").nin("PENDING", "FAILED", "SUBMITTED");
|
||||
if (StringUtils.isBlank(query.getLatestApprovalStatus())) {
|
||||
// PREPARING 是资金状态机内部态,扣款和流水未收口前绝不能泄露给人工审核.
|
||||
criteria.and("latestApprovalStatus").in("SUBMIT", "PASS", "NOT_PASS");
|
||||
}
|
||||
}
|
||||
|
||||
if (Objects.nonNull(query.getStartTime()) && Objects.nonNull(query.getEndTime())) {
|
||||
criteria.andOperator(
|
||||
@ -115,25 +127,141 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserBankWithdrawMoneyApply getById(Long id) {
|
||||
public UserBankWithdrawMoneyApply getById(Long id) {
|
||||
|
||||
return mongoTemplate.findOne(Query.query(Criteria.where("id").is(id)),
|
||||
UserBankWithdrawMoneyApply.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||
|
||||
mongoTemplate.updateFirst(Query.query(Criteria.where("id").in(apply.getId())),
|
||||
new Update()
|
||||
.set("updateTime", TimestampUtils.now())
|
||||
.set("updateUser", apply.getUpdateUser())
|
||||
.set("updateUserOrigin", apply.getUpdateUserOrigin())
|
||||
.set("latestApprovalStatus", apply.getLatestApprovalStatus())
|
||||
.set("settlementResult", apply.getSettlementResult())
|
||||
.push("approvalProcesses", apply.getProcess()),
|
||||
UserBankWithdrawMoneyApply.class);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserBankWithdrawMoneyApply> listPendingExternalReview(int limit) {
|
||||
// 只扫描仍在等待审核且未成功投递的申请,并限制单批上限;按最近尝试时间轮转,避免一个永久坏单占住批次饿死新申请。
|
||||
Criteria criteria = Criteria.where("latestApprovalStatus").is("SUBMIT")
|
||||
// 只补投本版本明确写入 outbox 状态的新申请;历史缺字段 SUBMIT 单继续留在旧 Console,不能在开关开启时批量迁移。
|
||||
.and("externalReviewStatus").in("PENDING", "FAILED");
|
||||
return mongoTemplate.find(Query.query(criteria)
|
||||
.with(Sort.by(Sort.Order.asc("externalReviewUpdatedTime"),
|
||||
Sort.Order.asc("createTime")))
|
||||
.limit(Math.max(1, Math.min(limit, 100))),
|
||||
UserBankWithdrawMoneyApply.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserBankWithdrawMoneyApply getLatestPreparingByUser(Long userId) {
|
||||
return mongoTemplate.findOne(Query.query(Criteria.where("submitUserId").is(userId)
|
||||
.and("latestApprovalStatus").is("PREPARING"))
|
||||
.with(Sort.by(Sort.Order.desc("createTime"))), UserBankWithdrawMoneyApply.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<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,8 +22,7 @@ import java.math.BigDecimal;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
@ -49,10 +48,20 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SalaryReceiptRes changeBalance(SalaryReceipt receipt) {
|
||||
if (!ValidatorUtils.validateFastPass(receipt)) {
|
||||
throw new IllegalArgumentException("changeBalance required param error.");
|
||||
}
|
||||
public SalaryReceiptRes changeBalance(SalaryReceipt receipt) {
|
||||
if (!ValidatorUtils.validateFastPass(receipt)) {
|
||||
throw new IllegalArgumentException("changeBalance required param error.");
|
||||
}
|
||||
|
||||
// 统一审核可能在退款已提交、响应或完成标记丢失后重试;先查稳定 trackId,避免再次修改账户余额。
|
||||
SalaryAccountRunningWater existingReceipt = receipt.getTrackId() == null
|
||||
|| receipt.getTrackId().isBlank()
|
||||
? null
|
||||
: getRunningWaterByTrackId(receipt.getTrackId());
|
||||
if (existingReceipt != null) {
|
||||
validateIdempotentReceipt(existingReceipt, receipt);
|
||||
return toExistingReceiptResult(existingReceipt, receipt);
|
||||
}
|
||||
|
||||
// 检查账户是否存在,不存在则初始化
|
||||
SalaryAccountBalance balance = salaryAccountBalanceService.getByUserIdAndType(
|
||||
@ -91,27 +100,10 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
||||
|
||||
// 插入流水记录
|
||||
Long recordId = IdWorkerUtils.getId();
|
||||
try {
|
||||
SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId,
|
||||
newBalance, newAvailableBalance);
|
||||
salaryAccountRunningWaterService.save(runningWater);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 幂等性:trackId重复,查询已有记录
|
||||
LambdaQueryWrapper<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;
|
||||
}
|
||||
SalaryAccountRunningWater runningWater = buildRunningWater(receipt, recordId,
|
||||
newBalance, newAvailableBalance);
|
||||
// track_id 唯一冲突必须向外抛出,让 @Transactional 回滚本次余额增量;调用方随后用同一命令重试并回读赢家流水.
|
||||
salaryAccountRunningWaterService.save(runningWater);
|
||||
|
||||
return SalaryReceiptRes.of(
|
||||
recordId,
|
||||
@ -119,9 +111,50 @@ public class SalaryAccountGatewayImpl implements SalaryAccountGateway {
|
||||
PennyAmount.ofPenny(newAvailableBalance),
|
||||
receipt.getAmount()
|
||||
);
|
||||
}
|
||||
|
||||
private SalaryAccountRunningWater buildRunningWater(SalaryReceipt receipt, Long recordId,
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasReceipt(String trackId) {
|
||||
return trackId != null && !trackId.isBlank() && getRunningWaterByTrackId(trackId) != null;
|
||||
}
|
||||
|
||||
private SalaryAccountRunningWater getRunningWaterByTrackId(String trackId) {
|
||||
LambdaQueryWrapper<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,
|
||||
Long balance, Long availableBalance) {
|
||||
SalaryAccountRunningWater water = new SalaryAccountRunningWater()
|
||||
.setId(recordId)
|
||||
|
||||
@ -51,9 +51,11 @@ public class SalaryAccountInnerConvertor {
|
||||
if (Objects.isNull(innerCmd)) {
|
||||
return null;
|
||||
}
|
||||
SalaryWithdrawCmd cmd = new SalaryWithdrawCmd();
|
||||
cmd.setAmount(innerCmd.getAmount());
|
||||
cmd.setRemark(innerCmd.getRemark());
|
||||
SalaryWithdrawCmd cmd = new SalaryWithdrawCmd();
|
||||
cmd.setAmount(innerCmd.getAmount());
|
||||
// 外部审核重试必须沿用来源提现号;不能在转换层丢失退款幂等键。
|
||||
cmd.setTrackId(innerCmd.getTrackId());
|
||||
cmd.setRemark(innerCmd.getRemark());
|
||||
cmd.setSalaryType(innerCmd.getSalaryType());
|
||||
|
||||
// 设置请求来源信息
|
||||
|
||||
@ -97,10 +97,21 @@ public class UserBankRunningWaterClientEndpoint implements UserBankRunningWaterC
|
||||
|
||||
|
||||
@Override
|
||||
public ResultResponse<Void> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||
userBankRunningWaterClientService.approvalMoneyApply(apply);
|
||||
return ResultResponse.success();
|
||||
}
|
||||
public ResultResponse<Void> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||
userBankRunningWaterClientService.approvalMoneyApply(apply);
|
||||
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
|
||||
public ResultResponse<List<UserBankRunningWaterDTO>> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
||||
|
||||
@ -5,7 +5,8 @@ import com.red.circle.wallet.infra.database.rds.service.WalletGoldBalanceService
|
||||
import com.red.circle.wallet.inner.endpoint.bank.api.UserBankBalanceClientApi;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd;
|
||||
import com.red.circle.wallet.inner.model.dto.BankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||
@ -38,9 +39,14 @@ public class UserBankBalanceClientEndpoint implements UserBankBalanceClientApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultResponse<Boolean> incr(UserBankBalanceIncrCmd cmd) {
|
||||
return ResultResponse.success(userBankBalanceClientService.incr(cmd));
|
||||
}
|
||||
public ResultResponse<Boolean> incr(UserBankBalanceIncrCmd cmd) {
|
||||
return ResultResponse.success(userBankBalanceClientService.incr(cmd));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultResponse<BankBalanceDTO> refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd) {
|
||||
return ResultResponse.success(userBankBalanceClientService.refundWithdrawalOnce(cmd));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultResponse<BankBalanceDTO> decr(UserBankBalanceDecrCmd cmd) {
|
||||
|
||||
@ -73,7 +73,11 @@ public interface UserBankRunningWaterClientService {
|
||||
/**
|
||||
* 审核申请记录.
|
||||
*/
|
||||
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
||||
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
||||
|
||||
boolean approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply);
|
||||
|
||||
boolean markExternalReviewRefundCompleted(Long id, String commandId);
|
||||
|
||||
/**
|
||||
* 月用户工资流水.
|
||||
|
||||
@ -93,9 +93,19 @@ public class UserBankRunningWaterClientServiceImpl implements UserBankRunningWat
|
||||
}
|
||||
|
||||
@Override
|
||||
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||
bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
|
||||
}
|
||||
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||
bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean approvalMoneyApplyCas(UserBankApprovalMoneyApplyCmd apply) {
|
||||
return bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markExternalReviewRefundCompleted(Long id, String commandId) {
|
||||
return bankWithdrawMoneyApplyService.markExternalReviewRefundCompleted(id, commandId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserBankRunningWaterDTO> getMonthSalaryRunningWater(List<Long> userIds, String event, String month) {
|
||||
|
||||
@ -2,7 +2,8 @@ package com.red.circle.wallet.inner.service.wallet;
|
||||
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd;
|
||||
import com.red.circle.wallet.inner.model.dto.BankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||
@ -32,7 +33,9 @@ public interface UserBankBalanceClientService {
|
||||
*
|
||||
* @return 是否操作成功
|
||||
*/
|
||||
boolean incr(UserBankBalanceIncrCmd cmd);
|
||||
boolean incr(UserBankBalanceIncrCmd cmd);
|
||||
|
||||
BankBalanceDTO refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd);
|
||||
|
||||
/**
|
||||
* 类型消费累计.
|
||||
|
||||
@ -3,7 +3,8 @@ package com.red.circle.wallet.inner.service.wallet.impl;
|
||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceDecrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceIncrCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankBalanceQryCmd;
|
||||
import com.red.circle.wallet.inner.model.cmd.UserBankWithdrawalRefundCmd;
|
||||
import com.red.circle.wallet.inner.model.dto.BankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceDTO;
|
||||
import com.red.circle.wallet.inner.model.dto.UserBankBalanceStatisticsDTO;
|
||||
@ -34,9 +35,15 @@ public class UserBankBalanceClientServiceImpl implements UserBankBalanceClientSe
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean incr(UserBankBalanceIncrCmd cmd) {
|
||||
return userBankBalanceService.incr(cmd.getUserId(), cmd.getSysOrigin(), cmd.getAmount());
|
||||
}
|
||||
public boolean incr(UserBankBalanceIncrCmd cmd) {
|
||||
return userBankBalanceService.incr(cmd.getUserId(), cmd.getSysOrigin(), cmd.getAmount());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO refundWithdrawalOnce(UserBankWithdrawalRefundCmd cmd) {
|
||||
return userBankBalanceService.refundWithdrawalOnce(cmd.getUserId(), cmd.getSysOrigin(),
|
||||
cmd.getAmount(), cmd.getTrackId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankBalanceDTO decr(UserBankBalanceDecrCmd cmd) {
|
||||
|
||||
@ -26,7 +26,19 @@ management:
|
||||
enabled: false
|
||||
info:
|
||||
enabled: true
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# 默认关闭,生产只通过运行环境开启并注入双审地址/令牌,避免本地申请误投真实财务工作台。
|
||||
hyapp:
|
||||
withdrawal-review:
|
||||
enabled: ${HYAPP_WITHDRAWAL_REVIEW_ENABLED:false}
|
||||
submit-url: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_URL:}
|
||||
submit-token: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_TOKEN:}
|
||||
app-code: ${HYAPP_WITHDRAWAL_REVIEW_APP_CODE:yumi}
|
||||
source-system: ${HYAPP_WITHDRAWAL_REVIEW_SOURCE_SYSTEM:chatapp3}
|
||||
# 双节点必须先完成同一版本滚动,再使用未来 UTC epoch ms 同时切换审核 owner;0 仅预部署、不切流。
|
||||
cutover-at-ms: ${HYAPP_WITHDRAWAL_REVIEW_CUTOVER_AT_MS:0}
|
||||
request-timeout-ms: ${HYAPP_WITHDRAWAL_REVIEW_TIMEOUT_MS:5000}
|
||||
retry-delay-ms: ${HYAPP_WITHDRAWAL_REVIEW_RETRY_DELAY_MS:30000}
|
||||
creation-retry-delay-ms: ${HYAPP_WITHDRAWAL_CREATION_RETRY_DELAY_MS:10000}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user