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
+1 -1
View File
@@ -15,7 +15,7 @@
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
| 客户资金账户与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;当前只做静态收款码、截图申请、平台审核和手工充扣,消费与支付回调尚未接入 |
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
@@ -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("付款方式无效");
+35 -6
View File
@@ -436,14 +436,17 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
-- 一件代发客户资金账户:充值先行,订单消费与冻结/解冻后续接入。
-- 一件代发客户资金账户:实际余额优先,授信额度补足;冻结后结算或解冻。
CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结金额',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '可用余额=实际余额-冻结金额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -453,7 +456,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND actual_balance = frozen_balance + available_balance)
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -487,11 +490,14 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费',
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额',
available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额',
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -507,5 +513,28 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND actual_balance_after = frozen_balance_after + available_balance_after)
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
business_type VARCHAR(32) NOT NULL COMMENT '调用方业务类型',
business_no VARCHAR(128) NOT NULL COMMENT '调用方业务单号',
amount DECIMAL(20,2) NOT NULL COMMENT '冻结总额',
actual_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用实际资金',
credit_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用授信',
status VARCHAR(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放',
freeze_ledger_id BIGINT UNSIGNED NULL COMMENT '冻结流水ID',
action_ledger_id BIGINT UNSIGNED NULL COMMENT '结算或解冻流水ID',
remark VARCHAR(500) NULL COMMENT '业务说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间',
completed_at DATETIME(3) NULL COMMENT '结算或释放完成时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_hold_business (customer_id,business_type,business_no),
KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id),
CONSTRAINT chk_fin_wallet_hold_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount),
CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED'))
) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期';
@@ -9,6 +9,7 @@ import com.baiyue.finance.service.FinanceEventException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,7 +23,7 @@ import static org.mockito.Mockito.when;
class FinanceCustomerAccountServiceTest {
@Test
void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() {
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
@@ -31,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
1, 20, 1, 1)));
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
@@ -40,6 +41,9 @@ class FinanceCustomerAccountServiceTest {
assertThat(row.contactPhone()).isEqualTo("13800138000");
assertThat(row.outstandingReceivable()).isZero();
assertThat(row.pendingBilling()).isZero();
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@@ -52,7 +56,7 @@ class FinanceCustomerAccountServiceTest {
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
1, 20, 1, 1)));
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
@@ -68,7 +72,7 @@ class FinanceCustomerAccountServiceTest {
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
@@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest {
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(20);
assertThat(columnCount).isEqualTo(310);
assertThat(tableCount).isEqualTo(21);
assertThat(columnCount).isEqualTo(330);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -1,10 +1,8 @@
package com.baiyue.finance;
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -32,6 +30,8 @@ class FinanceWalletBehaviorTest {
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
assertThat(account.availableBalance()).isEqualByComparingTo("0.00");
assertThat(account.creditLimit()).isEqualByComparingTo("0.00");
assertThat(account.availableCredit()).isEqualByComparingTo("0.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@@ -47,7 +47,8 @@ class FinanceWalletBehaviorTest {
@Test
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "", "cn", "development").channels();
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
BigDecimal.ZERO, "cn", "development").channels();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
@@ -68,6 +69,9 @@ class FinanceWalletBehaviorTest {
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
when(rs.getInt("version")).thenReturn(2);
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
return mapper.mapRow(rs, 0);
@@ -81,8 +85,45 @@ class FinanceWalletBehaviorTest {
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
}
@Test
void configuredCreditLimitIsUsedForAFirstAccountRead() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
assertThat(deducted.creditUsed()).isEqualByComparingTo("20.00");
assertThat(deducted.availableCredit()).isEqualByComparingTo("30.00");
assertThat(deducted.availableBalance()).isEqualByComparingTo("30.00");
WalletAccount recharged = service.rechargeAccount(deducted, new BigDecimal("25.00"));
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
assertThat(recharged.actualBalance()).isEqualByComparingTo("5.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("55.00");
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
BigDecimal defaultCreditLimit) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, "test", "test");
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
defaultCreditLimit, "test", "test");
}
}
@@ -14,19 +14,23 @@ class FinanceWalletContractTest {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
.contains("actual_balance = frozen_balance + available_balance")
.contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
.contains("credit_limit >= credit_used+credit_frozen")
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
.contains("status IN ('PENDING','APPROVED','REJECTED')")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
.contains("amount <> 0")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status");
.contains("idx_fin_recharge_customer_status")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
.contains("amount=actual_frozen_amount+credit_frozen_amount");
}
@Test
void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception {
String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java"));
String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java"));
String internal = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java"));
assertThat(client)
.contains("user.tenantId()")
.contains("merchant:finance:account:view")
@@ -36,6 +40,13 @@ class FinanceWalletContractTest {
assertThat(platform)
.contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust")
.contains("/accounts/{customerId}/credit-limit")
.contains("Idempotency-Key");
assertThat(internal)
.contains("/internal/finance/v1/wallet")
.contains("X-Internal-Service-Token")
.contains("/holds/{businessType}/{businessNo}/settle")
.contains("/holds/{businessType}/{businessNo}/release")
.contains("Idempotency-Key");
}
}