Files
baiyue-firstleg/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java
T

756 lines
49 KiB
Java

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;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class FinanceWalletService {
private static final Set<String> ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final Set<String> IMAGE_TYPES = Set.of("image/jpeg", "image/png");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceFileClient fileClient;
private final FinanceDocumentNumberGenerator numberGenerator;
private final String internalToken;
private final String rechargeQrCodeUrl;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
FinanceDocumentNumberGenerator numberGenerator,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.qr-code-url:}") String rechargeQrCodeUrl,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.rechargeQrCodeUrl = rechargeQrCode(rechargeQrCodeUrl, development);
}
private static String rechargeQrCode(String configured, boolean development) {
String value = blankToNull(configured);
return value == null && development ? "/customer-recharge-qr.svg" : value;
}
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List<WalletAccount> rows = jdbc.query(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,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) {
requirePositive(customerId, "客户"); validatePage(page, size);
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=?", Long.class, customerId);
List<WalletLedgerEntry> rows = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(rs, n) -> ledgerRow(rs), customerId, size, (page - 1) * size);
return WalletPage.of(rows, total == null ? 0 : total, page, size);
}
public WalletPage<RechargeRequest> rechargeRequests(long customerId, int page, int size) {
requirePositive(customerId, "客户"); validatePage(page, size);
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_recharge_request WHERE customer_id=?", Long.class, customerId);
List<RechargeRequest> rows = jdbc.query(
"SELECT * FROM fin_recharge_request WHERE customer_id=? ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END,created_at DESC,id DESC LIMIT ? OFFSET ?",
(rs, n) -> rechargeRow(rs), customerId, size, (page - 1) * size);
return WalletPage.of(rows, total == null ? 0 : total, page, size);
}
public RechargeChannel channel() {
return new RechargeChannel(rechargeQrCodeUrl != null, rechargeQrCodeUrl);
}
@Transactional
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
String idempotencyKey, BigDecimal amount,
MultipartFile file) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
BigDecimal normalizedAmount = money(amount);
RechargeChannel channel = channel();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", "收款二维码未配置");
validateImage(file);
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(),
"fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()),
"size", file.getSize(), "fileHash", fileHash(file)));
Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash);
if (replay != null) return requiredRecharge(replay, customerId);
ApiResponse<FinanceFileClient.Metadata> upload;
try {
upload = fileClient.upload(internalToken, operatorId, customerId, customerId, file);
} catch (RuntimeException exception) {
throw error("FIN_503", "付款截图暂时无法保存,请重试");
}
if (upload == null || !upload.successful() || upload.data() == null || upload.data().fileId() == null) {
throw error("FIN_503", "付款截图暂时无法保存,请重试");
}
String requestNo = numberGenerator.next("RC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,'PENDING',0,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, requestNo);
statement.setBigDecimal(3, normalizedAmount);
statement.setLong(4, upload.data().fileId()); statement.setString(5, upload.data().fileName());
statement.setLong(6, operatorId); statement.setString(7, clean(operatorName, 128));
return statement;
}, holder);
long requestId = generatedId(holder);
complete(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestId);
return requiredRecharge(requestId, customerId);
}
@Transactional
public RechargeRequest reviewRecharge(long requestId, long operatorId, String operatorName,
String idempotencyKey, RechargeReviewRequest request) {
requirePositive(requestId, "充值申请"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedVersion() < 0) throw bad("审核请求无效");
String decision = upper(request.decision());
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw bad("审核决定无效");
if ("REJECT".equals(decision) && !StringUtils.hasText(request.remark())) throw bad("驳回时必须填写原因");
RechargeRequest before = requiredRecharge(requestId, null);
String requestHash = hash(Map.of("requestId", requestId, "request", request));
Long replay = reserve(customerIdOf(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestHash);
if (replay != null) return requiredRecharge(replay, null);
int updated = jdbc.update(
"UPDATE fin_recharge_request SET status=?,version=version+1,reviewed_by=?,reviewed_by_name=?,review_remark=?,reviewed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='PENDING' AND version=?",
"APPROVE".equals(decision) ? "APPROVED" : "REJECTED", operatorId, clean(operatorName, 128),
clean(request.remark(), 500), requestId, request.expectedVersion());
if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化");
if ("APPROVE".equals(decision)) {
WalletAccount account = accountForUpdate(beforeCustomerId(requestId));
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(), null, null,
operatorId, operatorName, request.remark());
}
RechargeRequest after = requiredRecharge(requestId, null);
audit(beforeCustomerId(requestId), "RECHARGE_REQUEST", requestId, "RECHARGE_" + decision,
request.remark(), before, after, operatorId, operatorName);
complete(beforeCustomerId(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestId);
return after;
}
@Transactional
public WalletLedgerEntry adjust(long customerId, long operatorId, String operatorName,
String idempotencyKey, AccountAdjustmentRequest request) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("账户调整请求无效");
String type = upper(request.type());
if (!Set.of("RECHARGE", "DEDUCTION").contains(type)) throw bad("账户调整类型无效");
BigDecimal amount = money(request.amount());
if (request.occurredDate() == null) throw bad("发生日期不能为空");
if (request.occurredDate().isAfter(LocalDate.now())) throw bad("发生日期不能晚于今天");
String adjustmentPaymentMethod = upper(request.paymentMethod());
if (!ADJUSTMENT_PAYMENT_METHODS.contains(adjustmentPaymentMethod)) throw bad("充值或扣款方式无效");
Long replay = reserve(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", 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 ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
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, 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,
account(customerId), operatorId, operatorName);
complete(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, entry.ledgerId());
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(), "授信额度");
LocalDate expiresOn = request.creditExpiresOn();
if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
throw bad("授信到期日必须晚于今天");
}
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", "账户余额版本已变化,请刷新后重试");
}
BigDecimal existingDebt = before.actualBalance().signum() < 0
? before.actualBalance().negate() : before.creditUsed();
if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
}
BigDecimal delta = limit.subtract(before.creditLimit());
if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
BigDecimal carriedDebt = existingDebt;
WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
carriedDebt, before.creditFrozen(), expiresOn, 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())).max(BigDecimal.ZERO);
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.creditExpiresOn(), 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);
}
/** Settles one estimate without releasing its protection before checking any shortfall. */
@Transactional
public WalletHold settleActual(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, BigDecimal actualAmount, String remark) {
requirePositive(customerId, "客户");
BigDecimal amount = money(actualAmount);
String businessType = businessType(rawBusinessType);
String businessNo = businessNo(rawBusinessNo);
String operation = "WALLET_HOLD_SETTLE_ACTUAL";
String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType,
"businessNo", businessNo, "amount", amount, "remark", String.valueOf(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", "资金冻结记录已处理");
WalletAccount after = settleActualAccount(before, hold, amount);
updateAccount(customerId, after, before.version(), 0);
WalletLedgerEntry ledger = insertLedger(customerId, "CONSUMPTION", amount.negate(), after,
businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", remark);
// The hold retains the original estimate; its action ledger records the actual charge.
int updated = jdbc.update("UPDATE fin_wallet_hold SET status='SETTLED',action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'",
ledger.ledgerId(), hold.holdId());
if (updated != 1) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
audit(customerId, "WALLET_HOLD", hold.holdId(), "SETTLE_ACTUAL", remark, before, after, 0, "业务系统");
complete(customerId, 0, operation, idempotencyKey, hold.holdId());
return requiredHold(hold.holdId(), customerId);
}
/** Refunds a successful consumption; recharge allocation rules apply without creating a recharge. */
@Transactional
public WalletLedgerEntry refundConsumption(long customerId, long originalLedgerId, String refundNo,
String idempotencyKey, BigDecimal refundAmount,
long operatorId, String operatorName, String remark) {
requirePositive(customerId, "客户");
requirePositive(originalLedgerId, "原扣款流水");
String reference = businessNo(refundNo);
BigDecimal amount = money(refundAmount);
if (!StringUtils.hasText(remark)) throw bad("退款原因不能为空");
String requestHash = hash(Map.of("customerId", customerId, "originalLedgerId", originalLedgerId,
"refundNo", reference, "amount", amount, "remark", remark));
Long replay = reserve(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, requestHash);
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
List<WalletLedgerEntry> originals = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), originalLedgerId, customerId);
if (originals.isEmpty()) throw error("FIN-WALLET-404", "原扣款流水不存在");
WalletLedgerEntry original = originals.get(0);
if (!"CONSUMPTION".equals(original.changeType()) || original.amount().signum() >= 0) {
throw bad("仅可对成功业务扣款发起关联退款");
}
List<WalletLedgerEntry> existing = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND source_no=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), customerId, reference);
if (!existing.isEmpty()) {
WalletLedgerEntry refunded = existing.get(0);
if (refunded.amount().compareTo(amount) != 0 || !original.businessNo().equals(refunded.referenceNo())) {
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一退款单对应的原扣款或金额不一致");
}
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, refunded.ledgerId());
return refunded;
}
// Locking reads see refunds committed while this transaction waited for the account lock,
// even under MySQL REPEATABLE READ after the idempotency lookup established a snapshot.
BigDecimal refunded = jdbc.queryForList(
"SELECT amount FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND reference_no=? FOR UPDATE",
BigDecimal.class, customerId, original.businessNo()).stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (refunded.add(amount).compareTo(original.amount().negate()) > 0) {
throw error("FIN-WALLET-REFUND-EXCEEDED", "退款金额超过原扣款剩余可退金额");
}
WalletAccount after = rechargeAccount(before, amount);
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry result = insertLedger(customerId, "REFUND", amount, after,
"BUSINESS_REFUND", reference, LocalDate.now(), null, original.businessNo(),
operatorId, operatorName, remark);
audit(customerId, "WALLET_LEDGER", originalLedgerId, "REFUND", remark, before, after, operatorId, operatorName);
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, result.ledgerId());
return result;
}
WalletAccount settleActualAccount(WalletAccount before, WalletHold hold, BigDecimal actualAmount) {
BigDecimal amount = money(actualAmount);
BigDecimal extra = amount.subtract(hold.amount()).max(BigDecimal.ZERO);
if (extra.signum() > 0 && before.availableBalance().compareTo(extra) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "实际费用补差所需的可用余额和授信额度不足");
}
BigDecimal actual = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (before.creditActive()) {
BigDecimal reservedCharge = amount.min(hold.amount());
BigDecimal cashCharge = reservedCharge.min(hold.actualFrozenAmount());
BigDecimal freeCash = actual.subtract(before.frozenBalance().subtract(before.creditFrozen()))
.max(BigDecimal.ZERO);
cashCharge = cashCharge.add(extra.min(freeCash));
actual = actual.subtract(cashCharge);
creditUsed = creditUsed.add(amount.subtract(cashCharge));
} else {
// Previously reserved credit still settles after expiry, without granting new credit.
actual = actual.subtract(amount);
}
return accountState(actual, before.frozenBalance().subtract(hold.amount()), before.creditLimit(),
creditUsed, before.creditFrozen().subtract(hold.creditFrozenAmount()),
before.creditExpiresOn(), before.version());
}
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 = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (settle && before.creditActive()) {
actual = actual.subtract(hold.actualFrozenAmount());
creditUsed = creditUsed.add(hold.creditFrozenAmount());
} else if (settle) {
actual = actual.subtract(hold.amount());
}
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
creditFrozen, before.creditExpiresOn(), 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);
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
}
public ResponseEntity<byte[]> clientVoucher(long customerId, long requestId) {
RechargeRequest request = requiredRecharge(requestId, customerId);
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
}
private WalletAccount accountForUpdate(long 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,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
customerId);
return jdbc.queryForObject(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> accountRow(rs), customerId);
}
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=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
WalletAccount account,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, long operatorId,
String operatorName, String remark) {
String businessNo = numberGenerator.next("AC");
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,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_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, 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.setObject(11, account.creditExpiresOn());
statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
statement.setString(19, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
private WalletLedgerEntry requiredLedger(long ledgerId, long customerId) {
List<WalletLedgerEntry> rows = jdbc.query("SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=?",
(rs, n) -> ledgerRow(rs), ledgerId, customerId);
if (rows.isEmpty()) throw error("FIN-WALLET-404", "账户流水不存在");
return rows.get(0);
}
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
before.creditExpiresOn(), before.version());
}
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
if (!before.creditActive()) {
return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
}
BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
BigDecimal actualDeduction = amount.min(actualAvailable);
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
before.creditFrozen(), before.creditExpiresOn(), before.version());
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen,
LocalDate creditExpiresOn, int version) {
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
&& !creditExpiresOn.isBefore(LocalDate.now());
if (!creditActive && creditUsed.signum() > 0) {
actual = actual.subtract(creditUsed);
creditUsed = BigDecimal.ZERO.setScale(2);
}
BigDecimal availableCredit = creditActive
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
BigDecimal available = creditActive
? actual.subtract(actualFrozen).add(availableCredit)
: actual.subtract(frozen);
if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
|| creditUsed.signum() < 0 || actualFrozen.signum() < 0
|| (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
throw error("FIN-WALLET-409", "账户余额状态无效");
}
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
}
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
timestamp(rs.getTimestamp("updated_at")));
}
private WalletAccount zeroAccount() {
BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new WalletAccount("CNY", zero, zero, zero, zero, zero,
zero, zero, null, false, 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};
List<RechargeRequest> rows = jdbc.query(sql, (rs, n) -> rechargeRow(rs), args);
if (rows.isEmpty()) throw error("FIN-WALLET-404", "充值申请不存在");
return rows.get(0);
}
private long customerIdOf(long requestId) { return beforeCustomerId(requestId); }
private long beforeCustomerId(long requestId) {
Long value = jdbc.queryForObject("SELECT customer_id FROM fin_recharge_request WHERE id=?", Long.class, requestId);
if (value == null || value <= 0) throw error("FIN-WALLET-404", "充值申请不存在");
return value;
}
private Long reserve(long customerId, long operatorId, String operation, String key, String requestHash) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) throw bad("Idempotency-Key不能为空且不能超过128字符");
String normalizedKey = key.trim();
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, normalizedKey);
if (!existing.isEmpty()) return replay(existing.get(0), requestHash);
try {
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
customerId, operatorId, operation, normalizedKey, requestHash);
return null;
} catch (DuplicateKeyException exception) {
Map<String, Object> row = jdbc.queryForMap(
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, normalizedKey);
return replay(row, requestHash);
}
}
private Long replay(Map<String, Object> row, String requestHash) {
if (!requestHash.equals(String.valueOf(row.get("request_hash")))) throw error("FIN-IDEMPOTENCY-CONFLICT", "同一幂等键的请求内容不一致");
if (!"SUCCEEDED".equals(String.valueOf(row.get("status"))) || row.get("result_resource_id") == null) {
throw error("FIN-IDEMPOTENCY-PROCESSING", "相同请求正在处理中,请稍后重试");
}
return ((Number) row.get("result_resource_id")).longValue();
}
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
resourceId, customerId, operatorId, operation, key.trim());
}
private void audit(long customerId, String resourceType, long resourceId, String action, String reason,
Object before, Object after, long operatorId, String operatorName) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, resourceType, resourceId, action, clean(reason, 500), json(before), json(after), operatorId,
clean(operatorName, 128), org.slf4j.MDC.get("requestId"), org.slf4j.MDC.get("traceId"));
}
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.getBigDecimal("credit_limit_after"),
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
rs.getObject("credit_expires_on_after", LocalDate.class),
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")));
}
private RechargeRequest rechargeRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new RechargeRequest(rs.getLong("id"), rs.getString("request_no"), rs.getBigDecimal("amount"),
rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"),
rs.getString("status"), rs.getInt("version"), rs.getString("submitted_by_name"),
rs.getString("reviewed_by_name"), rs.getString("review_remark"), timestamp(rs.getTimestamp("created_at")),
timestamp(rs.getTimestamp("reviewed_at")));
}
private String hash(Object value) {
try { return sha256(objectMapper.writeValueAsBytes(value)); }
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
}
private String fileHash(MultipartFile file) {
try { return sha256(file.getBytes()); }
catch (Exception exception) { throw bad("付款截图无法读取"); }
}
private static String sha256(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw error("FIN_500", "审计快照生成失败"); }
}
private static BigDecimal money(BigDecimal value) {
if (value == null || value.signum() <= 0 || value.scale() > 2 || value.compareTo(new BigDecimal("999999999999.99")) > 0) {
throw bad("金额必须大于0且最多保留两位小数");
}
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 void validateImage(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("请上传付款截图");
if (!IMAGE_TYPES.contains(String.valueOf(file.getContentType()).toLowerCase(Locale.ROOT))) throw bad("付款截图仅支持 JPG 或 PNG");
}
private static void validatePage(int page, int size) { if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效"); }
private static void requirePositive(long value, String label) { if (value <= 0) throw bad(label + "标识无效"); }
private static long generatedId(KeyHolder holder) {
Number key = holder.getKey(); if (key == null || key.longValue() <= 0) throw error("FIN_500", "业务记录创建失败"); return key.longValue();
}
private static String upper(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String blankToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String clean(String value, int max) {
if (!StringUtils.hasText(value)) return null; String normalized = value.trim();
if (normalized.length() > max) throw bad("文本长度不能超过" + max + "个字符"); return normalized;
}
private static LocalDateTime timestamp(Timestamp value) { return value == null ? null : value.toLocalDateTime(); }
private static FinanceEventException bad(String message) { return error("FIN-REQ-400", message); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}