diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml index 730d2c738..75979d525 100644 --- a/config/nacos/cn/finance-service.yml +++ b/config/nacos/cn/finance-service.yml @@ -7,3 +7,4 @@ spring: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:} baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml index aaa7a7ed9..28e288d48 100644 --- a/config/nacos/local/finance-service.yml +++ b/config/nacos/local/finance-service.yml @@ -7,3 +7,4 @@ spring: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg} baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 53a182db0..f6523c568 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -116,6 +116,7 @@ services: FINANCE_DB_URL: ${FINANCE_DB_URL} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} + FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00} order: <<: *common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 48b561e90..b0071019f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -17608,15 +17608,18 @@ SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; --- 一件代发客户资金账户:充值先行,订单消费与冻结/解冻后续接入。 +-- 一件代发客户资金账户:实际余额优先,授信额度补足;冻结后结算或解冻。 USE baiyue_finance; CREATE TABLE IF NOT EXISTS fin_wallet_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY', actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金', - frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结金额', - available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '可用余额=实际余额-冻结金额', + frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结', + available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额', + credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度', + credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信', + credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本', created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID', updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID', @@ -17626,7 +17629,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account ( UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code), CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0), CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'), - CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND actual_balance = frozen_balance + available_balance) + CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance) ) ENGINE=InnoDB COMMENT='客户CNY资金账户主账'; CREATE TABLE IF NOT EXISTS fin_recharge_request ( @@ -17660,11 +17663,14 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', - change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费', + change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额', frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额', available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额', + credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度', + credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信', + available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信', source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等', source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号', occurred_date DATE NOT NULL COMMENT '业务发生日期', @@ -17680,9 +17686,66 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no), CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0), CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0), - CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND actual_balance_after = frozen_balance_after + available_balance_after) + CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after) ) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水'; +CREATE TABLE IF NOT EXISTS fin_wallet_hold ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + business_type VARCHAR(32) NOT NULL COMMENT '调用方业务类型', + business_no VARCHAR(128) NOT NULL COMMENT '调用方业务单号', + amount DECIMAL(20,2) NOT NULL COMMENT '冻结总额', + actual_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用实际资金', + credit_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用授信', + status VARCHAR(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放', + freeze_ledger_id BIGINT UNSIGNED NULL COMMENT '冻结流水ID', + action_ledger_id BIGINT UNSIGNED NULL COMMENT '结算或解冻流水ID', + remark VARCHAR(500) NULL COMMENT '业务说明', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间', + completed_at DATETIME(3) NULL COMMENT '结算或释放完成时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_wallet_hold_business (customer_id,business_type,business_no), + KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id), + CONSTRAINT chk_fin_wallet_hold_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount), + CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED')) +) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期'; + +DROP PROCEDURE IF EXISTS migrate_fin_wallet_credit; +DELIMITER $$ +CREATE PROCEDURE migrate_fin_wallet_credit() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_limit') THEN + ALTER TABLE fin_wallet_account + ADD COLUMN credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度' AFTER available_balance, + ADD COLUMN credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信' AFTER credit_limit, + ADD COLUMN credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信' AFTER credit_used; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND CONSTRAINT_NAME='chk_fin_wallet_account_balance' AND CONSTRAINT_TYPE='CHECK') THEN + ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance; + END IF; + UPDATE fin_wallet_account + SET available_balance=actual_balance+credit_limit-credit_used-frozen_balance; + ALTER TABLE fin_wallet_account ADD CONSTRAINT chk_fin_wallet_account_balance + CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance); + + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_limit_after') THEN + ALTER TABLE fin_wallet_ledger + ADD COLUMN credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度' AFTER available_balance_after, + ADD COLUMN credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信' AFTER credit_limit_after, + ADD COLUMN available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信' AFTER credit_used_after; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_balance' AND CONSTRAINT_TYPE='CHECK') THEN + ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_balance; + END IF; + ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_balance + CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after); +END$$ +DELIMITER ; +CALL migrate_fin_wallet_credit(); +DROP PROCEDURE IF EXISTS migrate_fin_wallet_credit; + -- 客户资金账户权限初始化。 diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 767321054..c76ab009c 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -15,7 +15,7 @@ | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | | 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM | -| 客户资金账户与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;当前只做静态收款码、截图申请、平台审核和手工充扣,消费与支付回调尚未接入 | +| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 | | 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` | | 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 | | 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` | diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java new file mode 100644 index 000000000..e9eccddfd --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java @@ -0,0 +1,72 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.baiyue.finance.service.FinanceWalletService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/finance/v1/wallet") +public class InternalFinanceWalletController { + private final FinanceWalletService service; + private final String expectedToken; + + public InternalFinanceWalletController(FinanceWalletService service, + @Value("${baiyue.security.internal-service-token}") String expectedToken) { + this.service = service; + this.expectedToken = expectedToken; + } + + @GetMapping("/accounts/{customerId}") + public ApiResponse account(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long customerId) { + check(token); + return FinanceResponses.success(service.account(customerId)); + } + + @PostMapping("/accounts/{customerId}/holds") + public ApiResponse freeze(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, + @RequestBody WalletFreezeRequest request) { + check(token); + return FinanceResponses.success(service.freeze(customerId, idempotencyKey, request)); + } + + @PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/settle") + public ApiResponse settle(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, @PathVariable String businessType, + @PathVariable String businessNo, + @RequestBody(required = false) WalletHoldActionRequest request) { + check(token); + return FinanceResponses.success(service.settle(customerId, businessType, businessNo, + idempotencyKey, request)); + } + + @PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/release") + public ApiResponse release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, @PathVariable String businessType, + @PathVariable String businessNo, + @RequestBody(required = false) WalletHoldActionRequest request) { + check(token); + return FinanceResponses.success(service.release(customerId, businessType, businessNo, + idempotencyKey, request)); + } + + private void check(String token) { + if (!StringUtils.hasText(expectedToken) || token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java index 7b000a9c6..c026b5321 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java @@ -3,6 +3,7 @@ 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.CreditLimitRequest; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest; @@ -48,6 +49,16 @@ public class PlatformFinanceWalletController { idempotencyKey, request)); } + @PutMapping("/accounts/{customerId}/credit-limit") + @PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')") + public ApiResponse creditLimit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long customerId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody CreditLimitRequest request) { + return FinanceResponses.success(service.setCreditLimit(customerId, user.userId(), user.displayName(), + idempotencyKey, request)); + } + @GetMapping("/recharge-requests/{requestId}/voucher") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public ResponseEntity voucher(@PathVariable long requestId) { return service.voucher(requestId); } diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index 458722ae1..17210777c 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -31,7 +31,9 @@ public final class FinancePhase1Dtos { BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost, BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance, - BigDecimal availableBalance, int accountVersion, + BigDecimal availableBalance, BigDecimal creditLimit, + BigDecimal creditUsed, BigDecimal availableCredit, + int accountVersion, long pendingRechargeCount, String status, LocalDateTime lastChangedAt) {} @@ -42,6 +44,8 @@ public final class FinancePhase1Dtos { String remark, String walletBusinessNo, String changeType, BigDecimal walletAmount, BigDecimal actualBalanceAfter, BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter, + BigDecimal creditLimitAfter, BigDecimal creditUsedAfter, + BigDecimal availableCreditAfter, String sourceType, String sourceNo, LocalDate occurredDate, String paymentMethod, String referenceNo) {} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java index 7f2dc16fa..5a8d4fd92 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java @@ -10,6 +10,8 @@ public final class FinanceWalletDtos { public record WalletAccount(String currencyCode, BigDecimal actualBalance, BigDecimal frozenBalance, BigDecimal availableBalance, + BigDecimal creditLimit, BigDecimal creditUsed, + BigDecimal creditFrozen, BigDecimal availableCredit, int version, LocalDateTime updatedAt) { } public record WalletPage(List records, long total, int page, int size, int pages) { @@ -22,6 +24,8 @@ public final class FinanceWalletDtos { public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType, BigDecimal amount, BigDecimal actualBalanceAfter, BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter, + BigDecimal creditLimitAfter, BigDecimal creditUsedAfter, + BigDecimal availableCreditAfter, String sourceType, String sourceNo, LocalDate occurredDate, String paymentMethod, String referenceNo, String operatorName, String remark, LocalDateTime createdAt) { } @@ -40,4 +44,19 @@ public final class FinanceWalletDtos { public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate, String paymentMethod, String referenceNo, String remark, int expectedAccountVersion) { } + + public record CreditLimitRequest(BigDecimal creditLimit, String remark, + int expectedAccountVersion) { } + + public record WalletFreezeRequest(BigDecimal amount, String businessType, + String businessNo, String remark) { } + + public record WalletHoldActionRequest(String remark) { } + + public record WalletHold(long holdId, long customerId, String businessType, + String businessNo, BigDecimal amount, + BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount, + String status, Long freezeLedgerId, Long actionLedgerId, + WalletAccount account, LocalDateTime createdAt, + LocalDateTime completedAt) { } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java index c4c42e293..6d9ecdc3f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -21,12 +21,18 @@ public class FinanceCustomerAccountService { private final JdbcTemplate jdbc; private final IamCustomerClient iamCustomerClient; private final String internalToken; + private final BigDecimal defaultCreditLimit; public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient, - @Value("${baiyue.security.internal-service-token:}") String internalToken) { + @Value("${baiyue.security.internal-service-token:}") String internalToken, + @Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) { this.jdbc = jdbc; this.iamCustomerClient = iamCustomerClient; this.internalToken = internalToken; + if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) { + throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数"); + } + this.defaultCreditLimit = defaultCreditLimit.setScale(2); } public PageEnvelope list(int page, int pageSize, String keyword, String status, Long customerId) { @@ -47,12 +53,13 @@ public class FinanceCustomerAccountService { PageResult customers = response.data(); Map metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList()); List rows = customers.records().stream().map(customer -> { - Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO); + Metrics value = metrics.get(customer.id()); return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(), value.outstandingPayable(), value.actualBalance(), value.frozenBalance(), value.availableBalance(), + value.creditLimit(), value.creditUsed(), value.availableCredit(), value.accountVersion(), value.pendingRechargeCount(), customer.status(), value.lastChangedAt()); }).toList(); @@ -63,7 +70,7 @@ public class FinanceCustomerAccountService { if (customerIds.isEmpty()) return Map.of(); String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); Map result = new HashMap<>(); - customerIds.forEach(id -> result.put(id, new MutableMetrics())); + customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit))); jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) .forEach(row -> metric(result, row, "outstandingReceivable", "amount")); jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray()) @@ -76,7 +83,7 @@ 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,actual_balance,frozen_balance,available_balance,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray()) + jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,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)); @@ -94,6 +101,9 @@ public class FinanceCustomerAccountService { target.actualBalance = decimal(row.get("actual_balance")); target.frozenBalance = decimal(row.get("frozen_balance")); target.availableBalance = decimal(row.get("available_balance")); + target.creditLimit = decimal(row.get("credit_limit")); + target.creditUsed = decimal(row.get("credit_used")); + target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen"))); target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0; } @@ -145,9 +155,17 @@ public class FinanceCustomerAccountService { private BigDecimal actualBalance = BigDecimal.ZERO; private BigDecimal frozenBalance = BigDecimal.ZERO; private BigDecimal availableBalance = BigDecimal.ZERO; + private BigDecimal creditLimit; + private BigDecimal creditUsed = BigDecimal.ZERO; + private BigDecimal availableCredit; private int accountVersion; private long pendingRechargeCount; private LocalDateTime lastChangedAt; + private MutableMetrics(BigDecimal defaultCreditLimit) { + this.creditLimit = defaultCreditLimit; + this.availableCredit = defaultCreditLimit; + this.availableBalance = defaultCreditLimit; + } void set(String field, BigDecimal value) { switch (field) { case "outstandingReceivable" -> outstandingReceivable = value; @@ -160,16 +178,15 @@ public class FinanceCustomerAccountService { } } Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, - actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); } + actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit, + accountVersion, pendingRechargeCount, lastChangedAt); } } private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost, BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance, - BigDecimal availableBalance, int accountVersion, long pendingRechargeCount, + BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed, + BigDecimal availableCredit, 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, BigDecimal.ZERO, BigDecimal.ZERO, - BigDecimal.ZERO, 0, 0, null); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 77c02dff5..efedc01dd 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -76,6 +76,9 @@ public class FinancePhase1OperationService { "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 credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," + + "COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," + + "COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," + "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 ?"; @@ -85,6 +88,7 @@ public class FinancePhase1OperationService { 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"), + money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"), 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); @@ -107,7 +111,7 @@ public class FinancePhase1OperationService { 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(), - null, null, null, null, null, null, null, null, null, null, null); + null, null, null, 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; @@ -120,6 +124,8 @@ public class FinancePhase1OperationService { 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.getBigDecimal("credit_limit_after"), rs.getBigDecimal("credit_used_after"), + rs.getBigDecimal("available_credit_after"), rs.getString("source_type"), rs.getString("source_no"), occurredDate, rs.getString("payment_method"), rs.getString("reference_no")); }, customerId).stream().filter(entry -> diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java index bcde3a51c..29faab5a1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -3,10 +3,14 @@ package com.baiyue.finance.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.finance.client.FinanceFileClient; import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest; import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry; import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage; import com.fasterxml.jackson.databind.ObjectMapper; @@ -49,6 +53,7 @@ public class FinanceWalletService { private final String internalToken; private final String wechatQrCodeUrl; private final String alipayQrCodeUrl; + private final BigDecimal defaultCreditLimit; public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceFileClient fileClient, @@ -56,6 +61,7 @@ public class FinanceWalletService { @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.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit, @Value("${spring.profiles.active:}") String activeProfiles, @Value("${DEPLOY_ENV:}") String deployEnvironment) { this.jdbc = jdbc; @@ -63,6 +69,7 @@ public class FinanceWalletService { this.fileClient = fileClient; this.numberGenerator = numberGenerator; this.internalToken = internalToken; + this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度"); boolean development = "development".equalsIgnoreCase(deployEnvironment.trim()) || List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase); this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development); @@ -77,12 +84,9 @@ public class FinanceWalletService { public WalletAccount account(long customerId) { requirePositive(customerId, "客户"); List 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); + "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?", + (rs, n) -> accountRow(rs), customerId); + return rows.isEmpty() ? zeroAccount() : rows.get(0); } public WalletPage ledger(long customerId, int page, int size) { @@ -169,10 +173,9 @@ public class FinanceWalletService { 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, + 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, operatorId, operatorName, request.remark()); } @@ -199,15 +202,15 @@ public class FinanceWalletService { 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); + BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate(); + WalletAccount afterAccount = "RECHARGE".equals(type) + ? rechargeAccount(before, amount) : deductAccount(before, amount); + updateAccount(customerId, afterAccount, before.version(), operatorId); String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION"; - WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available, + WalletLedgerEntry entry = insertLedger(customerId, type, signed, afterAccount, sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(), adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark()); audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before, @@ -216,6 +219,121 @@ public class FinanceWalletService { return entry; } + @Transactional + public WalletLedgerEntry setCreditLimit(long customerId, long operatorId, String operatorName, + String idempotencyKey, CreditLimitRequest request) { + requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人"); + if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效"); + BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度"); + Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request)); + if (replay != null) return requiredLedger(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + if (before.version() != request.expectedAccountVersion()) { + throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试"); + } + if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) { + throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计"); + } + BigDecimal delta = limit.subtract(before.creditLimit()); + if (delta.signum() == 0) throw bad("授信额度未发生变化"); + WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit, + before.creditUsed(), before.creditFrozen(), before.version()); + updateAccount(customerId, after, before.version(), operatorId); + WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after, + "CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null, + operatorId, operatorName, request.remark()); + audit(customerId, "WALLET_ACCOUNT", customerId, "CREDIT_LIMIT_UPDATE", request.remark(), + before, account(customerId), operatorId, operatorName); + complete(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, entry.ledgerId()); + return entry; + } + + @Transactional + public WalletHold freeze(long customerId, String idempotencyKey, WalletFreezeRequest request) { + requirePositive(customerId, "客户"); + if (request == null) throw bad("冻结请求无效"); + BigDecimal amount = money(request.amount()); + String businessType = businessType(request.businessType()); + String businessNo = businessNo(request.businessNo()); + String requestHash = hash(Map.of("customerId", customerId, "amount", amount, + "businessType", businessType, "businessNo", businessNo, + "remark", String.valueOf(request.remark()))); + Long replay = reserve(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletHold existing = findHold(customerId, businessType, businessNo, false); + if (existing != null) { + if (existing.amount().compareTo(amount) != 0) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号的冻结金额不一致"); + } + complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, existing.holdId()); + return withAccount(existing); + } + WalletAccount before = accountForUpdate(customerId); + if (before.availableBalance().compareTo(amount) < 0) { + throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足"); + } + BigDecimal actualAvailable = before.actualBalance() + .subtract(before.frozenBalance().subtract(before.creditFrozen())); + BigDecimal actualFrozen = amount.min(actualAvailable); + BigDecimal creditFrozen = amount.subtract(actualFrozen); + WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount), + before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version()); + updateAccount(customerId, after, before.version(), 0); + long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen, + clean(request.remark(), 500)); + WalletLedgerEntry ledger = insertLedger(customerId, "FREEZE", amount.negate(), after, + businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", request.remark()); + jdbc.update("UPDATE fin_wallet_hold SET freeze_ledger_id=? WHERE id=?", ledger.ledgerId(), holdId); + audit(customerId, "WALLET_HOLD", holdId, "FREEZE", request.remark(), before, after, 0, "业务系统"); + complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, holdId); + return requiredHold(holdId, customerId); + } + + @Transactional + public WalletHold settle(long customerId, String businessType, String businessNo, + String idempotencyKey, WalletHoldActionRequest request) { + return completeHold(customerId, businessType, businessNo, idempotencyKey, request, true); + } + + @Transactional + public WalletHold release(long customerId, String businessType, String businessNo, + String idempotencyKey, WalletHoldActionRequest request) { + return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false); + } + + private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo, + String idempotencyKey, WalletHoldActionRequest request, boolean settle) { + requirePositive(customerId, "客户"); + String businessType = businessType(rawBusinessType); + String businessNo = businessNo(rawBusinessNo); + String operation = settle ? "WALLET_HOLD_SETTLE" : "WALLET_HOLD_RELEASE"; + String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType, + "businessNo", businessNo, "remark", String.valueOf(request == null ? null : request.remark()))); + Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + WalletHold hold = findHold(customerId, businessType, businessNo, true); + if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance(); + BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed(); + BigDecimal frozen = before.frozenBalance().subtract(hold.amount()); + BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount()); + WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed, + creditFrozen, before.version()); + updateAccount(customerId, after, before.version(), 0); + String action = settle ? "SETTLED" : "RELEASED"; + WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE", + settle ? hold.amount().negate() : hold.amount(), after, businessType, businessNo, + LocalDate.now(), null, businessNo, 0, "业务系统", request == null ? null : request.remark()); + jdbc.update("UPDATE fin_wallet_hold SET status=?,action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'", + action, ledger.ledgerId(), hold.holdId()); + audit(customerId, "WALLET_HOLD", hold.holdId(), action, + request == null ? null : request.remark(), before, after, 0, "业务系统"); + complete(customerId, 0, operation, idempotencyKey, hold.holdId()); + return requiredHold(hold.holdId(), customerId); + } + public ResponseEntity voucher(long requestId) { RechargeRequest request = requiredRecharge(requestId, null); long customerId = beforeCustomerId(requestId); @@ -228,23 +346,22 @@ public class FinanceWalletService { } 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); + jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)", + customerId, defaultCreditLimit, defaultCreditLimit); 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); + "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE", + (rs, n) -> accountRow(rs), 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); + private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) { + int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?", + account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(), + account.creditUsed(), account.creditFrozen(), 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, + WalletAccount account, String sourceType, String sourceNo, LocalDate occurredDate, String paymentMethod, String referenceNo, long operatorId, String operatorName, String remark) { @@ -252,14 +369,17 @@ public class FinanceWalletService { 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,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; + statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance()); + statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance()); + statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed()); + statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType); + statement.setString(12, sourceNo); statement.setObject(13, occurredDate); + statement.setString(14, paymentMethod); statement.setString(15, referenceNo); + statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128)); + statement.setString(18, clean(remark, 500)); return statement; }, holder); return requiredLedger(generatedId(holder), customerId); } @@ -271,6 +391,102 @@ public class FinanceWalletService { return rows.get(0); } + WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) { + BigDecimal creditRepayment = amount.min(before.creditUsed()); + return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(), + before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version()); + } + + WalletAccount deductAccount(WalletAccount before, BigDecimal amount) { + BigDecimal actualAvailable = before.actualBalance() + .subtract(before.frozenBalance().subtract(before.creditFrozen())); + BigDecimal actualDeduction = amount.min(actualAvailable); + BigDecimal creditDeduction = amount.subtract(actualDeduction); + return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(), + before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version()); + } + + WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit, + BigDecimal creditUsed, BigDecimal creditFrozen, int version) { + BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen); + BigDecimal actualFrozen = frozen.subtract(creditFrozen); + BigDecimal available = actual.subtract(actualFrozen).add(availableCredit); + if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0 + || creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0 + || availableCredit.signum() < 0 || available.signum() < 0) { + throw error("FIN-WALLET-409", "账户余额状态无效"); + } + return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed, + creditFrozen, availableCredit, version, null); + } + + private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException { + BigDecimal creditLimit = rs.getBigDecimal("credit_limit"); + BigDecimal creditUsed = rs.getBigDecimal("credit_used"); + BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen"); + return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"), + rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed, + creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"), + timestamp(rs.getTimestamp("updated_at"))); + } + + private WalletAccount zeroAccount() { + BigDecimal zero = BigDecimal.ZERO.setScale(2); + return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero, + zero, defaultCreditLimit, 0, null); + } + + private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount, + BigDecimal actualFrozen, BigDecimal creditFrozen, String remark) { + KeyHolder holder = new GeneratedKeyHolder(); + try { + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_wallet_hold(customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status,remark) VALUES(?,?,?,?,?,?,'FROZEN',?)", + Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, businessType); + statement.setString(3, businessNo); statement.setBigDecimal(4, amount); + statement.setBigDecimal(5, actualFrozen); statement.setBigDecimal(6, creditFrozen); + statement.setString(7, remark); return statement; + }, holder); + } catch (DuplicateKeyException exception) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号已存在资金冻结记录"); + } + return generatedId(holder); + } + + private WalletHold requiredHold(long holdId, long customerId) { + List rows = jdbc.query("SELECT * FROM fin_wallet_hold WHERE id=? AND customer_id=?", + (rs, n) -> holdRow(rs), holdId, customerId); + if (rows.isEmpty()) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + return withAccount(rows.get(0)); + } + + private WalletHold findHold(long customerId, String businessType, String businessNo, boolean forUpdate) { + String sql = "SELECT * FROM fin_wallet_hold WHERE customer_id=? AND business_type=? AND business_no=?" + + (forUpdate ? " FOR UPDATE" : ""); + List rows = jdbc.query(sql, (rs, n) -> holdRow(rs), customerId, businessType, businessNo); + return rows.isEmpty() ? null : rows.get(0); + } + + private WalletHold withAccount(WalletHold hold) { + return new WalletHold(hold.holdId(), hold.customerId(), hold.businessType(), hold.businessNo(), + hold.amount(), hold.actualFrozenAmount(), hold.creditFrozenAmount(), hold.status(), + hold.freezeLedgerId(), hold.actionLedgerId(), account(hold.customerId()), hold.createdAt(), + hold.completedAt()); + } + + private WalletHold holdRow(java.sql.ResultSet rs) throws java.sql.SQLException { + long customerId = rs.getLong("customer_id"); + Number freezeLedger = (Number) rs.getObject("freeze_ledger_id"); + Number actionLedger = (Number) rs.getObject("action_ledger_id"); + return new WalletHold(rs.getLong("id"), customerId, rs.getString("business_type"), + rs.getString("business_no"), rs.getBigDecimal("amount"), rs.getBigDecimal("actual_frozen_amount"), + rs.getBigDecimal("credit_frozen_amount"), rs.getString("status"), + freezeLedger == null ? null : freezeLedger.longValue(), actionLedger == null ? null : actionLedger.longValue(), + null, timestamp(rs.getTimestamp("created_at")), timestamp(rs.getTimestamp("completed_at"))); + } + private RechargeRequest requiredRecharge(long requestId, Long customerId) { String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?"); Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId}; @@ -328,7 +544,9 @@ public class FinanceWalletService { 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.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"), + rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_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"))); } @@ -364,6 +582,23 @@ public class FinanceWalletService { } return value.setScale(2, RoundingMode.UNNECESSARY); } + private static BigDecimal nonNegativeMoney(BigDecimal value, String label) { + if (value == null || value.signum() < 0 || value.scale() > 2 + || value.compareTo(new BigDecimal("999999999999.99")) > 0) { + throw bad(label + "不能为负数且最多保留两位小数"); + } + return value.setScale(2, RoundingMode.UNNECESSARY); + } + private static String businessType(String value) { + String normalized = upper(value); + if (!normalized.matches("[A-Z][A-Z0-9_]{1,31}")) throw bad("业务类型格式无效"); + return normalized; + } + private static String businessNo(String value) { + String normalized = clean(value, 128); + if (normalized == null) throw bad("业务单号不能为空"); + return normalized; + } private static String paymentMethod(String value) { String normalized = upper(value); if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效"); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index a1d49b76e..6a818f861 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -436,14 +436,17 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment ( ) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录'; --- 一件代发客户资金账户:充值先行,订单消费与冻结/解冻后续接入。 +-- 一件代发客户资金账户:实际余额优先,授信额度补足;冻结后结算或解冻。 CREATE TABLE IF NOT EXISTS fin_wallet_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY', actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金', - frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结金额', - available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '可用余额=实际余额-冻结金额', + frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结', + available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额', + credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度', + credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信', + credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本', created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID', updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID', @@ -453,7 +456,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account ( UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code), CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0), CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'), - CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND actual_balance = frozen_balance + available_balance) + CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance) ) ENGINE=InnoDB COMMENT='客户CNY资金账户主账'; CREATE TABLE IF NOT EXISTS fin_recharge_request ( @@ -487,11 +490,14 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', - change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费', + change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额', frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额', available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额', + credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度', + credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信', + available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信', source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等', source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号', occurred_date DATE NOT NULL COMMENT '业务发生日期', @@ -507,5 +513,28 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no), CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0), CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0), - CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND actual_balance_after = frozen_balance_after + available_balance_after) + CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after) ) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水'; + +CREATE TABLE IF NOT EXISTS fin_wallet_hold ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + business_type VARCHAR(32) NOT NULL COMMENT '调用方业务类型', + business_no VARCHAR(128) NOT NULL COMMENT '调用方业务单号', + amount DECIMAL(20,2) NOT NULL COMMENT '冻结总额', + actual_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用实际资金', + credit_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用授信', + status VARCHAR(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放', + freeze_ledger_id BIGINT UNSIGNED NULL COMMENT '冻结流水ID', + action_ledger_id BIGINT UNSIGNED NULL COMMENT '结算或解冻流水ID', + remark VARCHAR(500) NULL COMMENT '业务说明', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间', + completed_at DATETIME(3) NULL COMMENT '结算或释放完成时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_wallet_hold_business (customer_id,business_type,business_no), + KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id), + CONSTRAINT chk_fin_wallet_hold_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount), + CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED')) +) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期'; diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java index 4628bb385..00981425f 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -9,6 +9,7 @@ import com.baiyue.finance.service.FinanceEventException; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -22,7 +23,7 @@ import static org.mockito.Mockito.when; class FinanceCustomerAccountServiceTest { @Test - void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() { + void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() { JdbcTemplate jdbc = mock(JdbcTemplate.class); IamCustomerClient iam = mock(IamCustomerClient.class); when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); @@ -31,7 +32,7 @@ class FinanceCustomerAccountServiceTest { new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")), 1, 20, 1, 1))); - var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token") + var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00")) .list(1, 20, " Alice ", "active", 900000000000000001L); AccountSummary row = result.data().get(0); @@ -40,6 +41,9 @@ class FinanceCustomerAccountServiceTest { assertThat(row.contactPhone()).isEqualTo("13800138000"); assertThat(row.outstandingReceivable()).isZero(); assertThat(row.pendingBilling()).isZero(); + assertThat(row.creditLimit()).isEqualByComparingTo("8000.00"); + assertThat(row.availableCredit()).isEqualByComparingTo("8000.00"); + assertThat(row.availableBalance()).isEqualByComparingTo("8000.00"); verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); } @@ -52,7 +56,7 @@ class FinanceCustomerAccountServiceTest { new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)), 1, 20, 1, 1))); - new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L); + new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L); var sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class)); @@ -68,7 +72,7 @@ class FinanceCustomerAccountServiceTest { @Test void rejectsUnsupportedFinanceAccountStatus() { - var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); + var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO); assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()) diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 82efc8dc7..b794badf7 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest { 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 78ec93a87..9457d3a25 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest { missing.add(currentTable + "." + column.group(1)); } } - assertThat(tableCount).isEqualTo(20); - assertThat(columnCount).isEqualTo(310); + assertThat(tableCount).isEqualTo(21); + assertThat(columnCount).isEqualTo(330); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java index 0302b60b8..e9ea2908e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java @@ -1,10 +1,8 @@ -package com.baiyue.finance; +package com.baiyue.finance.service; import com.baiyue.finance.client.FinanceFileClient; import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest; -import com.baiyue.finance.service.FinanceDocumentNumberGenerator; -import com.baiyue.finance.service.FinanceEventException; -import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -32,6 +30,8 @@ class FinanceWalletBehaviorTest { assertThat(account.actualBalance()).isEqualByComparingTo("0.00"); assertThat(account.frozenBalance()).isEqualByComparingTo("0.00"); assertThat(account.availableBalance()).isEqualByComparingTo("0.00"); + assertThat(account.creditLimit()).isEqualByComparingTo("0.00"); + assertThat(account.availableCredit()).isEqualByComparingTo("0.00"); verify(jdbc, never()).update(anyString(), any(Object[].class)); } @@ -47,7 +47,8 @@ class FinanceWalletBehaviorTest { @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", "", "", + BigDecimal.ZERO, "cn", "development").channels(); assertThat(channels).allSatisfy(channel -> { assertThat(channel.enabled()).isTrue(); @@ -68,6 +69,9 @@ class FinanceWalletBehaviorTest { when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00")); when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO); when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00")); + when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO); + when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO); + when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO); when(rs.getInt("version")).thenReturn(2); when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00")); return mapper.mapRow(rs, 0); @@ -81,8 +85,45 @@ class FinanceWalletBehaviorTest { verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class)); } + @Test + void configuredCreditLimitIsUsedForAFirstAccountRead() { + 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", new BigDecimal("8000.00")).account(9L); + + assertThat(account.creditLimit()).isEqualByComparingTo("8000.00"); + assertThat(account.availableCredit()).isEqualByComparingTo("8000.00"); + assertThat(account.availableBalance()).isEqualByComparingTo("8000.00"); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO, + new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3); + + WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00")); + assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00"); + assertThat(deducted.creditUsed()).isEqualByComparingTo("20.00"); + assertThat(deducted.availableCredit()).isEqualByComparingTo("30.00"); + assertThat(deducted.availableBalance()).isEqualByComparingTo("30.00"); + + WalletAccount recharged = service.rechargeAccount(deducted, new BigDecimal("25.00")); + assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00"); + assertThat(recharged.actualBalance()).isEqualByComparingTo("5.00"); + assertThat(recharged.availableBalance()).isEqualByComparingTo("55.00"); + } + private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) { + return service(jdbc, wechat, alipay, BigDecimal.ZERO); + } + + private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay, + BigDecimal defaultCreditLimit) { return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(), - mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, "test", "test"); + mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, + defaultCreditLimit, "test", "test"); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java index 35e5f5d85..bddaafc77 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -14,19 +14,23 @@ class FinanceWalletContractTest { String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); assertThat(sql) .contains("CREATE TABLE IF NOT EXISTS fin_wallet_account") - .contains("actual_balance = frozen_balance + available_balance") + .contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance") + .contains("credit_limit >= credit_used+credit_frozen") .contains("CREATE TABLE IF NOT EXISTS fin_recharge_request") .contains("status IN ('PENDING','APPROVED','REJECTED')") .contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger") .contains("amount <> 0") .contains("uk_fin_wallet_ledger_no") - .contains("idx_fin_recharge_customer_status"); + .contains("idx_fin_recharge_customer_status") + .contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold") + .contains("amount=actual_frozen_amount+credit_frozen_amount"); } @Test void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception { String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java")); String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java")); + String internal = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java")); assertThat(client) .contains("user.tenantId()") .contains("merchant:finance:account:view") @@ -36,6 +40,13 @@ class FinanceWalletContractTest { assertThat(platform) .contains("platform:finance:recharge:review") .contains("platform:finance:customer-account:adjust") + .contains("/accounts/{customerId}/credit-limit") + .contains("Idempotency-Key"); + assertThat(internal) + .contains("/internal/finance/v1/wallet") + .contains("X-Internal-Service-Token") + .contains("/holds/{businessType}/{businessNo}/settle") + .contains("/holds/{businessType}/{businessNo}/release") .contains("Idempotency-Key"); } } diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 87c520265..e6f5d0895 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -25,6 +25,7 @@ test('Compose and deploy script expose finance with fail-closed database config' assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/) assert.match(compose, /SERVER_PORT: 9203/) assert.match(compose, /FINANCE_DB_URL: \$\{FINANCE_DB_URL\}/) + assert.match(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: \$\{FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0\.00\}/) assert.match(serviceMap, /^ finance:\s*$/m) assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/)