This commit is contained in:
2026-09-19 17:02:04 +08:00
parent 4b69f13d76
commit 9a7b1bb2b8
9 changed files with 42 additions and 65 deletions
@@ -40,19 +40,18 @@ public class ClientFinanceWalletController {
return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size));
}
@GetMapping("/recharge-channels")
@GetMapping("/recharge-channel")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> channels() { return FinanceResponses.success(service.channels()); }
public ApiResponse<?> channel() { return FinanceResponses.success(service.channel()); }
@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));
idempotencyKey, amount, file));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@@ -33,13 +33,12 @@ public final class FinanceWalletDtos {
String remark, LocalDateTime createdAt) { }
public record RechargeRequest(long requestId, String requestNo, BigDecimal amount,
String paymentMethod, long voucherFileId, String voucherFileName,
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 RechargeChannel(boolean enabled, String qrCodeUrl) { }
public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { }
@@ -42,7 +42,6 @@ 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");
@@ -51,15 +50,13 @@ public class FinanceWalletService {
private final FinanceFileClient fileClient;
private final FinanceDocumentNumberGenerator numberGenerator;
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
private final String rechargeQrCodeUrl;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
FinanceDocumentNumberGenerator numberGenerator,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
@Value("${baiyue.finance.recharge.qr-code-url:}") String rechargeQrCodeUrl,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -69,8 +66,7 @@ public class FinanceWalletService {
this.internalToken = internalToken;
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
this.alipayQrCodeUrl = rechargeQrCode(alipayQrCodeUrl, development);
this.rechargeQrCodeUrl = rechargeQrCode(rechargeQrCodeUrl, development);
}
private static String rechargeQrCode(String configured, boolean development) {
@@ -104,22 +100,20 @@ public class FinanceWalletService {
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));
public RechargeChannel channel() {
return new RechargeChannel(rechargeQrCodeUrl != null, rechargeQrCodeUrl);
}
@Transactional
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
String idempotencyKey, BigDecimal amount,
String paymentMethod, MultipartFile file) {
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() + "收款二维码未配置");
RechargeChannel channel = channel();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", "收款二维码未配置");
validateImage(file);
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method,
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(),
"fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()),
"size", file.getSize(), "fileHash", fileHash(file)));
Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash);
@@ -138,12 +132,12 @@ public class FinanceWalletService {
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,?,?)",
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,'PENDING',0,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, requestNo);
statement.setBigDecimal(3, normalizedAmount); statement.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));
statement.setBigDecimal(3, normalizedAmount);
statement.setLong(4, upload.data().fileId()); statement.setString(5, upload.data().fileName());
statement.setLong(6, operatorId); statement.setString(7, clean(operatorName, 128));
return statement;
}, holder);
long requestId = generatedId(holder);
@@ -173,7 +167,7 @@ public class FinanceWalletService {
WalletAccount afterAccount = rechargeAccount(account, before.amount());
updateAccount(beforeCustomerId(requestId), afterAccount, account.version(), operatorId);
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), afterAccount,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), null, null,
operatorId, operatorName, request.remark());
}
RechargeRequest after = requiredRecharge(requestId, null);
@@ -587,7 +581,7 @@ public class FinanceWalletService {
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.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")));
@@ -633,11 +627,6 @@ public class FinanceWalletService {
if (normalized == null) throw bad("业务单号不能为空");
return normalized;
}
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");
@@ -8,5 +8,4 @@ spring:
baiyue:
finance:
recharge:
wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg}
alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg}
qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
@@ -467,7 +467,6 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -485,7 +484,6 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id),
CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0),
CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')),
CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账';
@@ -22,7 +22,7 @@ class FinanceWalletBehaviorTest {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "https://pay.example/wechat.png", "").account(9L);
var account = service(jdbc, "https://pay.example/qr.png").account(9L);
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
@@ -34,28 +34,25 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeChannelsAreEnabledOnlyWhenQrCodeIsConfigured() {
var channels = service(mock(JdbcTemplate.class), "https://pay.example/wechat.png", "").channels();
var channel = service(mock(JdbcTemplate.class), "https://pay.example/qr.png").channel();
assertThat(channels).satisfiesExactly(
wechat -> { assertThat(wechat.paymentMethod()).isEqualTo("WECHAT"); assertThat(wechat.enabled()).isTrue(); },
alipay -> { assertThat(alipay.paymentMethod()).isEqualTo("ALIPAY"); assertThat(alipay.enabled()).isFalse(); });
assertThat(channel.enabled()).isTrue();
assertThat(channel.qrCodeUrl()).isEqualTo("https://pay.example/qr.png");
}
@Test
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
"cn", "development").channels();
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "",
"cn", "development").channel();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
assertThat(channel.qrCodeUrl()).isEqualTo("/customer-recharge-qr.svg");
});
assertThat(channels.enabled()).isTrue();
assertThat(channels.qrCodeUrl()).isEqualTo("/customer-recharge-qr.svg");
}
@Test
void deductionMayMakeActualBalanceNegative() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
@@ -71,7 +68,7 @@ class FinanceWalletBehaviorTest {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "x", "y").account(9L);
var account = service(jdbc, "x").account(9L);
assertThat(account.creditLimit()).isZero();
assertThat(account.availableCredit()).isZero();
@@ -82,7 +79,7 @@ class FinanceWalletBehaviorTest {
@Test
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
@@ -100,7 +97,7 @@ class FinanceWalletBehaviorTest {
@Test
void deductionNeverConsumesCashReservedByAnActiveHold() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
@@ -116,7 +113,7 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 5);
@@ -133,7 +130,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
@@ -144,7 +141,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
@@ -155,7 +152,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateRejectsCreditFrozenAboveTotalFrozenAmount() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
@@ -165,7 +162,7 @@ class FinanceWalletBehaviorTest {
@Test
void expiredCreditNoLongerContributesToAvailableBalance() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
@@ -176,9 +173,9 @@ class FinanceWalletBehaviorTest {
assertThat(account.availableBalance()).isEqualByComparingTo("-20.00");
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
private static FinanceWalletService service(JdbcTemplate jdbc, String qrCodeUrl) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", qrCodeUrl,
"test", "test");
}
}