feat(withdrawal): route Yumi reviews through HyApp

This commit is contained in:
hy001 2026-07-20 15:46:35 +08:00
parent 69300b45f6
commit 4ebfb3cc6b
32 changed files with 1541 additions and 329 deletions

View File

@ -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);
/**
* 类型消费累计.

View File

@ -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);
/**
* 月用户工资流水

View File

@ -44,6 +44,11 @@ public class ApprovalMoneyApplyCmd extends CommonCommand {
*/
private String remark;
private Long updateUser;
}
private Long updateUser;
/**
* 统一审核终态命令号普通旧 Console 审核为空外部双审回调必须携带并持久化.
*/
private String externalReviewCommandId;
}

View File

@ -46,7 +46,12 @@ public class UserBankApprovalMoneyApplyCmd extends CommonCommand {
*/
private Long updateUser;
private Integer updateUserOrigin;
}
private Integer updateUserOrigin;
/**
* 统一审核终态命令号用于区分同命令补偿与另一个审核动作.
*/
private String externalReviewCommandId;
}

View File

@ -73,6 +73,11 @@ public class UserBankWithdrawMoneyApplyQryCmd implements Serializable {
/**
* 金额类型MONEY(美金政策)SALARY_DIAMOND(工资钻石政策)
*/
private String amountType;
}
private String amountType;
/**
* 仅查询仍由旧 Console 审核的申请统一双审托管单不能继续出现在旧审核入口.
*/
private Boolean legacyReviewOnly;
}

View File

@ -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;
}

View File

@ -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;
/**
* 创建时间.

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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()));
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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.")

View File

@ -34,5 +34,10 @@ public interface SalaryAccountGateway {
* @param receipt 工资凭证
* @return 凭证响应
*/
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
}
SalaryReceiptRes changeBalance(SalaryReceipt receipt);
/**
* 判断稳定业务号对应的工资资金事实是否已提交用于崩溃恢复时区分已扣款与确定未扣款.
*/
boolean hasReceipt(String trackId);
}

View File

@ -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;
/**
* 创建时间.

View File

@ -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;
/**
* 结算结果;

View File

@ -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);
/**
* 获取余额.

View File

@ -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);
}

View File

@ -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)) {

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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());
// 设置请求来源信息

View File

@ -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) {

View File

@ -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) {

View File

@ -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);
/**
* 月用户工资流水.

View File

@ -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) {

View File

@ -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);
/**
* 类型消费累计.

View File

@ -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) {

View File

@ -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 同时切换审核 owner0 仅预部署、不切流。
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}