756 lines
49 KiB
Java
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); }
|
|
}
|