feat(finance): add customer wallet recharge flow

This commit is contained in:
2026-09-05 08:34:40 +08:00
parent 6002e4223b
commit 5792630231
24 changed files with 1285 additions and 24 deletions
+2
View File
@@ -5,3 +5,5 @@ spring:
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
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:}
+2
View File
@@ -5,3 +5,5 @@ spring:
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
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:}
@@ -4910,8 +4910,8 @@ BEGIN
'status VARCHAR(16) NOT NULL DEFAULT ''PENDING'',attempt_count INT NOT NULL DEFAULT 0,',
'compensation_count INT NOT NULL DEFAULT 0,next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),',
'lease_owner VARCHAR(128) NULL,lease_until DATETIME(3) NULL,last_error VARCHAR(1000) NULL,',
'dead_at DATETIME(3) NULL,sent_at DATETIME(3) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),',
'updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),',
'dead_at DATETIME(3) NULL,sent_at DATETIME(3) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',',
'updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',',
'PRIMARY KEY(event_id),KEY idx_audit_outbox_publish(status,next_retry_at,lease_until,created_at),',
'KEY idx_audit_outbox_retention(status,sent_at)) ENGINE=InnoDB COMMENT=''可靠审计事件本地发送箱'''
);
@@ -16047,8 +16047,8 @@ CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_template (
template_name VARCHAR(128) NOT NULL, category VARCHAR(64) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
title_template VARCHAR(256) NOT NULL, body_template VARCHAR(10000) NOT NULL,
variable_whitelist_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', version INT NOT NULL DEFAULT 1,
created_by BIGINT NOT NULL, updated_by BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL, updated_by BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_notification_template_code (tenant_id, template_code),
KEY idx_notification_template_list (tenant_id, status, updated_at)
) ENGINE=InnoDB COMMENT='消息模板;首期仅IN_APP';
@@ -16060,7 +16060,7 @@ CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_task (
idempotency_key VARCHAR(128) NOT NULL, source_event_id VARCHAR(128) NULL, source_event_type VARCHAR(128) NULL,
status VARCHAR(16) NOT NULL, recipient_count INT NOT NULL DEFAULT 0, attempt_count INT NOT NULL DEFAULT 0,
scheduled_at DATETIME(3) NULL, sent_at DATETIME(3) NULL, last_error VARCHAR(1000) NULL, created_by BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_notification_task_no (task_no), UNIQUE KEY uk_notification_task_idempotency (idempotency_key),
UNIQUE KEY uk_notification_source_event (source_event_id, source_event_type), KEY idx_notification_task_dispatch (status, scheduled_at, created_at),
KEY idx_notification_task_tenant (tenant_id, created_at)
@@ -16070,7 +16070,7 @@ CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_recipient (
id BIGINT NOT NULL, task_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, membership_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, display_name VARCHAR(128) NOT NULL, app_code VARCHAR(16) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
status VARCHAR(24) NOT NULL DEFAULT 'RESOLVED', failure_reason VARCHAR(1000) NULL, delivered_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_notification_recipient (task_id, membership_id, channel),
KEY idx_notification_recipient_member (tenant_id, membership_id, created_at)
) ENGINE=InnoDB COMMENT='消息接收人不可变快照';
@@ -16080,7 +16080,7 @@ CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_inbox (
membership_id BIGINT NOT NULL, app_code VARCHAR(16) NOT NULL, category VARCHAR(64) NOT NULL,
title VARCHAR(256) NOT NULL, body VARCHAR(10000) NOT NULL, source_type VARCHAR(128) NULL, source_id VARCHAR(128) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'UNREAD', sent_at DATETIME(3) NOT NULL, read_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_notification_inbox_recipient (recipient_id),
KEY idx_notification_inbox_list (tenant_id, membership_id, app_code, status, sent_at),
KEY idx_notification_inbox_category (tenant_id, membership_id, app_code, category, sent_at)
@@ -16089,8 +16089,8 @@ CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_inbox (
CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_event_inbox (
id BIGINT NOT NULL, source_event_id VARCHAR(128) NOT NULL, event_type VARCHAR(128) NOT NULL,
payload_hash CHAR(64) NOT NULL, task_id BIGINT NULL, status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
last_error VARCHAR(1000) NULL, processed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
last_error VARCHAR(1000) NULL, processed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_notification_event (source_event_id, event_type),
KEY idx_notification_event_status (status, updated_at)
) ENGINE=InnoDB COMMENT='业务事件消费幂等记录';
@@ -17606,3 +17606,208 @@ CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
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 '可用余额=实际余额-冻结金额',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND actual_balance = frozen_balance + available_balance)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审核乐观锁版本',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '客户提交人ID',
submitted_by_name VARCHAR(128) NULL COMMENT '客户提交人名称快照',
reviewed_by BIGINT UNSIGNED NULL COMMENT '平台审核人ID',
reviewed_by_name VARCHAR(128) NULL COMMENT '平台审核人名称快照',
review_remark VARCHAR(500) NULL COMMENT '审核说明或驳回原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
reviewed_at DATETIME(3) NULL COMMENT '平台审核完成时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_recharge_request_no (request_no),
KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id),
CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0),
CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')),
CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账';
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费',
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额',
available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
payment_method VARCHAR(32) NULL COMMENT '充值或扣款方式',
reference_no VARCHAR(128) NULL COMMENT '收款凭证号或关联单据号',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NULL COMMENT '操作人名称快照',
remark VARCHAR(500) NULL COMMENT '变动说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_ledger_no (business_no),
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND actual_balance_after = frozen_balance_after + available_balance_after)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
-- 客户资金账户权限初始化。
-- 客户资金账户与充值权限。只读访问和资金写操作使用独立权限。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_wallet_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_wallet_permissions()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5105056 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:finance:recharge:review'))
OR (id = 5105057 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:finance:customer-account:adjust'))
OR (id = 5305020 AND (tenant_id <> 0 OR app_code <> 'MERCHANT'
OR permission_type <> 'MENU'
OR permission_code <> 'merchant:finance:account:view'))
OR (id = 5305021 AND (tenant_id <> 0 OR app_code <> 'MERCHANT'
OR permission_type <> 'BUTTON'
OR permission_code <> 'merchant:finance:recharge:create'))
OR (permission_code = 'platform:finance:recharge:review'
AND (id <> 5105056 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
OR (permission_code = 'platform:finance:customer-account:adjust'
AND (id <> 5105057 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
OR (permission_code = 'merchant:finance:account:view'
AND (id <> 5305020 OR tenant_id <> 0 OR app_code <> 'MERCHANT'))
OR (permission_code = 'merchant:finance:recharge:create'
AND (id <> 5305021 OR tenant_id <> 0 OR app_code <> 'MERCHANT'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:权限ID、编码或类型冲突';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review',
'审核客户充值',NULL,'POST',100,'ACTIVE',1,0),
(5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust',
'调整客户资金账户',NULL,'POST',110,'ACTIVE',1,0),
(5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view',
'资金账户','/finance/account','GET',50,'ACTIVE',1,0),
(5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create',
'提交充值申请',NULL,'POST',10,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
permission_name=VALUES(permission_name), parent_id=VALUES(parent_id),
route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')
AND permission.status='ACTIVE' AND permission.deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'merchant:finance:account:view',
'merchant:finance:recharge:create')
AND permission.status='ACTIVE' AND permission.deleted=0;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_type='BUTTON'
AND parent_id=5105040 AND status='ACTIVE' AND built_in=1 AND deleted=0
AND permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台写权限资源不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND built_in=1 AND deleted=0
AND ((id=5305020 AND permission_type='MENU' AND parent_id=5300000
AND permission_code='merchant:finance:account:view')
OR (id=5305021 AND permission_type='BUTTON' AND parent_id=5305020
AND permission_code='merchant:finance:recharge:create'))) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:客户权限资源不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.status='ACTIVE' AND permission.deleted=0
AND permission.permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台超级管理员授权不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.tenant_id=0 AND permission.app_code='MERCHANT'
AND permission.status='ACTIVE' AND permission.deleted=0
AND permission.permission_code IN (
'merchant:finance:account:view',
'merchant:finance:recharge:create')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:企业负责人授权不完整';
END IF;
END$$
DELIMITER ;
CALL migrate_wallet_permissions();
DROP PROCEDURE IF EXISTS migrate_wallet_permissions;
SELECT '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
+4 -3
View File
@@ -2,7 +2,7 @@
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
- 产品范围先读 `../../docs/财务管理/财务管理产品确认表.md` 与 `财务核心五页产品与接口验收契约.md`,跨服务契约再读 `Firstleg-Finance跨服务契约.md`;专题 README、证据矩阵和测试环境报告只用于状态或验收问题,不作为普通开发首读。
- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
@@ -15,13 +15,14 @@
| 客户只读账单、收款与应收 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/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` |
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 |
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单、充值申请、账户流水分别使用 `REC`、`AR`、`AP`、`RC`、`AC` 前缀 |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` |
| 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
@FeignClient(name="file-service", contextId="financeFileClient", url="${baiyue.clients.file.url:}", path="/internal/file/v1/business-files")
public interface FinanceFileClient {
@@ -13,5 +14,9 @@ public interface FinanceFileClient {
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId,
@PathVariable long customerId, @PathVariable long fileId);
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long customerId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceWalletService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/client/finance/v1")
public class ClientFinanceWalletController {
private final FinanceWalletService service;
public ClientFinanceWalletController(FinanceWalletService service) { this.service = service; }
@GetMapping("/account")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> account(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(service.account(user.tenantId()));
}
@GetMapping("/account/ledger")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> ledger(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return FinanceResponses.success(service.ledger(user.tenantId(), page, size));
}
@GetMapping("/recharge-requests")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ApiResponse<?> requests(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size));
}
@GetMapping("/recharge-channels")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> channels() { return FinanceResponses.success(service.channels()); }
@PostMapping(value = "/recharge-requests", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> submit(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestParam BigDecimal amount,
@RequestParam String paymentMethod,
@RequestPart("file") MultipartFile file) {
return FinanceResponses.success(service.submitRecharge(user.tenantId(), user.userId(), user.displayName(),
idempotencyKey, amount, paymentMethod, file));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
public ResponseEntity<byte[]> voucher(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long requestId) {
return service.clientVoucher(user.tenantId(), requestId);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
import com.baiyue.finance.service.FinanceWalletService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/finance/v1")
public class PlatformFinanceWalletController {
private final FinanceWalletService service;
public PlatformFinanceWalletController(FinanceWalletService service) { this.service = service; }
@GetMapping("/accounts/{customerId}/recharge-requests")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public PageEnvelope<RechargeRequest> requests(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
var result = service.rechargeRequests(customerId, page, pageSize);
return PageEnvelope.success(result.records(), result.page(), result.size(), result.total());
}
@PostMapping("/recharge-requests/{requestId}/review")
@PreAuthorize("hasAuthority('platform:finance:recharge:review')")
public ApiResponse<?> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long requestId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody RechargeReviewRequest request) {
return FinanceResponses.success(service.reviewRecharge(requestId, user.userId(), user.displayName(),
idempotencyKey, request));
}
@PostMapping("/accounts/{customerId}/adjustments")
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long customerId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody AccountAdjustmentRequest request) {
return FinanceResponses.success(service.adjust(customerId, user.userId(), user.displayName(),
idempotencyKey, request));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
}
@@ -30,13 +30,20 @@ public final class FinancePhase1Dtos {
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal totalCost,
BigDecimal paidCost, BigDecimal outstandingPayable,
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
String documentType, long documentId, String documentNo,
BigDecimal receivableChange, BigDecimal payableChange,
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
String remark, String walletBusinessNo, String changeType,
BigDecimal walletAmount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo) {}
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId, int sourceVersion,
@@ -0,0 +1,43 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class FinanceWalletDtos {
private FinanceWalletDtos() { }
public record WalletAccount(String currencyCode, BigDecimal actualBalance,
BigDecimal frozenBalance, BigDecimal availableBalance,
int version, LocalDateTime updatedAt) { }
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
public static <T> WalletPage<T> of(List<T> records, long total, int page, int size) {
return new WalletPage<>(records, total, page, size,
total == 0 ? 0 : (int) ((total + size - 1) / size));
}
}
public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType,
BigDecimal amount, BigDecimal actualBalanceAfter,
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
public record RechargeRequest(long requestId, String requestNo, BigDecimal amount,
String paymentMethod, long voucherFileId, String voucherFileName,
String status, int version, String submittedByName,
String reviewedByName, String reviewRemark,
LocalDateTime createdAt, LocalDateTime reviewedAt) { }
public record RechargeChannel(String paymentMethod, String displayName,
boolean enabled, String qrCodeUrl) { }
public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { }
public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate,
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
}
@@ -52,6 +52,8 @@ public class FinanceCustomerAccountService {
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
@@ -74,13 +76,38 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
.forEach(row -> walletMetric(result, row));
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
.forEach(row -> pendingMetric(result, row));
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 7))
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
Map<Long, Metrics> frozen = new HashMap<>();
result.forEach((id, value) -> frozen.put(id, value.freeze()));
return frozen;
}
private static void walletMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
Object id = row.get("customer_id");
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
MutableMetrics target = result.get(number.longValue());
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
target.availableBalance = decimal(row.get("available_balance"));
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
private static void pendingMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
Object id = row.get("customer_id");
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
Object count = row.get("pending_count");
result.get(number.longValue()).pendingRechargeCount = count instanceof Number value ? value.longValue() : 0;
}
private static BigDecimal decimal(Object value) {
return value == null ? BigDecimal.ZERO : value instanceof BigDecimal amount ? amount : new BigDecimal(value.toString());
}
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> row, String field, String valueKey) {
Object id = row.get("customer_id");
if (!(id instanceof Number number)) return;
@@ -115,6 +142,11 @@ public class FinanceCustomerAccountService {
private BigDecimal totalCost = BigDecimal.ZERO;
private BigDecimal paidCost = BigDecimal.ZERO;
private BigDecimal outstandingPayable = BigDecimal.ZERO;
private BigDecimal actualBalance = BigDecimal.ZERO;
private BigDecimal frozenBalance = BigDecimal.ZERO;
private BigDecimal availableBalance = BigDecimal.ZERO;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
void set(String field, BigDecimal value) {
switch (field) {
@@ -127,14 +159,17 @@ public class FinanceCustomerAccountService {
default -> { }
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); }
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, int accountVersion, long pendingRechargeCount,
LocalDateTime lastChangedAt) {
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, 0, 0, null);
}
}
@@ -17,7 +17,7 @@ import java.util.Set;
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
@@ -73,12 +73,19 @@ public class FinancePhase1OperationService {
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," +
"COALESCE((SELECT actual_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) actual_balance," +
"COALESCE((SELECT frozen_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) frozen_balance," +
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
rs.getString("account_status"), time(rs, "last_changed_at")), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
@@ -99,9 +106,25 @@ public class FinancePhase1OperationService {
BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange);
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark(),
null, null, null, null, null, null, null, null, null, null, null);
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
BigDecimal derivedOutstanding = outstanding;
projected.addAll(jdbc.query("SELECT * FROM fin_wallet_ledger WHERE customer_id=?", (rs, n) -> {
LocalDate occurredDate = rs.getDate("occurred_date").toLocalDate();
LocalDateTime occurredAt = occurredDate.atStartOfDay();
String type = rs.getString("change_type");
return new LedgerEntry(rs.getLong("id"), occurredAt, type, "WALLET_LEDGER", rs.getLong("id"),
rs.getString("business_no"), BigDecimal.ZERO, BigDecimal.ZERO, derivedOutstanding,
rs.getString("operator_name"), rs.getString("remark"), rs.getString("business_no"), type,
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"),
rs.getBigDecimal("frozen_balance_after"), rs.getBigDecimal("available_balance_after"),
rs.getString("source_type"), rs.getString("source_no"), occurredDate,
rs.getString("payment_method"), rs.getString("reference_no"));
}, customerId).stream().filter(entry ->
(businessType == null || businessType.isBlank() || businessType.equals(entry.businessType()))
&& inRange(entry.occurredAt(), startDate, endDate)).toList());
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
.thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed()));
int from = Math.min((page - 1) * pageSize, projected.size());
@@ -0,0 +1,381 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class FinanceWalletService {
private static final Set<String> PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY");
private static final Set<String> ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final Set<String> IMAGE_TYPES = Set.of("image/jpeg", "image/png");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceFileClient fileClient;
private final FinanceDocumentNumberGenerator numberGenerator;
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
FinanceDocumentNumberGenerator numberGenerator,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
this.wechatQrCodeUrl = blankToNull(wechatQrCodeUrl);
this.alipayQrCodeUrl = blankToNull(alipayQrCodeUrl);
}
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List<WalletAccount> rows = jdbc.query(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
timestamp(rs.getTimestamp("updated_at"))), customerId);
return rows.isEmpty() ? new WalletAccount("CNY", BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
BigDecimal.ZERO.setScale(2), 0, null) : rows.get(0);
}
public WalletPage<WalletLedgerEntry> ledger(long customerId, int page, int size) {
requirePositive(customerId, "客户"); validatePage(page, size);
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=?", Long.class, customerId);
List<WalletLedgerEntry> rows = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(rs, n) -> ledgerRow(rs), customerId, size, (page - 1) * size);
return WalletPage.of(rows, total == null ? 0 : total, page, size);
}
public WalletPage<RechargeRequest> rechargeRequests(long customerId, int page, int size) {
requirePositive(customerId, "客户"); validatePage(page, size);
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_recharge_request WHERE customer_id=?", Long.class, customerId);
List<RechargeRequest> rows = jdbc.query(
"SELECT * FROM fin_recharge_request WHERE customer_id=? ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END,created_at DESC,id DESC LIMIT ? OFFSET ?",
(rs, n) -> rechargeRow(rs), customerId, size, (page - 1) * size);
return WalletPage.of(rows, total == null ? 0 : total, page, size);
}
public List<RechargeChannel> channels() {
return List.of(new RechargeChannel("WECHAT", "微信支付", wechatQrCodeUrl != null, wechatQrCodeUrl),
new RechargeChannel("ALIPAY", "支付宝", alipayQrCodeUrl != null, alipayQrCodeUrl));
}
@Transactional
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
String idempotencyKey, BigDecimal amount,
String paymentMethod, MultipartFile file) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
BigDecimal normalizedAmount = money(amount);
String method = paymentMethod(paymentMethod);
RechargeChannel channel = channels().stream().filter(value -> value.paymentMethod().equals(method)).findFirst().orElseThrow();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", channel.displayName() + "收款二维码未配置");
validateImage(file);
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method,
"fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()),
"size", file.getSize(), "fileHash", fileHash(file)));
Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash);
if (replay != null) return requiredRecharge(replay, customerId);
ApiResponse<FinanceFileClient.Metadata> upload;
try {
upload = fileClient.upload(internalToken, operatorId, customerId, customerId, file);
} catch (RuntimeException exception) {
throw error("FIN_503", "付款截图暂时无法保存,请重试");
}
if (upload == null || !upload.successful() || upload.data() == null || upload.data().fileId() == null) {
throw error("FIN_503", "付款截图暂时无法保存,请重试");
}
String requestNo = numberGenerator.next("RC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,payment_method,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,?,'PENDING',0,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, requestNo);
statement.setBigDecimal(3, normalizedAmount); statement.setString(4, method);
statement.setLong(5, upload.data().fileId()); statement.setString(6, upload.data().fileName());
statement.setLong(7, operatorId); statement.setString(8, clean(operatorName, 128));
return statement;
}, holder);
long requestId = generatedId(holder);
complete(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestId);
return requiredRecharge(requestId, customerId);
}
@Transactional
public RechargeRequest reviewRecharge(long requestId, long operatorId, String operatorName,
String idempotencyKey, RechargeReviewRequest request) {
requirePositive(requestId, "充值申请"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedVersion() < 0) throw bad("审核请求无效");
String decision = upper(request.decision());
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw bad("审核决定无效");
if ("REJECT".equals(decision) && !StringUtils.hasText(request.remark())) throw bad("驳回时必须填写原因");
RechargeRequest before = requiredRecharge(requestId, null);
String requestHash = hash(Map.of("requestId", requestId, "request", request));
Long replay = reserve(customerIdOf(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestHash);
if (replay != null) return requiredRecharge(replay, null);
int updated = jdbc.update(
"UPDATE fin_recharge_request SET status=?,version=version+1,reviewed_by=?,reviewed_by_name=?,review_remark=?,reviewed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='PENDING' AND version=?",
"APPROVE".equals(decision) ? "APPROVED" : "REJECTED", operatorId, clean(operatorName, 128),
clean(request.remark(), 500), requestId, request.expectedVersion());
if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化");
if ("APPROVE".equals(decision)) {
WalletAccount account = accountForUpdate(beforeCustomerId(requestId));
BigDecimal actual = account.actualBalance().add(before.amount());
BigDecimal available = account.availableBalance().add(before.amount());
updateAccount(beforeCustomerId(requestId), actual, account.frozenBalance(), available, account.version(), operatorId);
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), actual, account.frozenBalance(), available,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
operatorId, operatorName, request.remark());
}
RechargeRequest after = requiredRecharge(requestId, null);
audit(beforeCustomerId(requestId), "RECHARGE_REQUEST", requestId, "RECHARGE_" + decision,
request.remark(), before, after, operatorId, operatorName);
complete(beforeCustomerId(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestId);
return after;
}
@Transactional
public WalletLedgerEntry adjust(long customerId, long operatorId, String operatorName,
String idempotencyKey, AccountAdjustmentRequest request) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("账户调整请求无效");
String type = upper(request.type());
if (!Set.of("RECHARGE", "DEDUCTION").contains(type)) throw bad("账户调整类型无效");
BigDecimal amount = money(request.amount());
if (request.occurredDate() == null) throw bad("发生日期不能为空");
if (request.occurredDate().isAfter(LocalDate.now())) throw bad("发生日期不能晚于今天");
String adjustmentPaymentMethod = upper(request.paymentMethod());
if (!ADJUSTMENT_PAYMENT_METHODS.contains(adjustmentPaymentMethod)) throw bad("充值或扣款方式无效");
Long replay = reserve(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, hash(request));
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
BigDecimal actual = before.actualBalance().add(signed);
BigDecimal available = before.availableBalance().add(signed);
updateAccount(customerId, actual, before.frozenBalance(), available, before.version(), operatorId);
String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION";
WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available,
sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(),
adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark());
audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before,
account(customerId), operatorId, operatorName);
complete(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, entry.ledgerId());
return entry;
}
public ResponseEntity<byte[]> voucher(long requestId) {
RechargeRequest request = requiredRecharge(requestId, null);
long customerId = beforeCustomerId(requestId);
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
}
public ResponseEntity<byte[]> clientVoucher(long customerId, long requestId) {
RechargeRequest request = requiredRecharge(requestId, customerId);
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
}
private WalletAccount accountForUpdate(long customerId) {
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0)", customerId);
return jdbc.queryForObject(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
timestamp(rs.getTimestamp("updated_at"))), customerId);
}
private void updateAccount(long customerId, BigDecimal actual, BigDecimal frozen, BigDecimal available,
int expectedVersion, long operatorId) {
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
actual, frozen, available, operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
BigDecimal actual, BigDecimal frozen, BigDecimal available,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, long operatorId,
String operatorName, String remark) {
String businessNo = numberGenerator.next("AC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, actual); statement.setBigDecimal(6, frozen);
statement.setBigDecimal(7, available); statement.setString(8, sourceType); statement.setString(9, sourceNo);
statement.setObject(10, occurredDate); statement.setString(11, paymentMethod); statement.setString(12, referenceNo);
statement.setLong(13, operatorId); statement.setString(14, clean(operatorName, 128));
statement.setString(15, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
private WalletLedgerEntry requiredLedger(long ledgerId, long customerId) {
List<WalletLedgerEntry> rows = jdbc.query("SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=?",
(rs, n) -> ledgerRow(rs), ledgerId, customerId);
if (rows.isEmpty()) throw error("FIN-WALLET-404", "账户流水不存在");
return rows.get(0);
}
private RechargeRequest requiredRecharge(long requestId, Long customerId) {
String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?");
Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId};
List<RechargeRequest> rows = jdbc.query(sql, (rs, n) -> rechargeRow(rs), args);
if (rows.isEmpty()) throw error("FIN-WALLET-404", "充值申请不存在");
return rows.get(0);
}
private long customerIdOf(long requestId) { return beforeCustomerId(requestId); }
private long beforeCustomerId(long requestId) {
Long value = jdbc.queryForObject("SELECT customer_id FROM fin_recharge_request WHERE id=?", Long.class, requestId);
if (value == null || value <= 0) throw error("FIN-WALLET-404", "充值申请不存在");
return value;
}
private Long reserve(long customerId, long operatorId, String operation, String key, String requestHash) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) throw bad("Idempotency-Key不能为空且不能超过128字符");
String normalizedKey = key.trim();
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, normalizedKey);
if (!existing.isEmpty()) return replay(existing.get(0), requestHash);
try {
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
customerId, operatorId, operation, normalizedKey, requestHash);
return null;
} catch (DuplicateKeyException exception) {
Map<String, Object> row = jdbc.queryForMap(
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, normalizedKey);
return replay(row, requestHash);
}
}
private Long replay(Map<String, Object> row, String requestHash) {
if (!requestHash.equals(String.valueOf(row.get("request_hash")))) throw error("FIN-IDEMPOTENCY-CONFLICT", "同一幂等键的请求内容不一致");
if (!"SUCCEEDED".equals(String.valueOf(row.get("status"))) || row.get("result_resource_id") == null) {
throw error("FIN-IDEMPOTENCY-PROCESSING", "相同请求正在处理中,请稍后重试");
}
return ((Number) row.get("result_resource_id")).longValue();
}
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
resourceId, customerId, operatorId, operation, key.trim());
}
private void audit(long customerId, String resourceType, long resourceId, String action, String reason,
Object before, Object after, long operatorId, String operatorName) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, resourceType, resourceId, action, clean(reason, 500), json(before), json(after), operatorId,
clean(operatorName, 128), org.slf4j.MDC.get("requestId"), org.slf4j.MDC.get("traceId"));
}
private WalletLedgerEntry ledgerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new WalletLedgerEntry(rs.getLong("id"), rs.getString("business_no"), rs.getString("change_type"),
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
rs.getBigDecimal("available_balance_after"), rs.getString("source_type"), rs.getString("source_no"),
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
}
private RechargeRequest rechargeRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new RechargeRequest(rs.getLong("id"), rs.getString("request_no"), rs.getBigDecimal("amount"),
rs.getString("payment_method"), rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"),
rs.getString("status"), rs.getInt("version"), rs.getString("submitted_by_name"),
rs.getString("reviewed_by_name"), rs.getString("review_remark"), timestamp(rs.getTimestamp("created_at")),
timestamp(rs.getTimestamp("reviewed_at")));
}
private String hash(Object value) {
try { return sha256(objectMapper.writeValueAsBytes(value)); }
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
}
private String fileHash(MultipartFile file) {
try { return sha256(file.getBytes()); }
catch (Exception exception) { throw bad("付款截图无法读取"); }
}
private static String sha256(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw error("FIN_500", "审计快照生成失败"); }
}
private static BigDecimal money(BigDecimal value) {
if (value == null || value.signum() <= 0 || value.scale() > 2 || value.compareTo(new BigDecimal("999999999999.99")) > 0) {
throw bad("金额必须大于0且最多保留两位小数");
}
return value.setScale(2, RoundingMode.UNNECESSARY);
}
private static String paymentMethod(String value) {
String normalized = upper(value);
if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效");
return normalized;
}
private static void validateImage(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("请上传付款截图");
if (!IMAGE_TYPES.contains(String.valueOf(file.getContentType()).toLowerCase(Locale.ROOT))) throw bad("付款截图仅支持 JPG 或 PNG");
}
private static void validatePage(int page, int size) { if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效"); }
private static void requirePositive(long value, String label) { if (value <= 0) throw bad(label + "标识无效"); }
private static long generatedId(KeyHolder holder) {
Number key = holder.getKey(); if (key == null || key.longValue() <= 0) throw error("FIN_500", "业务记录创建失败"); return key.longValue();
}
private static String upper(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String blankToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String clean(String value, int max) {
if (!StringUtils.hasText(value)) return null; String normalized = value.trim();
if (normalized.length() > max) throw bad("文本长度不能超过" + max + "个字符"); return normalized;
}
private static LocalDateTime timestamp(Timestamp value) { return value == null ? null : value.toLocalDateTime(); }
private static FinanceEventException bad(String message) { return error("FIN-REQ-400", message); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
+75
View File
@@ -434,3 +434,78 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
-- 一件代发客户资金账户:充值先行,订单消费与冻结/解冻后续接入。
CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结金额',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '可用余额=实际余额-冻结金额',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND actual_balance = frozen_balance + available_balance)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审核乐观锁版本',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '客户提交人ID',
submitted_by_name VARCHAR(128) NULL COMMENT '客户提交人名称快照',
reviewed_by BIGINT UNSIGNED NULL COMMENT '平台审核人ID',
reviewed_by_name VARCHAR(128) NULL COMMENT '平台审核人名称快照',
review_remark VARCHAR(500) NULL COMMENT '审核说明或驳回原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
reviewed_at DATETIME(3) NULL COMMENT '平台审核完成时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_recharge_request_no (request_no),
KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id),
CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0),
CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')),
CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账';
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费',
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额',
available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
payment_method VARCHAR(32) NULL COMMENT '充值或扣款方式',
reference_no VARCHAR(128) NULL COMMENT '收款凭证号或关联单据号',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NULL COMMENT '操作人名称快照',
remark VARCHAR(500) NULL COMMENT '变动说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_wallet_ledger_no (business_no),
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND actual_balance_after = frozen_balance_after + available_balance_after)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
@@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest {
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(265);
assertThat(tableCount).isEqualTo(20);
assertThat(columnCount).isEqualTo(310);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -0,0 +1,77 @@
package com.baiyue.finance;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceWalletService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FinanceWalletBehaviorTest {
@Test
void missingAccountReadsAsZeroWithoutCreatingData() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "https://pay.example/wechat.png", "").account(9L);
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
assertThat(account.availableBalance()).isEqualByComparingTo("0.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void rechargeChannelsAreEnabledOnlyWhenQrCodeIsConfigured() {
var channels = service(mock(JdbcTemplate.class), "https://pay.example/wechat.png", "").channels();
assertThat(channels).satisfiesExactly(
wechat -> { assertThat(wechat.paymentMethod()).isEqualTo("WECHAT"); assertThat(wechat.enabled()).isTrue(); },
alipay -> { assertThat(alipay.paymentMethod()).isEqualTo("ALIPAY"); assertThat(alipay.enabled()).isFalse(); });
}
@Test
void manualDeductionCannotExceedAvailableBalance() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getInt("version")).thenReturn(2);
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
return mapper.mapRow(rs, 0);
});
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay);
}
}
@@ -0,0 +1,41 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceWalletContractTest {
@Test
void schemaKeepsBalancesConsistentAndRechargeReviewTraceable() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
.contains("actual_balance = frozen_balance + available_balance")
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
.contains("status IN ('PENDING','APPROVED','REJECTED')")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
.contains("amount <> 0")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status");
}
@Test
void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception {
String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java"));
String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java"));
assertThat(client)
.contains("user.tenantId()")
.contains("merchant:finance:account:view")
.contains("merchant:finance:recharge:create")
.contains("consumes = \"multipart/form-data\"")
.doesNotContain("@RequestParam long customerId");
assertThat(platform)
.contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust")
.contains("Idempotency-Key");
}
}
@@ -0,0 +1,122 @@
-- 客户资金账户与充值权限。只读访问和资金写操作使用独立权限。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_wallet_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_wallet_permissions()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5105056 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:finance:recharge:review'))
OR (id = 5105057 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:finance:customer-account:adjust'))
OR (id = 5305020 AND (tenant_id <> 0 OR app_code <> 'MERCHANT'
OR permission_type <> 'MENU'
OR permission_code <> 'merchant:finance:account:view'))
OR (id = 5305021 AND (tenant_id <> 0 OR app_code <> 'MERCHANT'
OR permission_type <> 'BUTTON'
OR permission_code <> 'merchant:finance:recharge:create'))
OR (permission_code = 'platform:finance:recharge:review'
AND (id <> 5105056 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
OR (permission_code = 'platform:finance:customer-account:adjust'
AND (id <> 5105057 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
OR (permission_code = 'merchant:finance:account:view'
AND (id <> 5305020 OR tenant_id <> 0 OR app_code <> 'MERCHANT'))
OR (permission_code = 'merchant:finance:recharge:create'
AND (id <> 5305021 OR tenant_id <> 0 OR app_code <> 'MERCHANT'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:权限ID、编码或类型冲突';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review',
'审核客户充值',NULL,'POST',100,'ACTIVE',1,0),
(5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust',
'调整客户资金账户',NULL,'POST',110,'ACTIVE',1,0),
(5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view',
'资金账户','/finance/account','GET',50,'ACTIVE',1,0),
(5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create',
'提交充值申请',NULL,'POST',10,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
permission_name=VALUES(permission_name), parent_id=VALUES(parent_id),
route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')
AND permission.status='ACTIVE' AND permission.deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'merchant:finance:account:view',
'merchant:finance:recharge:create')
AND permission.status='ACTIVE' AND permission.deleted=0;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_type='BUTTON'
AND parent_id=5105040 AND status='ACTIVE' AND built_in=1 AND deleted=0
AND permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台写权限资源不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND built_in=1 AND deleted=0
AND ((id=5305020 AND permission_type='MENU' AND parent_id=5300000
AND permission_code='merchant:finance:account:view')
OR (id=5305021 AND permission_type='BUTTON' AND parent_id=5305020
AND permission_code='merchant:finance:recharge:create'))) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:客户权限资源不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.status='ACTIVE' AND permission.deleted=0
AND permission.permission_code IN (
'platform:finance:recharge:review',
'platform:finance:customer-account:adjust')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台超级管理员授权不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.tenant_id=0 AND permission.app_code='MERCHANT'
AND permission.status='ACTIVE' AND permission.deleted=0
AND permission.permission_code IN (
'merchant:finance:account:view',
'merchant:finance:recharge:create')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:企业负责人授权不完整';
END IF;
END$$
DELIMITER ;
CALL migrate_wallet_permissions();
DROP PROCEDURE IF EXISTS migrate_wallet_permissions;
+2 -2
View File
@@ -124,7 +124,7 @@ paths:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。
responses:
'200':
description: 当前平台成员权限查询成功
@@ -326,7 +326,7 @@ paths:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限
description: 返回当前有效客户成员权限编码。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。
description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。
responses:
'200':
description: 当前商户成员权限查询成功
@@ -42,6 +42,11 @@ class IamOpenApiContractTest {
.contains("merchant:warehouse:inbound:list")
.contains("lastleg:pda:operate")
.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR")
.contains("merchant:finance:account:view")
.contains("merchant:finance:recharge:create")
.contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust")
.contains("两者独立授权")
.contains("列表权限包含详情只读")
.contains("PermissionCodesApiResponse:")
.contains("TenantPageApiResponse:")
@@ -0,0 +1,88 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class WalletPermissionSqlContractTest {
@Test
void declaresReadAndWritePermissionsAsSeparateResources() throws Exception {
String sql = sql();
assertThat(sql).contains(
"(5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review'",
"(5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust'",
"(5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view'",
"(5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create'",
"'资金账户','/finance/account','GET'",
"'提交充值申请',NULL,'POST'",
"'审核客户充值',NULL,'POST'",
"'调整客户资金账户',NULL,'POST'");
}
@Test
void failsClosedOnPermissionIdentityOrTypeConflicts() throws Exception {
String sql = sql();
assertThat(sql).contains(
"migrate_wallet_permissions",
"permission_type <> 'MENU'",
"permission_type <> 'BUTTON'",
"permission_code = 'merchant:finance:account:view'",
"permission_code = 'platform:finance:customer-account:adjust'",
"资金账户权限初始化失败:权限ID、编码或类型冲突");
}
@Test
void grantsMerchantPermissionsOnlyToTheEnterpriseOwner() throws Exception {
String sql = sql();
int permissionIndex = sql.indexOf("'merchant:finance:account:view'", sql.indexOf("INSERT IGNORE INTO"));
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex);
int grantEnd = sql.indexOf(';', permissionIndex);
String grant = sql.substring(grantStart, grantEnd);
assertThat(grant).contains(
"role.app_code='MERCHANT'",
"role.role_code='MERCHANT_OWNER'",
"'merchant:finance:account:view'",
"'merchant:finance:recharge:create'");
assertThat(grant).doesNotContain("PLATFORM_ADMIN", "PLATFORM_CUSTOM_ADMIN");
}
@Test
void grantsPlatformWritesOnlyToTheSuperAdministratorByDefault() throws Exception {
String sql = sql();
int permissionIndex = sql.indexOf("'platform:finance:recharge:review'", sql.indexOf("INSERT IGNORE INTO"));
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex);
int grantEnd = sql.indexOf(';', permissionIndex);
String grant = sql.substring(grantStart, grantEnd);
assertThat(grant).contains(
"role.app_code='PLATFORM'",
"role.role_code='PLATFORM_ADMIN'",
"'platform:finance:recharge:review'",
"'platform:finance:customer-account:adjust'");
assertThat(grant).doesNotContain(
"PLATFORM_CUSTOM_ADMIN",
"CUSTOMER_REVIEWER",
"ORDER_OPERATOR");
}
@Test
void assertsCatalogAndDefaultRoleGrants() throws Exception {
assertThat(sql()).contains(
"资金账户权限初始化失败:平台写权限资源不完整",
"资金账户权限初始化失败:客户权限资源不完整",
"资金账户权限初始化失败:平台超级管理员授权不完整",
"资金账户权限初始化失败:企业负责人授权不完整",
")) <> 2 THEN");
}
private static String sql() throws Exception {
return new ClassPathResource("db/wallet-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -110,6 +110,35 @@ class AuthorizationSnapshotServiceTest {
"merchant:warehouse:inbound:list");
}
@Test
void exposesWalletPermissionsFromCurrentRoleAssignments() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
"merchant:finance:account:view",
"merchant:finance:recharge:create"));
var merchantPermissions = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
assertThat(merchantPermissions).containsExactlyInAnyOrder(
"merchant:finance:account:view",
"merchant:finance:recharge:create");
when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1);
when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN"));
when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of(
"platform:finance:recharge:review",
"platform:finance:customer-account:adjust"));
var platformPermissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L,
"platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(platformPermissions).containsExactlyInAnyOrder(
"platform:finance:recharge:review",
"platform:finance:customer-account:adjust");
}
@Test
void rejectsIncompleteCurrentIdentityAsBusinessError() {
assertThatThrownBy(() -> service
+2 -1
View File
@@ -129,10 +129,11 @@ Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项。' '测试登记清空状态'
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目'
Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-dev`' '开发登记工作目录'
Assert-Contains $devTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '开发环境全流程总控'
Assert-Contains $devTaskRegistry '`01a06c0b-0515-7c60-b329-77342b54e666`' '开发环境全流程总控'
Assert-NotContains $devTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '开发登记测试总控'
Assert-NotContains $devTaskRegistry 'c64729ff-6710-43c6-8ef2-d84e9666cc1e' '开发登记测试 projectId'
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })