feat(wallet): route user withdrawals through dual review

This commit is contained in:
zhx 2026-07-17 18:38:10 +08:00
parent e25aed3085
commit f49e897431
14 changed files with 395 additions and 18 deletions

View File

@ -76,7 +76,7 @@ public interface UserBankRunningWaterClientApi {
ResultResponse<UserBankWithdrawMoneyApplyDTO> getWithdrawMoneyApplyById(@RequestParam("id") Long id);
@PostMapping("/approvalMoneyApply")
ResultResponse<Void> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
ResultResponse<Boolean> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
/**
* 月用户工资流水

View File

@ -0,0 +1,104 @@
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 统一提现双审对 Likei 的终态回调入口.
*/
@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())
.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;
@NotBlank
private String commandId;
}
}

View File

@ -784,13 +784,19 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
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()));
if (!Objects.equals(apply.getLatestApprovalStatus(),
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
// hyapp-server callback 成功本地事务提交失败时会用同一 command 重试同终态必须幂等成功相反终态仍拒绝覆盖
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
Objects.equals(apply.getLatestApprovalStatus(), param.getApprovalStatus()));
return;
}
UserBankCardDTO bankCard = ResponseAssert.requiredSuccess(
userBankCardClient.getById(apply.getBankCardId()));
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
Boolean updated = ResponseAssert.requiredSuccess(
bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd()
.setId(apply.getId())
.setUpdateUserOrigin(1)
@ -813,7 +819,17 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
.setRemark(param.getRemark())
.setCreateTime(TimestampUtils.now())
)
);
));
if (!Boolean.TRUE.equals(updated)) {
// Mongo 使用 latestApprovalStatus=SUBMIT 条件更新并发重复审核只有一个调用者能进入后续退款逻辑
UserBankWithdrawMoneyApplyDTO latest = ResponseAssert.requiredSuccess(
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, latest);
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
Objects.equals(latest.getLatestApprovalStatus(), param.getApprovalStatus()));
return;
}
// 如果是驳回则将金额还给用户银行卡账户且插入返还日志
saveUserBankBalance(param, apply);

View File

@ -45,6 +45,11 @@ management:
health:
show-details: always
# 与 hyapp-server 的 Aslan withdrawal source callback_token 保持一致,仅由生产环境注入。
hyapp:
withdrawal-review:
callback-token: ${HYAPP_WITHDRAWAL_REVIEW_CALLBACK_TOKEN:}
framework:
nacos:
subscribeServices:
@ -71,3 +76,5 @@ red-circle:
- /console/datav/online/room/count
- /console/datav/aslan/region-country/statistics
- /console/user/base/info/im/sig
# 该入口不走 console 登录态,控制器使用独立资金回调令牌做常量时间鉴权。
- /console/integration/withdrawal-review/decision

View File

@ -20,6 +20,7 @@ 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.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;
@ -66,6 +67,7 @@ public class UserBankWithdrawCmdExe {
private final UserBankBalanceService userBankBalanceService;
private final UserBankRunningWaterService userBankRunningWaterService;
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
private final WithdrawalReviewSubmissionTask withdrawalReviewSubmissionTask;
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
@ -122,7 +124,7 @@ public class UserBankWithdrawCmdExe {
}
// 申请详情
userBankWithdrawMoneyApplyService.add(
UserBankWithdrawMoneyApply withdrawalApply =
new UserBankWithdrawMoneyApply()
.setId(applyId)
.setSysOrigin(cmd.requireReqSysOrigin())
@ -139,6 +141,10 @@ public class UserBankWithdrawCmdExe {
.setCreateTime(TimestampUtils.now())
))
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
// 申请文档同时承担可靠投递 outbox网络失败不会丢单定时任务会按来源申请 ID 幂等重试
// 即使运行配置暂未开启也保留 PENDING启用后定时任务会自动补投停机窗口内的申请
.setExternalReviewStatus("PENDING")
.setExternalReviewUpdatedTime(TimestampUtils.now())
.setServiceCharge(serviceCharge)
.setActualAmount(actualAmount)
.setCreateTime(TimestampUtils.now())
@ -146,8 +152,11 @@ public class UserBankWithdrawCmdExe {
.setCreateUser(cmd.requiredReqUserId())
.setUpdateUser(cmd.requiredReqUserId())
.setCreateUserOrigin(OpUserType.APP.getType())
.setUpdateUserOrigin(OpUserType.APP.getType())
);
.setUpdateUserOrigin(OpUserType.APP.getType());
userBankWithdrawMoneyApplyService.add(withdrawalApply);
// 首次尽量实时进入运营队列失败只落 outbox 错误不回滚已提交的用户提现避免出现扣款成功但接口报失败的假象
withdrawalReviewSubmissionTask.submit(withdrawalApply, bankCard);
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
redisService.unlock(key);

View File

@ -0,0 +1,108 @@
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.UserBankCard;
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;
/**
* hyapp-server 统一提现双审提交客户端.
*/
@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;
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:aslan}") String appCode,
@Value("${hyapp.withdrawal-review.source-system:likei}") String sourceSystem,
@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.requestTimeout = Duration.ofMillis(Math.max(1000, requestTimeoutMs));
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.requestTimeout)
.build();
}
public boolean isEnabled() {
return enabled;
}
public String submit(UserBankWithdrawMoneyApply apply, UserBankCard bankCard) {
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 || bankCard == null || apply.getAmount() == null) {
throw new IllegalArgumentException("withdrawal review application is incomplete");
}
BigDecimal normalizedAmount = apply.getAmount().stripTrailingZeros();
long amountMinor = normalizedAmount.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);
payload.put("withdrawMethod", bankCard.getCardType());
payload.put("withdrawAddress", bankCard.getCardNo());
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);
}
}
}

View File

@ -0,0 +1,57 @@
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.UserBankCard;
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankWithdrawMoneyApply;
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankCardService;
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;
/**
* Likei 提现申请可靠投递到 hyapp-server 的统一运营/财务双审队列.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WithdrawalReviewSubmissionTask {
private static final int BATCH_SIZE = 50;
private final HyAppWithdrawalReviewClient reviewClient;
private final UserBankWithdrawMoneyApplyService withdrawApplyService;
private final UserBankCardService userBankCardService;
@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, null);
}
}
/**
* 首次提交和定时补偿共用同一入口失败只记录在原申请文档用户扣款申请仍然成功后续由定时任务继续投递.
*/
public void submit(UserBankWithdrawMoneyApply application, UserBankCard knownBankCard) {
if (!reviewClient.isEnabled() || application == null) {
return;
}
try {
UserBankCard bankCard = knownBankCard == null
? userBankCardService.getById(application.getBankCardId()) : knownBankCard;
String reviewApplicationId = reviewClient.submit(application, bankCard);
withdrawApplyService.markExternalReviewSubmitted(application.getId(), reviewApplicationId);
} catch (Exception exception) {
withdrawApplyService.markExternalReviewFailed(application.getId(), exception.getMessage());
log.error("Submit withdrawal {} to hyapp review failed", application.getId(), exception);
}
}
}

View File

@ -92,6 +92,23 @@ public class UserBankWithdrawMoneyApply implements Serializable {
*/
private String latestApprovalStatus;
/**
* 统一提现双审投递状态PENDING/FAILED/SUBMITTED.
*/
private String externalReviewStatus;
/**
* hyapp-server 生成的统一审核申请 ID.
*/
private String externalReviewApplicationId;
/**
* 最近一次投递错误仅用于后台排障不参与业务判断.
*/
private String externalReviewLastError;
private Timestamp externalReviewUpdatedTime;
/**
* 结算结果;
*/

View File

@ -42,9 +42,18 @@ public interface UserBankWithdrawMoneyApplyService {
*/
UserBankWithdrawMoneyApply getById(Long id);
/**
* 获取尚未成功进入统一双审中心的申请固定小批量轮询避免全表加载.
*/
List<UserBankWithdrawMoneyApply> listPendingExternalReview(int limit);
void markExternalReviewSubmitted(Long id, String reviewApplicationId);
void markExternalReviewFailed(Long id, String errorMessage);
/**
* 审核申请记录.
*/
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
}

View File

@ -1,5 +1,6 @@
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;
@ -122,9 +123,49 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
}
@Override
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
public List<UserBankWithdrawMoneyApply> listPendingExternalReview(int limit) {
Criteria criteria = Criteria.where("latestApprovalStatus").is("SUBMIT")
.and("externalReviewStatus").ne("SUBMITTED");
return mongoTemplate.find(Query.query(criteria)
.with(Sort.by(Sort.Order.asc("createTime")))
.limit(Math.max(1, Math.min(limit, 100))),
UserBankWithdrawMoneyApply.class);
}
mongoTemplate.updateFirst(Query.query(Criteria.where("id").in(apply.getId())),
@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 为前置条件重复回调不能再次进入退款逻辑
UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(apply.getId())
.and("latestApprovalStatus").is("SUBMIT")),
new Update()
.set("updateTime", TimestampUtils.now())
.set("updateUser", apply.getUpdateUser())
@ -133,7 +174,7 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
.set("settlementResult", apply.getSettlementResult())
.push("approvalProcesses", apply.getProcess()),
UserBankWithdrawMoneyApply.class);
return result.getModifiedCount() == 1;
}
}

View File

@ -97,9 +97,8 @@ public class UserBankRunningWaterClientEndpoint implements UserBankRunningWaterC
@Override
public ResultResponse<Void> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
userBankRunningWaterClientService.approvalMoneyApply(apply);
return ResultResponse.success();
public ResultResponse<Boolean> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
return ResultResponse.success(userBankRunningWaterClientService.approvalMoneyApply(apply));
}
@Override

View File

@ -73,7 +73,7 @@ public interface UserBankRunningWaterClientService {
/**
* 审核申请记录.
*/
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
/**
* 月用户工资流水.

View File

@ -93,8 +93,8 @@ public class UserBankRunningWaterClientServiceImpl implements UserBankRunningWat
}
@Override
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
public boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
return bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
}
@Override

View File

@ -29,4 +29,14 @@ management:
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:aslan}
source-system: ${HYAPP_WITHDRAWAL_REVIEW_SOURCE_SYSTEM:likei}
request-timeout-ms: ${HYAPP_WITHDRAWAL_REVIEW_TIMEOUT_MS:5000}
retry-delay-ms: ${HYAPP_WITHDRAWAL_REVIEW_RETRY_DELAY_MS:30000}