feat(wallet): route user withdrawals through dual review
This commit is contained in:
parent
e25aed3085
commit
f49e897431
@ -76,7 +76,7 @@ public interface UserBankRunningWaterClientApi {
|
|||||||
ResultResponse<UserBankWithdrawMoneyApplyDTO> getWithdrawMoneyApplyById(@RequestParam("id") Long id);
|
ResultResponse<UserBankWithdrawMoneyApplyDTO> getWithdrawMoneyApplyById(@RequestParam("id") Long id);
|
||||||
|
|
||||||
@PostMapping("/approvalMoneyApply")
|
@PostMapping("/approvalMoneyApply")
|
||||||
ResultResponse<Void> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
ResultResponse<Boolean> approvalMoneyApply(@RequestBody UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月用户工资流水
|
* 月用户工资流水
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -784,14 +784,20 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess(
|
UserBankWithdrawMoneyApplyDTO apply = ResponseAssert.requiredSuccess(
|
||||||
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||||
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, apply);
|
||||||
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED, Objects
|
if (!Objects.equals(apply.getLatestApprovalStatus(),
|
||||||
.equals(apply.getLatestApprovalStatus(), WithdrawMoneyApprovalProcessStatus.SUBMIT.name()));
|
WithdrawMoneyApprovalProcessStatus.SUBMIT.name())) {
|
||||||
|
// hyapp-server 在 callback 成功、本地事务提交失败时会用同一 command 重试;同终态必须幂等成功,相反终态仍拒绝覆盖。
|
||||||
|
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
|
||||||
|
Objects.equals(apply.getLatestApprovalStatus(), param.getApprovalStatus()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
UserBankCardDTO bankCard = ResponseAssert.requiredSuccess(
|
UserBankCardDTO bankCard = ResponseAssert.requiredSuccess(
|
||||||
userBankCardClient.getById(apply.getBankCardId()));
|
userBankCardClient.getById(apply.getBankCardId()));
|
||||||
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
|
ResponseAssert.notNull(BankErrorCode.INVALID_BANK_CARD_ERROR, bankCard);
|
||||||
|
|
||||||
bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd()
|
Boolean updated = ResponseAssert.requiredSuccess(
|
||||||
|
bankRunningWaterClient.approvalMoneyApply(new UserBankApprovalMoneyApplyCmd()
|
||||||
.setId(apply.getId())
|
.setId(apply.getId())
|
||||||
.setUpdateUserOrigin(1)
|
.setUpdateUserOrigin(1)
|
||||||
.setUpdateUser(param.getUpdateUser())
|
.setUpdateUser(param.getUpdateUser())
|
||||||
@ -813,7 +819,17 @@ public class UserBankBalanceBackServiceImpl implements UserBankBalanceBackServic
|
|||||||
.setRemark(param.getRemark())
|
.setRemark(param.getRemark())
|
||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(TimestampUtils.now())
|
||||||
)
|
)
|
||||||
);
|
));
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(updated)) {
|
||||||
|
// Mongo 使用 latestApprovalStatus=SUBMIT 条件更新;并发重复审核只有一个调用者能进入后续退款逻辑。
|
||||||
|
UserBankWithdrawMoneyApplyDTO latest = ResponseAssert.requiredSuccess(
|
||||||
|
bankRunningWaterClient.getWithdrawMoneyApplyById(param.getId()));
|
||||||
|
ResponseAssert.notNull(ConsoleErrorCode.NOT_FOUND_RECORD, latest);
|
||||||
|
ResponseAssert.isTrue(ConsoleErrorCode.PROCESSED,
|
||||||
|
Objects.equals(latest.getLatestApprovalStatus(), param.getApprovalStatus()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是驳回则将金额还给用户银行卡账户且插入返还日志
|
// 如果是驳回则将金额还给用户银行卡账户且插入返还日志
|
||||||
saveUserBankBalance(param, apply);
|
saveUserBankBalance(param, apply);
|
||||||
|
|||||||
@ -45,6 +45,11 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
|
# 与 hyapp-server 的 Aslan withdrawal source callback_token 保持一致,仅由生产环境注入。
|
||||||
|
hyapp:
|
||||||
|
withdrawal-review:
|
||||||
|
callback-token: ${HYAPP_WITHDRAWAL_REVIEW_CALLBACK_TOKEN:}
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
nacos:
|
nacos:
|
||||||
subscribeServices:
|
subscribeServices:
|
||||||
@ -71,3 +76,5 @@ red-circle:
|
|||||||
- /console/datav/online/room/count
|
- /console/datav/online/room/count
|
||||||
- /console/datav/aslan/region-country/statistics
|
- /console/datav/aslan/region-country/statistics
|
||||||
- /console/user/base/info/im/sig
|
- /console/user/base/info/im/sig
|
||||||
|
# 该入口不走 console 登录态,控制器使用独立资金回调令牌做常量时间鉴权。
|
||||||
|
- /console/integration/withdrawal-review/decision
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import com.red.circle.tool.core.text.StringUtils;
|
|||||||
import com.red.circle.tool.core.tuple.PennyAmount;
|
import com.red.circle.tool.core.tuple.PennyAmount;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO;
|
import com.red.circle.wallet.app.dto.clientobject.UserBankWithdrawResultCO;
|
||||||
import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd;
|
import com.red.circle.wallet.app.dto.cmd.UserBankWithdrawCmd;
|
||||||
|
import com.red.circle.wallet.app.scheduler.WithdrawalReviewSubmissionTask;
|
||||||
import com.red.circle.wallet.domain.gateway.SalaryAccountGateway;
|
import com.red.circle.wallet.domain.gateway.SalaryAccountGateway;
|
||||||
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankRunningWater;
|
import com.red.circle.wallet.infra.database.mongo.entity.bank.UserBankRunningWater;
|
||||||
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
import com.red.circle.wallet.infra.database.mongo.service.bank.UserBankBalanceService;
|
||||||
@ -66,6 +67,7 @@ public class UserBankWithdrawCmdExe {
|
|||||||
private final UserBankBalanceService userBankBalanceService;
|
private final UserBankBalanceService userBankBalanceService;
|
||||||
private final UserBankRunningWaterService userBankRunningWaterService;
|
private final UserBankRunningWaterService userBankRunningWaterService;
|
||||||
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
private final HostSalaryWithdrawEnabledQryExe hostSalaryWithdrawEnabledQryExe;
|
||||||
|
private final WithdrawalReviewSubmissionTask withdrawalReviewSubmissionTask;
|
||||||
|
|
||||||
|
|
||||||
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
public UserBankWithdrawResultCO execute(UserBankWithdrawCmd cmd) {
|
||||||
@ -122,7 +124,7 @@ public class UserBankWithdrawCmdExe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 申请详情
|
// 申请详情
|
||||||
userBankWithdrawMoneyApplyService.add(
|
UserBankWithdrawMoneyApply withdrawalApply =
|
||||||
new UserBankWithdrawMoneyApply()
|
new UserBankWithdrawMoneyApply()
|
||||||
.setId(applyId)
|
.setId(applyId)
|
||||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
.setSysOrigin(cmd.requireReqSysOrigin())
|
||||||
@ -139,6 +141,10 @@ public class UserBankWithdrawCmdExe {
|
|||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(TimestampUtils.now())
|
||||||
))
|
))
|
||||||
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
.setLatestApprovalStatus(WithdrawMoneyApprovalProcessStatus.SUBMIT.name())
|
||||||
|
// 申请文档同时承担可靠投递 outbox;网络失败不会丢单,定时任务会按来源申请 ID 幂等重试。
|
||||||
|
// 即使运行配置暂未开启也保留 PENDING;启用后定时任务会自动补投停机窗口内的申请。
|
||||||
|
.setExternalReviewStatus("PENDING")
|
||||||
|
.setExternalReviewUpdatedTime(TimestampUtils.now())
|
||||||
.setServiceCharge(serviceCharge)
|
.setServiceCharge(serviceCharge)
|
||||||
.setActualAmount(actualAmount)
|
.setActualAmount(actualAmount)
|
||||||
.setCreateTime(TimestampUtils.now())
|
.setCreateTime(TimestampUtils.now())
|
||||||
@ -146,8 +152,11 @@ public class UserBankWithdrawCmdExe {
|
|||||||
.setCreateUser(cmd.requiredReqUserId())
|
.setCreateUser(cmd.requiredReqUserId())
|
||||||
.setUpdateUser(cmd.requiredReqUserId())
|
.setUpdateUser(cmd.requiredReqUserId())
|
||||||
.setCreateUserOrigin(OpUserType.APP.getType())
|
.setCreateUserOrigin(OpUserType.APP.getType())
|
||||||
.setUpdateUserOrigin(OpUserType.APP.getType())
|
.setUpdateUserOrigin(OpUserType.APP.getType());
|
||||||
);
|
userBankWithdrawMoneyApplyService.add(withdrawalApply);
|
||||||
|
|
||||||
|
// 首次尽量实时进入运营队列;失败只落 outbox 错误,不回滚已提交的用户提现,避免出现扣款成功但接口报失败的假象。
|
||||||
|
withdrawalReviewSubmissionTask.submit(withdrawalApply, bankCard);
|
||||||
|
|
||||||
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
|
walletBankCacheService.setMonthWithdrawal(cmd.requiredReqUserId());
|
||||||
redisService.unlock(key);
|
redisService.unlock(key);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,6 +92,23 @@ public class UserBankWithdrawMoneyApply implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String latestApprovalStatus;
|
private String latestApprovalStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一提现双审投递状态:PENDING/FAILED/SUBMITTED.
|
||||||
|
*/
|
||||||
|
private String externalReviewStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hyapp-server 生成的统一审核申请 ID.
|
||||||
|
*/
|
||||||
|
private String externalReviewApplicationId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次投递错误,仅用于后台排障,不参与业务判断.
|
||||||
|
*/
|
||||||
|
private String externalReviewLastError;
|
||||||
|
|
||||||
|
private Timestamp externalReviewUpdatedTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算结果;
|
* 结算结果;
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -42,9 +42,18 @@ public interface UserBankWithdrawMoneyApplyService {
|
|||||||
*/
|
*/
|
||||||
UserBankWithdrawMoneyApply getById(Long id);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.red.circle.wallet.infra.database.mongo.service.bank.impl;
|
package com.red.circle.wallet.infra.database.mongo.service.bank.impl;
|
||||||
|
|
||||||
|
import com.mongodb.client.result.UpdateResult;
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||||
import com.red.circle.tool.core.date.TimestampUtils;
|
import com.red.circle.tool.core.date.TimestampUtils;
|
||||||
import com.red.circle.tool.core.date.ZonedDateTimeUtils;
|
import com.red.circle.tool.core.date.ZonedDateTimeUtils;
|
||||||
@ -122,9 +123,49 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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()
|
new Update()
|
||||||
.set("updateTime", TimestampUtils.now())
|
.set("updateTime", TimestampUtils.now())
|
||||||
.set("updateUser", apply.getUpdateUser())
|
.set("updateUser", apply.getUpdateUser())
|
||||||
@ -133,7 +174,7 @@ public class UserBankWithdrawMoneyApplyServiceImpl implements UserBankWithdrawMo
|
|||||||
.set("settlementResult", apply.getSettlementResult())
|
.set("settlementResult", apply.getSettlementResult())
|
||||||
.push("approvalProcesses", apply.getProcess()),
|
.push("approvalProcesses", apply.getProcess()),
|
||||||
UserBankWithdrawMoneyApply.class);
|
UserBankWithdrawMoneyApply.class);
|
||||||
|
return result.getModifiedCount() == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,9 +97,8 @@ public class UserBankRunningWaterClientEndpoint implements UserBankRunningWaterC
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResultResponse<Void> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
public ResultResponse<Boolean> approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||||
userBankRunningWaterClientService.approvalMoneyApply(apply);
|
return ResultResponse.success(userBankRunningWaterClientService.approvalMoneyApply(apply));
|
||||||
return ResultResponse.success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -73,7 +73,7 @@ public interface UserBankRunningWaterClientService {
|
|||||||
/**
|
/**
|
||||||
* 审核申请记录.
|
* 审核申请记录.
|
||||||
*/
|
*/
|
||||||
void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月用户工资流水.
|
* 月用户工资流水.
|
||||||
|
|||||||
@ -93,8 +93,8 @@ public class UserBankRunningWaterClientServiceImpl implements UserBankRunningWat
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
public boolean approvalMoneyApply(UserBankApprovalMoneyApplyCmd apply) {
|
||||||
bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
|
return bankWithdrawMoneyApplyService.approvalMoneyApply(apply);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -29,4 +29,14 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
|
# 生产由环境变量开启;本地/测试默认关闭,避免误把开发提现投递到真实财务工作台。
|
||||||
|
hyapp:
|
||||||
|
withdrawal-review:
|
||||||
|
enabled: ${HYAPP_WITHDRAWAL_REVIEW_ENABLED:false}
|
||||||
|
submit-url: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_URL:}
|
||||||
|
submit-token: ${HYAPP_WITHDRAWAL_REVIEW_SUBMIT_TOKEN:}
|
||||||
|
app-code: ${HYAPP_WITHDRAWAL_REVIEW_APP_CODE: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}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user