feat(finance): add customer wallet recharge flow

This commit is contained in:
2026-09-05 08:34:40 +08:00
parent 6002e4223b
commit 5792630231
24 changed files with 1285 additions and 24 deletions
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
@FeignClient(name="file-service", contextId="financeFileClient", url="${baiyue.clients.file.url:}", path="/internal/file/v1/business-files")
public interface FinanceFileClient {
@@ -13,5 +14,9 @@ public interface FinanceFileClient {
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId,
@PathVariable long customerId, @PathVariable long fileId);
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long customerId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceWalletService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/client/finance/v1")
public class ClientFinanceWalletController {
private final FinanceWalletService service;
public ClientFinanceWalletController(FinanceWalletService service) { this.service = service; }
@GetMapping("/account")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> account(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(service.account(user.tenantId()));
}
@GetMapping("/account/ledger")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> ledger(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return FinanceResponses.success(service.ledger(user.tenantId(), page, size));
}
@GetMapping("/recharge-requests")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> requests(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size));
}
@GetMapping("/recharge-channels")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> channels() { return FinanceResponses.success(service.channels()); }
@PostMapping(value = "/recharge-requests", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> submit(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestParam BigDecimal amount,
@RequestParam String paymentMethod,
@RequestPart("file") MultipartFile file) {
return FinanceResponses.success(service.submitRecharge(user.tenantId(), user.userId(), user.displayName(),
idempotencyKey, amount, paymentMethod, file));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ResponseEntity<byte[]> voucher(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long requestId) {
return service.clientVoucher(user.tenantId(), requestId);
}
}
@@ -0,0 +1,54 @@
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.RechargeReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
import com.baiyue.finance.service.FinanceWalletService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/finance/v1")
public class PlatformFinanceWalletController {
private final FinanceWalletService service;
public PlatformFinanceWalletController(FinanceWalletService service) { this.service = service; }
@GetMapping("/accounts/{customerId}/recharge-requests")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public PageEnvelope<RechargeRequest> requests(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
var result = service.rechargeRequests(customerId, page, pageSize);
return PageEnvelope.success(result.records(), result.page(), result.size(), result.total());
}
@PostMapping("/recharge-requests/{requestId}/review")
@PreAuthorize("hasAuthority('platform:finance:recharge:review')")
public ApiResponse<?> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long requestId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody RechargeReviewRequest request) {
return FinanceResponses.success(service.reviewRecharge(requestId, user.userId(), user.displayName(),
idempotencyKey, request));
}
@PostMapping("/accounts/{customerId}/adjustments")
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long customerId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody AccountAdjustmentRequest request) {
return FinanceResponses.success(service.adjust(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); }
}
@@ -30,13 +30,20 @@ public final class FinancePhase1Dtos {
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal totalCost,
BigDecimal paidCost, BigDecimal outstandingPayable,
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
String documentType, long documentId, String documentNo,
BigDecimal receivableChange, BigDecimal payableChange,
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
String remark, String walletBusinessNo, String changeType,
BigDecimal walletAmount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo) {}
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId, int sourceVersion,
@@ -0,0 +1,43 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class FinanceWalletDtos {
private FinanceWalletDtos() { }
public record WalletAccount(String currencyCode, BigDecimal actualBalance,
BigDecimal frozenBalance, BigDecimal availableBalance,
int version, LocalDateTime updatedAt) { }
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
public static <T> WalletPage<T> of(List<T> records, long total, int page, int size) {
return new WalletPage<>(records, total, page, size,
total == 0 ? 0 : (int) ((total + size - 1) / size));
}
}
public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType,
BigDecimal amount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
public record RechargeRequest(long requestId, String requestNo, BigDecimal amount,
String paymentMethod, long voucherFileId, String voucherFileName,
String status, int version, String submittedByName,
String reviewedByName, String reviewRemark,
LocalDateTime createdAt, LocalDateTime reviewedAt) { }
public record RechargeChannel(String paymentMethod, String displayName,
boolean enabled, String qrCodeUrl) { }
public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { }
public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate,
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
}
@@ -52,6 +52,8 @@ public class FinanceCustomerAccountService {
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
@@ -74,13 +76,38 @@ 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,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,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));
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 7))
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
Map<Long, Metrics> frozen = new HashMap<>();
result.forEach((id, value) -> frozen.put(id, value.freeze()));
return frozen;
}
private static void walletMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
Object id = row.get("customer_id");
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
MutableMetrics target = result.get(number.longValue());
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
target.availableBalance = decimal(row.get("available_balance"));
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
private static void pendingMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
Object id = row.get("customer_id");
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
Object count = row.get("pending_count");
result.get(number.longValue()).pendingRechargeCount = count instanceof Number value ? value.longValue() : 0;
}
private static BigDecimal decimal(Object value) {
return value == null ? BigDecimal.ZERO : value instanceof BigDecimal amount ? amount : new BigDecimal(value.toString());
}
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> row, String field, String valueKey) {
Object id = row.get("customer_id");
if (!(id instanceof Number number)) return;
@@ -115,6 +142,11 @@ public class FinanceCustomerAccountService {
private BigDecimal totalCost = BigDecimal.ZERO;
private BigDecimal paidCost = BigDecimal.ZERO;
private BigDecimal outstandingPayable = BigDecimal.ZERO;
private BigDecimal actualBalance = BigDecimal.ZERO;
private BigDecimal frozenBalance = BigDecimal.ZERO;
private BigDecimal availableBalance = BigDecimal.ZERO;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
void set(String field, BigDecimal value) {
switch (field) {
@@ -127,14 +159,17 @@ public class FinanceCustomerAccountService {
default -> { }
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); }
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, 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, null);
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, 0, 0, null);
}
}
@@ -17,7 +17,7 @@ import java.util.Set;
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
@@ -73,12 +73,19 @@ public class FinancePhase1OperationService {
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id 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'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id 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'))) outstanding_payable," +
"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 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 ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
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"),
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);
}
@@ -99,9 +106,25 @@ public class FinancePhase1OperationService {
BigDecimal receivableChange = row.receivable();
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());
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);
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
BigDecimal derivedOutstanding = outstanding;
projected.addAll(jdbc.query("SELECT * FROM fin_wallet_ledger WHERE customer_id=?", (rs, n) -> {
LocalDate occurredDate = rs.getDate("occurred_date").toLocalDate();
LocalDateTime occurredAt = occurredDate.atStartOfDay();
String type = rs.getString("change_type");
return new LedgerEntry(rs.getLong("id"), occurredAt, type, "WALLET_LEDGER", rs.getLong("id"),
rs.getString("business_no"), BigDecimal.ZERO, BigDecimal.ZERO, derivedOutstanding,
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.getString("source_type"), rs.getString("source_no"), occurredDate,
rs.getString("payment_method"), rs.getString("reference_no"));
}, customerId).stream().filter(entry ->
(businessType == null || businessType.isBlank() || businessType.equals(entry.businessType()))
&& inRange(entry.occurredAt(), startDate, endDate)).toList());
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
.thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed()));
int from = Math.min((page - 1) * pageSize, projected.size());
@@ -0,0 +1,381 @@
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.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.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> PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY");
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 wechatQrCodeUrl;
private final String alipayQrCodeUrl;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
FinanceDocumentNumberGenerator numberGenerator,
@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) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
this.wechatQrCodeUrl = blankToNull(wechatQrCodeUrl);
this.alipayQrCodeUrl = blankToNull(alipayQrCodeUrl);
}
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);
}
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 List<RechargeChannel> channels() {
return List.of(new RechargeChannel("WECHAT", "微信支付", wechatQrCodeUrl != null, wechatQrCodeUrl),
new RechargeChannel("ALIPAY", "支付宝", alipayQrCodeUrl != null, alipayQrCodeUrl));
}
@Transactional
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
String idempotencyKey, BigDecimal amount,
String paymentMethod, MultipartFile file) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
BigDecimal normalizedAmount = money(amount);
String method = paymentMethod(paymentMethod);
RechargeChannel channel = channels().stream().filter(value -> value.paymentMethod().equals(method)).findFirst().orElseThrow();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", channel.displayName() + "收款二维码未配置");
validateImage(file);
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method,
"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,payment_method,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.setString(4, method);
statement.setLong(5, upload.data().fileId()); statement.setString(6, upload.data().fileName());
statement.setLong(7, operatorId); statement.setString(8, 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));
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,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), 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", "账户余额版本已变化,请刷新后重试");
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);
String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION";
WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available,
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;
}
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,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0)", customerId);
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);
}
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);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
BigDecimal actual, BigDecimal frozen, BigDecimal available,
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,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;
}, 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);
}
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.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.getString("payment_method"), 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 String paymentMethod(String value) {
String normalized = upper(value);
if (!PAYMENT_METHODS.contains(normalized)) 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); }
}
+75
View File
@@ -434,3 +434,78 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
) 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 '可用余额=实际余额-冻结金额',
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',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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_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)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审核乐观锁版本',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '客户提交人ID',
submitted_by_name VARCHAR(128) NULL COMMENT '客户提交人名称快照',
reviewed_by BIGINT UNSIGNED NULL COMMENT '平台审核人ID',
reviewed_by_name VARCHAR(128) NULL COMMENT '平台审核人名称快照',
review_remark VARCHAR(500) NULL COMMENT '审核说明或驳回原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
reviewed_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_recharge_request_no (request_no),
KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id),
CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0),
CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')),
CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账';
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消费',
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 '变动后可用余额',
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 '业务发生日期',
payment_method VARCHAR(32) NULL COMMENT '充值或扣款方式',
reference_no VARCHAR(128) NULL COMMENT '收款凭证号或关联单据号',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NULL COMMENT '操作人名称快照',
remark VARCHAR(500) NULL COMMENT '变动说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_ledger_no (business_no),
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
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)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
@@ -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, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(265);
assertThat(tableCount).isEqualTo(20);
assertThat(columnCount).isEqualTo(310);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -0,0 +1,77 @@
package com.baiyue.finance;
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.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FinanceWalletBehaviorTest {
@Test
void missingAccountReadsAsZeroWithoutCreatingData() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "https://pay.example/wechat.png", "").account(9L);
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
assertThat(account.availableBalance()).isEqualByComparingTo("0.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void rechargeChannelsAreEnabledOnlyWhenQrCodeIsConfigured() {
var channels = service(mock(JdbcTemplate.class), "https://pay.example/wechat.png", "").channels();
assertThat(channels).satisfiesExactly(
wechat -> { assertThat(wechat.paymentMethod()).isEqualTo("WECHAT"); assertThat(wechat.enabled()).isTrue(); },
alipay -> { assertThat(alipay.paymentMethod()).isEqualTo("ALIPAY"); assertThat(alipay.enabled()).isFalse(); });
}
@Test
void manualDeductionCannotExceedAvailableBalance() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("currency_code")).thenReturn("CNY");
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.getInt("version")).thenReturn(2);
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
return mapper.mapRow(rs, 0);
});
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay);
}
}
@@ -0,0 +1,41 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceWalletContractTest {
@Test
void schemaKeepsBalancesConsistentAndRechargeReviewTraceable() throws Exception {
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("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");
}
@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"));
assertThat(client)
.contains("user.tenantId()")
.contains("merchant:finance:account:view")
.contains("merchant:finance:recharge:create")
.contains("consumes = \"multipart/form-data\"")
.doesNotContain("@RequestParam long customerId");
assertThat(platform)
.contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust")
.contains("Idempotency-Key");
}
}