feat(finance): add customer wallet recharge flow
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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) { }
|
||||
}
|
||||
+39
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+24
-1
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user