feat(finance): add credit and wallet hold lifecycle

This commit is contained in:
2026-09-05 11:54:55 +08:00
parent 6f2b2babc6
commit ba4407eb0d
19 changed files with 587 additions and 70 deletions
@@ -0,0 +1,72 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
import com.baiyue.finance.service.FinanceWalletService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/finance/v1/wallet")
public class InternalFinanceWalletController {
private final FinanceWalletService service;
private final String expectedToken;
public InternalFinanceWalletController(FinanceWalletService service,
@Value("${baiyue.security.internal-service-token}") String expectedToken) {
this.service = service;
this.expectedToken = expectedToken;
}
@GetMapping("/accounts/{customerId}")
public ApiResponse<?> account(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long customerId) {
check(token);
return FinanceResponses.success(service.account(customerId));
}
@PostMapping("/accounts/{customerId}/holds")
public ApiResponse<?> freeze(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@PathVariable long customerId,
@RequestBody WalletFreezeRequest request) {
check(token);
return FinanceResponses.success(service.freeze(customerId, idempotencyKey, request));
}
@PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/settle")
public ApiResponse<?> settle(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@PathVariable long customerId, @PathVariable String businessType,
@PathVariable String businessNo,
@RequestBody(required = false) WalletHoldActionRequest request) {
check(token);
return FinanceResponses.success(service.settle(customerId, businessType, businessNo,
idempotencyKey, request));
}
@PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/release")
public ApiResponse<?> release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@PathVariable long customerId, @PathVariable String businessType,
@PathVariable String businessNo,
@RequestBody(required = false) WalletHoldActionRequest request) {
check(token);
return FinanceResponses.success(service.release(customerId, businessType, businessNo,
idempotencyKey, request));
}
private void check(String token) {
if (!StringUtils.hasText(expectedToken) || token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
}
@@ -3,6 +3,7 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
@@ -48,6 +49,16 @@ public class PlatformFinanceWalletController {
idempotencyKey, request));
}
@PutMapping("/accounts/{customerId}/credit-limit")
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
public ApiResponse<?> creditLimit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long customerId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody CreditLimitRequest request) {
return FinanceResponses.success(service.setCreditLimit(customerId, user.userId(), user.displayName(),
idempotencyKey, request));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
@@ -31,7 +31,9 @@ public final class FinancePhase1Dtos {
BigDecimal receivedInPeriod, BigDecimal totalCost,
BigDecimal paidCost, BigDecimal outstandingPayable,
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, int accountVersion,
BigDecimal availableBalance, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal availableCredit,
int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
@@ -42,6 +44,8 @@ public final class FinancePhase1Dtos {
String remark, String walletBusinessNo, String changeType,
BigDecimal walletAmount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
BigDecimal availableCreditAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo) {}
@@ -10,6 +10,8 @@ public final class FinanceWalletDtos {
public record WalletAccount(String currencyCode, BigDecimal actualBalance,
BigDecimal frozenBalance, BigDecimal availableBalance,
BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal creditFrozen, BigDecimal availableCredit,
int version, LocalDateTime updatedAt) { }
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
@@ -22,6 +24,8 @@ public final class FinanceWalletDtos {
public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType,
BigDecimal amount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
BigDecimal availableCreditAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
@@ -40,4 +44,19 @@ public final class FinanceWalletDtos {
public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate,
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
int expectedAccountVersion) { }
public record WalletFreezeRequest(BigDecimal amount, String businessType,
String businessNo, String remark) { }
public record WalletHoldActionRequest(String remark) { }
public record WalletHold(long holdId, long customerId, String businessType,
String businessNo, BigDecimal amount,
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
String status, Long freezeLedgerId, Long actionLedgerId,
WalletAccount account, LocalDateTime createdAt,
LocalDateTime completedAt) { }
}
@@ -21,12 +21,18 @@ public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
private final BigDecimal defaultCreditLimit;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
}
this.defaultCreditLimit = defaultCreditLimit.setScale(2);
}
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
@@ -47,12 +53,13 @@ public class FinanceCustomerAccountService {
PageResult<IamCustomerClient.Customer> customers = response.data();
Map<Long, Metrics> metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList());
List<AccountSummary> rows = customers.records().stream().map(customer -> {
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
Metrics value = metrics.get(customer.id());
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.creditLimit(), value.creditUsed(), value.availableCredit(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
@@ -63,7 +70,7 @@ public class FinanceCustomerAccountService {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
@@ -76,7 +83,7 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
.forEach(row -> walletMetric(result, row));
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
.forEach(row -> pendingMetric(result, row));
@@ -94,6 +101,9 @@ public class FinanceCustomerAccountService {
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
target.availableBalance = decimal(row.get("available_balance"));
target.creditLimit = decimal(row.get("credit_limit"));
target.creditUsed = decimal(row.get("credit_used"));
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
@@ -145,9 +155,17 @@ public class FinanceCustomerAccountService {
private BigDecimal actualBalance = BigDecimal.ZERO;
private BigDecimal frozenBalance = BigDecimal.ZERO;
private BigDecimal availableBalance = BigDecimal.ZERO;
private BigDecimal creditLimit;
private BigDecimal creditUsed = BigDecimal.ZERO;
private BigDecimal availableCredit;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
private MutableMetrics(BigDecimal defaultCreditLimit) {
this.creditLimit = defaultCreditLimit;
this.availableCredit = defaultCreditLimit;
this.availableBalance = defaultCreditLimit;
}
void set(String field, BigDecimal value) {
switch (field) {
case "outstandingReceivable" -> outstandingReceivable = value;
@@ -160,16 +178,15 @@ public class FinanceCustomerAccountService {
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); }
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
accountVersion, pendingRechargeCount, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, int accountVersion, long pendingRechargeCount,
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
LocalDateTime lastChangedAt) {
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, 0, 0, null);
}
}
@@ -76,6 +76,9 @@ public class FinancePhase1OperationService {
"COALESCE((SELECT actual_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) actual_balance," +
"COALESCE((SELECT frozen_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) frozen_balance," +
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
@@ -85,6 +88,7 @@ public class FinancePhase1OperationService {
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
rs.getString("account_status"), time(rs, "last_changed_at")), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
@@ -107,7 +111,7 @@ public class FinancePhase1OperationService {
outstanding = outstanding.add(receivableChange);
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark(),
null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
BigDecimal derivedOutstanding = outstanding;
@@ -120,6 +124,8 @@ public class FinancePhase1OperationService {
rs.getString("operator_name"), rs.getString("remark"), rs.getString("business_no"), type,
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"),
rs.getBigDecimal("frozen_balance_after"), rs.getBigDecimal("available_balance_after"),
rs.getBigDecimal("credit_limit_after"), rs.getBigDecimal("credit_used_after"),
rs.getBigDecimal("available_credit_after"),
rs.getString("source_type"), rs.getString("source_no"), occurredDate,
rs.getString("payment_method"), rs.getString("reference_no"));
}, customerId).stream().filter(entry ->
@@ -3,10 +3,14 @@ package com.baiyue.finance.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -49,6 +53,7 @@ public class FinanceWalletService {
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
private final BigDecimal defaultCreditLimit;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
@@ -56,6 +61,7 @@ public class FinanceWalletService {
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -63,6 +69,7 @@ public class FinanceWalletService {
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
@@ -77,12 +84,9 @@ public class FinanceWalletService {
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List<WalletAccount> rows = jdbc.query(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
timestamp(rs.getTimestamp("updated_at"))), customerId);
return rows.isEmpty() ? new WalletAccount("CNY", BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
BigDecimal.ZERO.setScale(2), 0, null) : rows.get(0);
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> accountRow(rs), customerId);
return rows.isEmpty() ? zeroAccount() : rows.get(0);
}
public WalletPage<WalletLedgerEntry> ledger(long customerId, int page, int size) {
@@ -169,10 +173,9 @@ public class FinanceWalletService {
if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化");
if ("APPROVE".equals(decision)) {
WalletAccount account = accountForUpdate(beforeCustomerId(requestId));
BigDecimal actual = account.actualBalance().add(before.amount());
BigDecimal available = account.availableBalance().add(before.amount());
updateAccount(beforeCustomerId(requestId), actual, account.frozenBalance(), available, account.version(), operatorId);
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), actual, account.frozenBalance(), available,
WalletAccount afterAccount = rechargeAccount(account, before.amount());
updateAccount(beforeCustomerId(requestId), afterAccount, account.version(), operatorId);
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), afterAccount,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
operatorId, operatorName, request.remark());
}
@@ -199,15 +202,15 @@ public class FinanceWalletService {
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
BigDecimal actual = before.actualBalance().add(signed);
BigDecimal available = before.availableBalance().add(signed);
updateAccount(customerId, actual, before.frozenBalance(), available, before.version(), operatorId);
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
WalletAccount afterAccount = "RECHARGE".equals(type)
? rechargeAccount(before, amount) : deductAccount(before, amount);
updateAccount(customerId, afterAccount, before.version(), operatorId);
String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION";
WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available,
WalletLedgerEntry entry = insertLedger(customerId, type, signed, afterAccount,
sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(),
adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark());
audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before,
@@ -216,6 +219,121 @@ public class FinanceWalletService {
return entry;
}
@Transactional
public WalletLedgerEntry setCreditLimit(long customerId, long operatorId, String operatorName,
String idempotencyKey, CreditLimitRequest request) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) {
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
}
BigDecimal delta = limit.subtract(before.creditLimit());
if (delta.signum() == 0) throw bad("授信额度未发生变化");
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
before.creditUsed(), before.creditFrozen(), before.version());
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
operatorId, operatorName, request.remark());
audit(customerId, "WALLET_ACCOUNT", customerId, "CREDIT_LIMIT_UPDATE", request.remark(),
before, account(customerId), operatorId, operatorName);
complete(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, entry.ledgerId());
return entry;
}
@Transactional
public WalletHold freeze(long customerId, String idempotencyKey, WalletFreezeRequest request) {
requirePositive(customerId, "客户");
if (request == null) throw bad("冻结请求无效");
BigDecimal amount = money(request.amount());
String businessType = businessType(request.businessType());
String businessNo = businessNo(request.businessNo());
String requestHash = hash(Map.of("customerId", customerId, "amount", amount,
"businessType", businessType, "businessNo", businessNo,
"remark", String.valueOf(request.remark())));
Long replay = reserve(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, requestHash);
if (replay != null) return requiredHold(replay, customerId);
WalletHold existing = findHold(customerId, businessType, businessNo, false);
if (existing != null) {
if (existing.amount().compareTo(amount) != 0) {
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号的冻结金额不一致");
}
complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, existing.holdId());
return withAccount(existing);
}
WalletAccount before = accountForUpdate(customerId);
if (before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
}
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
BigDecimal actualFrozen = amount.min(actualAvailable);
BigDecimal creditFrozen = amount.subtract(actualFrozen);
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
updateAccount(customerId, after, before.version(), 0);
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
clean(request.remark(), 500));
WalletLedgerEntry ledger = insertLedger(customerId, "FREEZE", amount.negate(), after,
businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", request.remark());
jdbc.update("UPDATE fin_wallet_hold SET freeze_ledger_id=? WHERE id=?", ledger.ledgerId(), holdId);
audit(customerId, "WALLET_HOLD", holdId, "FREEZE", request.remark(), before, after, 0, "业务系统");
complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, holdId);
return requiredHold(holdId, customerId);
}
@Transactional
public WalletHold settle(long customerId, String businessType, String businessNo,
String idempotencyKey, WalletHoldActionRequest request) {
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, true);
}
@Transactional
public WalletHold release(long customerId, String businessType, String businessNo,
String idempotencyKey, WalletHoldActionRequest request) {
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false);
}
private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
requirePositive(customerId, "客户");
String businessType = businessType(rawBusinessType);
String businessNo = businessNo(rawBusinessNo);
String operation = settle ? "WALLET_HOLD_SETTLE" : "WALLET_HOLD_RELEASE";
String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType,
"businessNo", businessNo, "remark", String.valueOf(request == null ? null : request.remark())));
Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash);
if (replay != null) return requiredHold(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
creditFrozen, before.version());
updateAccount(customerId, after, before.version(), 0);
String action = settle ? "SETTLED" : "RELEASED";
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
settle ? hold.amount().negate() : hold.amount(), after, businessType, businessNo,
LocalDate.now(), null, businessNo, 0, "业务系统", request == null ? null : request.remark());
jdbc.update("UPDATE fin_wallet_hold SET status=?,action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'",
action, ledger.ledgerId(), hold.holdId());
audit(customerId, "WALLET_HOLD", hold.holdId(), action,
request == null ? null : request.remark(), before, after, 0, "业务系统");
complete(customerId, 0, operation, idempotencyKey, hold.holdId());
return requiredHold(hold.holdId(), customerId);
}
public ResponseEntity<byte[]> voucher(long requestId) {
RechargeRequest request = requiredRecharge(requestId, null);
long customerId = beforeCustomerId(requestId);
@@ -228,23 +346,22 @@ public class FinanceWalletService {
}
private WalletAccount accountForUpdate(long customerId) {
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0)", customerId);
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
customerId, defaultCreditLimit, defaultCreditLimit);
return jdbc.queryForObject(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
timestamp(rs.getTimestamp("updated_at"))), customerId);
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> accountRow(rs), customerId);
}
private void updateAccount(long customerId, BigDecimal actual, BigDecimal frozen, BigDecimal available,
int expectedVersion, long operatorId) {
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
actual, frozen, available, operatorId, customerId, expectedVersion);
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
BigDecimal actual, BigDecimal frozen, BigDecimal available,
WalletAccount account,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, long operatorId,
String operatorName, String remark) {
@@ -252,14 +369,17 @@ public class FinanceWalletService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, actual); statement.setBigDecimal(6, frozen);
statement.setBigDecimal(7, available); statement.setString(8, sourceType); statement.setString(9, sourceNo);
statement.setObject(10, occurredDate); statement.setString(11, paymentMethod); statement.setString(12, referenceNo);
statement.setLong(13, operatorId); statement.setString(14, clean(operatorName, 128));
statement.setString(15, clean(remark, 500)); return statement;
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
statement.setString(18, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
@@ -271,6 +391,102 @@ public class FinanceWalletService {
return rows.get(0);
}
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
BigDecimal creditRepayment = amount.min(before.creditUsed());
return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
}
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
BigDecimal actualDeduction = amount.min(actualAvailable);
BigDecimal creditDeduction = amount.subtract(actualDeduction);
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
|| creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
|| availableCredit.signum() < 0 || available.signum() < 0) {
throw error("FIN-WALLET-409", "账户余额状态无效");
}
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
creditFrozen, availableCredit, version, null);
}
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
BigDecimal creditUsed = rs.getBigDecimal("credit_used");
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
timestamp(rs.getTimestamp("updated_at")));
}
private WalletAccount zeroAccount() {
BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
zero, defaultCreditLimit, 0, null);
}
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
BigDecimal actualFrozen, BigDecimal creditFrozen, String remark) {
KeyHolder holder = new GeneratedKeyHolder();
try {
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_wallet_hold(customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status,remark) VALUES(?,?,?,?,?,?,'FROZEN',?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessType);
statement.setString(3, businessNo); statement.setBigDecimal(4, amount);
statement.setBigDecimal(5, actualFrozen); statement.setBigDecimal(6, creditFrozen);
statement.setString(7, remark); return statement;
}, holder);
} catch (DuplicateKeyException exception) {
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号已存在资金冻结记录");
}
return generatedId(holder);
}
private WalletHold requiredHold(long holdId, long customerId) {
List<WalletHold> rows = jdbc.query("SELECT * FROM fin_wallet_hold WHERE id=? AND customer_id=?",
(rs, n) -> holdRow(rs), holdId, customerId);
if (rows.isEmpty()) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
return withAccount(rows.get(0));
}
private WalletHold findHold(long customerId, String businessType, String businessNo, boolean forUpdate) {
String sql = "SELECT * FROM fin_wallet_hold WHERE customer_id=? AND business_type=? AND business_no=?"
+ (forUpdate ? " FOR UPDATE" : "");
List<WalletHold> rows = jdbc.query(sql, (rs, n) -> holdRow(rs), customerId, businessType, businessNo);
return rows.isEmpty() ? null : rows.get(0);
}
private WalletHold withAccount(WalletHold hold) {
return new WalletHold(hold.holdId(), hold.customerId(), hold.businessType(), hold.businessNo(),
hold.amount(), hold.actualFrozenAmount(), hold.creditFrozenAmount(), hold.status(),
hold.freezeLedgerId(), hold.actionLedgerId(), account(hold.customerId()), hold.createdAt(),
hold.completedAt());
}
private WalletHold holdRow(java.sql.ResultSet rs) throws java.sql.SQLException {
long customerId = rs.getLong("customer_id");
Number freezeLedger = (Number) rs.getObject("freeze_ledger_id");
Number actionLedger = (Number) rs.getObject("action_ledger_id");
return new WalletHold(rs.getLong("id"), customerId, rs.getString("business_type"),
rs.getString("business_no"), rs.getBigDecimal("amount"), rs.getBigDecimal("actual_frozen_amount"),
rs.getBigDecimal("credit_frozen_amount"), rs.getString("status"),
freezeLedger == null ? null : freezeLedger.longValue(), actionLedger == null ? null : actionLedger.longValue(),
null, timestamp(rs.getTimestamp("created_at")), timestamp(rs.getTimestamp("completed_at")));
}
private RechargeRequest requiredRecharge(long requestId, Long customerId) {
String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?");
Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId};
@@ -328,7 +544,9 @@ public class FinanceWalletService {
private WalletLedgerEntry ledgerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new WalletLedgerEntry(rs.getLong("id"), rs.getString("business_no"), rs.getString("change_type"),
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
rs.getBigDecimal("available_balance_after"), rs.getString("source_type"), rs.getString("source_no"),
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
rs.getString("source_type"), rs.getString("source_no"),
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
}
@@ -364,6 +582,23 @@ public class FinanceWalletService {
}
return value.setScale(2, RoundingMode.UNNECESSARY);
}
private static BigDecimal nonNegativeMoney(BigDecimal value, String label) {
if (value == null || value.signum() < 0 || value.scale() > 2
|| value.compareTo(new BigDecimal("999999999999.99")) > 0) {
throw bad(label + "不能为负数且最多保留两位小数");
}
return value.setScale(2, RoundingMode.UNNECESSARY);
}
private static String businessType(String value) {
String normalized = upper(value);
if (!normalized.matches("[A-Z][A-Z0-9_]{1,31}")) throw bad("业务类型格式无效");
return normalized;
}
private static String businessNo(String value) {
String normalized = clean(value, 128);
if (normalized == null) throw bad("业务单号不能为空");
return normalized;
}
private static String paymentMethod(String value) {
String normalized = upper(value);
if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效");