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); }
}