feat(finance): add customer wallet recharge flow
This commit is contained in:
@@ -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:}
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/client/finance/v1")
|
||||
public class ClientFinanceWalletController {
|
||||
private final FinanceWalletService service;
|
||||
|
||||
public ClientFinanceWalletController(FinanceWalletService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/account")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
|
||||
public ApiResponse<?> account(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
return FinanceResponses.success(service.account(user.tenantId()));
|
||||
}
|
||||
|
||||
@GetMapping("/account/ledger")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
|
||||
public ApiResponse<?> ledger(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return FinanceResponses.success(service.ledger(user.tenantId(), page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/recharge-requests")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
|
||||
public ApiResponse<?> requests(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/recharge-channels")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
|
||||
public ApiResponse<?> channels() { return FinanceResponses.success(service.channels()); }
|
||||
|
||||
@PostMapping(value = "/recharge-requests", consumes = "multipart/form-data")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
|
||||
public ApiResponse<?> submit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestParam BigDecimal amount,
|
||||
@RequestParam String paymentMethod,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
return FinanceResponses.success(service.submitRecharge(user.tenantId(), user.userId(), user.displayName(),
|
||||
idempotencyKey, amount, paymentMethod, file));
|
||||
}
|
||||
|
||||
@GetMapping("/recharge-requests/{requestId}/voucher")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:account:view')")
|
||||
public ResponseEntity<byte[]> voucher(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long requestId) {
|
||||
return service.clientVoucher(user.tenantId(), requestId);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/finance/v1")
|
||||
public class PlatformFinanceWalletController {
|
||||
private final FinanceWalletService service;
|
||||
|
||||
public PlatformFinanceWalletController(FinanceWalletService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/accounts/{customerId}/recharge-requests")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public PageEnvelope<RechargeRequest> requests(@PathVariable long customerId,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
var result = service.rechargeRequests(customerId, page, pageSize);
|
||||
return PageEnvelope.success(result.records(), result.page(), result.size(), result.total());
|
||||
}
|
||||
|
||||
@PostMapping("/recharge-requests/{requestId}/review")
|
||||
@PreAuthorize("hasAuthority('platform:finance:recharge:review')")
|
||||
public ApiResponse<?> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long requestId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody RechargeReviewRequest request) {
|
||||
return FinanceResponses.success(service.reviewRecharge(requestId, user.userId(), user.displayName(),
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/accounts/{customerId}/adjustments")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
|
||||
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long customerId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody AccountAdjustmentRequest request) {
|
||||
return FinanceResponses.success(service.adjust(customerId, user.userId(), user.displayName(),
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
@GetMapping("/recharge-requests/{requestId}/voucher")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
|
||||
}
|
||||
@@ -30,13 +30,20 @@ public final class FinancePhase1Dtos {
|
||||
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost,
|
||||
BigDecimal paidCost, BigDecimal outstandingPayable,
|
||||
BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, int accountVersion,
|
||||
long pendingRechargeCount,
|
||||
String status, LocalDateTime lastChangedAt) {}
|
||||
|
||||
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
|
||||
String documentType, long documentId, String documentNo,
|
||||
BigDecimal receivableChange, BigDecimal payableChange,
|
||||
BigDecimal outstandingReceivableAfter, String operatorName,
|
||||
String remark) {}
|
||||
String remark, String walletBusinessNo, String changeType,
|
||||
BigDecimal walletAmount, BigDecimal actualBalanceAfter,
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo) {}
|
||||
|
||||
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
|
||||
String sourceBusinessId, long sourceFeeId, int sourceVersion,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class FinanceWalletDtos {
|
||||
private FinanceWalletDtos() { }
|
||||
|
||||
public record WalletAccount(String currencyCode, BigDecimal actualBalance,
|
||||
BigDecimal frozenBalance, BigDecimal availableBalance,
|
||||
int version, LocalDateTime updatedAt) { }
|
||||
|
||||
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
|
||||
public static <T> WalletPage<T> of(List<T> records, long total, int page, int size) {
|
||||
return new WalletPage<>(records, total, page, size,
|
||||
total == 0 ? 0 : (int) ((total + size - 1) / size));
|
||||
}
|
||||
}
|
||||
|
||||
public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType,
|
||||
BigDecimal amount, BigDecimal actualBalanceAfter,
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String operatorName,
|
||||
String remark, LocalDateTime createdAt) { }
|
||||
|
||||
public record RechargeRequest(long requestId, String requestNo, BigDecimal amount,
|
||||
String paymentMethod, long voucherFileId, String voucherFileName,
|
||||
String status, int version, String submittedByName,
|
||||
String reviewedByName, String reviewRemark,
|
||||
LocalDateTime createdAt, LocalDateTime reviewedAt) { }
|
||||
|
||||
public record RechargeChannel(String paymentMethod, String displayName,
|
||||
boolean enabled, String qrCodeUrl) { }
|
||||
|
||||
public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { }
|
||||
|
||||
public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
}
|
||||
+39
-4
@@ -52,6 +52,8 @@ public class FinanceCustomerAccountService {
|
||||
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
|
||||
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
|
||||
value.outstandingPayable(),
|
||||
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
|
||||
value.accountVersion(), value.pendingRechargeCount(),
|
||||
customer.status(), value.lastChangedAt());
|
||||
}).toList();
|
||||
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
|
||||
@@ -74,13 +76,38 @@ public class FinanceCustomerAccountService {
|
||||
.forEach(row -> metric(result, row, "paidCost", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
|
||||
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
|
||||
.forEach(row -> walletMetric(result, row));
|
||||
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
|
||||
.forEach(row -> pendingMetric(result, row));
|
||||
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 7))
|
||||
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
|
||||
Map<Long, Metrics> frozen = new HashMap<>();
|
||||
result.forEach((id, value) -> frozen.put(id, value.freeze()));
|
||||
return frozen;
|
||||
}
|
||||
|
||||
private static void walletMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
|
||||
Object id = row.get("customer_id");
|
||||
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
|
||||
MutableMetrics target = result.get(number.longValue());
|
||||
target.actualBalance = decimal(row.get("actual_balance"));
|
||||
target.frozenBalance = decimal(row.get("frozen_balance"));
|
||||
target.availableBalance = decimal(row.get("available_balance"));
|
||||
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
|
||||
}
|
||||
|
||||
private static void pendingMetric(Map<Long, MutableMetrics> result, Map<String, Object> row) {
|
||||
Object id = row.get("customer_id");
|
||||
if (!(id instanceof Number number) || result.get(number.longValue()) == null) return;
|
||||
Object count = row.get("pending_count");
|
||||
result.get(number.longValue()).pendingRechargeCount = count instanceof Number value ? value.longValue() : 0;
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(Object value) {
|
||||
return value == null ? BigDecimal.ZERO : value instanceof BigDecimal amount ? amount : new BigDecimal(value.toString());
|
||||
}
|
||||
|
||||
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> row, String field, String valueKey) {
|
||||
Object id = row.get("customer_id");
|
||||
if (!(id instanceof Number number)) return;
|
||||
@@ -115,6 +142,11 @@ public class FinanceCustomerAccountService {
|
||||
private BigDecimal totalCost = BigDecimal.ZERO;
|
||||
private BigDecimal paidCost = BigDecimal.ZERO;
|
||||
private BigDecimal outstandingPayable = BigDecimal.ZERO;
|
||||
private BigDecimal actualBalance = BigDecimal.ZERO;
|
||||
private BigDecimal frozenBalance = BigDecimal.ZERO;
|
||||
private BigDecimal availableBalance = BigDecimal.ZERO;
|
||||
private int accountVersion;
|
||||
private long pendingRechargeCount;
|
||||
private LocalDateTime lastChangedAt;
|
||||
void set(String field, BigDecimal value) {
|
||||
switch (field) {
|
||||
@@ -127,14 +159,17 @@ public class FinanceCustomerAccountService {
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); }
|
||||
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
|
||||
actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); }
|
||||
}
|
||||
|
||||
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
|
||||
BigDecimal outstandingPayable,
|
||||
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, int accountVersion, long pendingRechargeCount,
|
||||
LocalDateTime lastChangedAt) {
|
||||
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, 0, 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.Set;
|
||||
public class FinanceDocumentNumberGenerator {
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
|
||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC");
|
||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
+24
-1
@@ -73,12 +73,19 @@ public class FinancePhase1OperationService {
|
||||
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," +
|
||||
"COALESCE((SELECT actual_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) actual_balance," +
|
||||
"COALESCE((SELECT frozen_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) frozen_balance," +
|
||||
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
|
||||
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
|
||||
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
|
||||
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
|
||||
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
|
||||
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
|
||||
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
|
||||
rs.getString("account_status"), time(rs, "last_changed_at")), args);
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
@@ -99,9 +106,25 @@ public class FinancePhase1OperationService {
|
||||
BigDecimal receivableChange = row.receivable();
|
||||
outstanding = outstanding.add(receivableChange);
|
||||
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark(),
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
|
||||
}
|
||||
BigDecimal derivedOutstanding = outstanding;
|
||||
projected.addAll(jdbc.query("SELECT * FROM fin_wallet_ledger WHERE customer_id=?", (rs, n) -> {
|
||||
LocalDate occurredDate = rs.getDate("occurred_date").toLocalDate();
|
||||
LocalDateTime occurredAt = occurredDate.atStartOfDay();
|
||||
String type = rs.getString("change_type");
|
||||
return new LedgerEntry(rs.getLong("id"), occurredAt, type, "WALLET_LEDGER", rs.getLong("id"),
|
||||
rs.getString("business_no"), BigDecimal.ZERO, BigDecimal.ZERO, derivedOutstanding,
|
||||
rs.getString("operator_name"), rs.getString("remark"), rs.getString("business_no"), type,
|
||||
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"),
|
||||
rs.getBigDecimal("frozen_balance_after"), rs.getBigDecimal("available_balance_after"),
|
||||
rs.getString("source_type"), rs.getString("source_no"), occurredDate,
|
||||
rs.getString("payment_method"), rs.getString("reference_no"));
|
||||
}, customerId).stream().filter(entry ->
|
||||
(businessType == null || businessType.isBlank() || businessType.equals(entry.businessType()))
|
||||
&& inRange(entry.occurredAt(), startDate, endDate)).toList());
|
||||
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
|
||||
.thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed()));
|
||||
int from = Math.min((page - 1) * pageSize, projected.size());
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class FinanceWalletService {
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY");
|
||||
private static final Set<String> ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
private static final Set<String> IMAGE_TYPES = Set.of("image/jpeg", "image/png");
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceFileClient fileClient;
|
||||
private final FinanceDocumentNumberGenerator numberGenerator;
|
||||
private final String internalToken;
|
||||
private final String wechatQrCodeUrl;
|
||||
private final String alipayQrCodeUrl;
|
||||
|
||||
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceFileClient fileClient,
|
||||
FinanceDocumentNumberGenerator numberGenerator,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
|
||||
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.fileClient = fileClient;
|
||||
this.numberGenerator = numberGenerator;
|
||||
this.internalToken = internalToken;
|
||||
this.wechatQrCodeUrl = blankToNull(wechatQrCodeUrl);
|
||||
this.alipayQrCodeUrl = blankToNull(alipayQrCodeUrl);
|
||||
}
|
||||
|
||||
public WalletAccount account(long customerId) {
|
||||
requirePositive(customerId, "客户");
|
||||
List<WalletAccount> rows = jdbc.query(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
|
||||
timestamp(rs.getTimestamp("updated_at"))), customerId);
|
||||
return rows.isEmpty() ? new WalletAccount("CNY", BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
|
||||
BigDecimal.ZERO.setScale(2), 0, null) : rows.get(0);
|
||||
}
|
||||
|
||||
public WalletPage<WalletLedgerEntry> ledger(long customerId, int page, int size) {
|
||||
requirePositive(customerId, "客户"); validatePage(page, size);
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=?", Long.class, customerId);
|
||||
List<WalletLedgerEntry> rows = jdbc.query(
|
||||
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
|
||||
(rs, n) -> ledgerRow(rs), customerId, size, (page - 1) * size);
|
||||
return WalletPage.of(rows, total == null ? 0 : total, page, size);
|
||||
}
|
||||
|
||||
public WalletPage<RechargeRequest> rechargeRequests(long customerId, int page, int size) {
|
||||
requirePositive(customerId, "客户"); validatePage(page, size);
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_recharge_request WHERE customer_id=?", Long.class, customerId);
|
||||
List<RechargeRequest> rows = jdbc.query(
|
||||
"SELECT * FROM fin_recharge_request WHERE customer_id=? ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END,created_at DESC,id DESC LIMIT ? OFFSET ?",
|
||||
(rs, n) -> rechargeRow(rs), customerId, size, (page - 1) * size);
|
||||
return WalletPage.of(rows, total == null ? 0 : total, page, size);
|
||||
}
|
||||
|
||||
public List<RechargeChannel> channels() {
|
||||
return List.of(new RechargeChannel("WECHAT", "微信支付", wechatQrCodeUrl != null, wechatQrCodeUrl),
|
||||
new RechargeChannel("ALIPAY", "支付宝", alipayQrCodeUrl != null, alipayQrCodeUrl));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
|
||||
String idempotencyKey, BigDecimal amount,
|
||||
String paymentMethod, MultipartFile file) {
|
||||
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
|
||||
BigDecimal normalizedAmount = money(amount);
|
||||
String method = paymentMethod(paymentMethod);
|
||||
RechargeChannel channel = channels().stream().filter(value -> value.paymentMethod().equals(method)).findFirst().orElseThrow();
|
||||
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", channel.displayName() + "收款二维码未配置");
|
||||
validateImage(file);
|
||||
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method,
|
||||
"fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()),
|
||||
"size", file.getSize(), "fileHash", fileHash(file)));
|
||||
Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash);
|
||||
if (replay != null) return requiredRecharge(replay, customerId);
|
||||
|
||||
ApiResponse<FinanceFileClient.Metadata> upload;
|
||||
try {
|
||||
upload = fileClient.upload(internalToken, operatorId, customerId, customerId, file);
|
||||
} catch (RuntimeException exception) {
|
||||
throw error("FIN_503", "付款截图暂时无法保存,请重试");
|
||||
}
|
||||
if (upload == null || !upload.successful() || upload.data() == null || upload.data().fileId() == null) {
|
||||
throw error("FIN_503", "付款截图暂时无法保存,请重试");
|
||||
}
|
||||
String requestNo = numberGenerator.next("RC");
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,payment_method,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,?,'PENDING',0,?,?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, customerId); statement.setString(2, requestNo);
|
||||
statement.setBigDecimal(3, normalizedAmount); statement.setString(4, method);
|
||||
statement.setLong(5, upload.data().fileId()); statement.setString(6, upload.data().fileName());
|
||||
statement.setLong(7, operatorId); statement.setString(8, clean(operatorName, 128));
|
||||
return statement;
|
||||
}, holder);
|
||||
long requestId = generatedId(holder);
|
||||
complete(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestId);
|
||||
return requiredRecharge(requestId, customerId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RechargeRequest reviewRecharge(long requestId, long operatorId, String operatorName,
|
||||
String idempotencyKey, RechargeReviewRequest request) {
|
||||
requirePositive(requestId, "充值申请"); requirePositive(operatorId, "操作人");
|
||||
if (request == null || request.expectedVersion() < 0) throw bad("审核请求无效");
|
||||
String decision = upper(request.decision());
|
||||
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw bad("审核决定无效");
|
||||
if ("REJECT".equals(decision) && !StringUtils.hasText(request.remark())) throw bad("驳回时必须填写原因");
|
||||
RechargeRequest before = requiredRecharge(requestId, null);
|
||||
String requestHash = hash(Map.of("requestId", requestId, "request", request));
|
||||
Long replay = reserve(customerIdOf(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestHash);
|
||||
if (replay != null) return requiredRecharge(replay, null);
|
||||
int updated = jdbc.update(
|
||||
"UPDATE fin_recharge_request SET status=?,version=version+1,reviewed_by=?,reviewed_by_name=?,review_remark=?,reviewed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='PENDING' AND version=?",
|
||||
"APPROVE".equals(decision) ? "APPROVED" : "REJECTED", operatorId, clean(operatorName, 128),
|
||||
clean(request.remark(), 500), requestId, request.expectedVersion());
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化");
|
||||
if ("APPROVE".equals(decision)) {
|
||||
WalletAccount account = accountForUpdate(beforeCustomerId(requestId));
|
||||
BigDecimal actual = account.actualBalance().add(before.amount());
|
||||
BigDecimal available = account.availableBalance().add(before.amount());
|
||||
updateAccount(beforeCustomerId(requestId), actual, account.frozenBalance(), available, account.version(), operatorId);
|
||||
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), actual, account.frozenBalance(), available,
|
||||
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
|
||||
operatorId, operatorName, request.remark());
|
||||
}
|
||||
RechargeRequest after = requiredRecharge(requestId, null);
|
||||
audit(beforeCustomerId(requestId), "RECHARGE_REQUEST", requestId, "RECHARGE_" + decision,
|
||||
request.remark(), before, after, operatorId, operatorName);
|
||||
complete(beforeCustomerId(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestId);
|
||||
return after;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletLedgerEntry adjust(long customerId, long operatorId, String operatorName,
|
||||
String idempotencyKey, AccountAdjustmentRequest request) {
|
||||
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
|
||||
if (request == null || request.expectedAccountVersion() < 0) throw bad("账户调整请求无效");
|
||||
String type = upper(request.type());
|
||||
if (!Set.of("RECHARGE", "DEDUCTION").contains(type)) throw bad("账户调整类型无效");
|
||||
BigDecimal amount = money(request.amount());
|
||||
if (request.occurredDate() == null) throw bad("发生日期不能为空");
|
||||
if (request.occurredDate().isAfter(LocalDate.now())) throw bad("发生日期不能晚于今天");
|
||||
String adjustmentPaymentMethod = upper(request.paymentMethod());
|
||||
if (!ADJUSTMENT_PAYMENT_METHODS.contains(adjustmentPaymentMethod)) throw bad("充值或扣款方式无效");
|
||||
Long replay = reserve(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, hash(request));
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
|
||||
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
|
||||
}
|
||||
BigDecimal actual = before.actualBalance().add(signed);
|
||||
BigDecimal available = before.availableBalance().add(signed);
|
||||
updateAccount(customerId, actual, before.frozenBalance(), available, before.version(), operatorId);
|
||||
String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION";
|
||||
WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available,
|
||||
sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(),
|
||||
adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark());
|
||||
audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before,
|
||||
account(customerId), operatorId, operatorName);
|
||||
complete(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, entry.ledgerId());
|
||||
return entry;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> voucher(long requestId) {
|
||||
RechargeRequest request = requiredRecharge(requestId, null);
|
||||
long customerId = beforeCustomerId(requestId);
|
||||
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> clientVoucher(long customerId, long requestId) {
|
||||
RechargeRequest request = requiredRecharge(requestId, customerId);
|
||||
return fileClient.download(internalToken, customerId, customerId, request.voucherFileId());
|
||||
}
|
||||
|
||||
private WalletAccount accountForUpdate(long customerId) {
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0)", customerId);
|
||||
return jdbc.queryForObject(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
|
||||
timestamp(rs.getTimestamp("updated_at"))), customerId);
|
||||
}
|
||||
|
||||
private void updateAccount(long customerId, BigDecimal actual, BigDecimal frozen, BigDecimal available,
|
||||
int expectedVersion, long operatorId) {
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
actual, frozen, available, operatorId, customerId, expectedVersion);
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
|
||||
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
|
||||
BigDecimal actual, BigDecimal frozen, BigDecimal available,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, long operatorId,
|
||||
String operatorName, String remark) {
|
||||
String businessNo = numberGenerator.next("AC");
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
|
||||
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, actual); statement.setBigDecimal(6, frozen);
|
||||
statement.setBigDecimal(7, available); statement.setString(8, sourceType); statement.setString(9, sourceNo);
|
||||
statement.setObject(10, occurredDate); statement.setString(11, paymentMethod); statement.setString(12, referenceNo);
|
||||
statement.setLong(13, operatorId); statement.setString(14, clean(operatorName, 128));
|
||||
statement.setString(15, clean(remark, 500)); return statement;
|
||||
}, holder);
|
||||
return requiredLedger(generatedId(holder), customerId);
|
||||
}
|
||||
|
||||
private WalletLedgerEntry requiredLedger(long ledgerId, long customerId) {
|
||||
List<WalletLedgerEntry> rows = jdbc.query("SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=?",
|
||||
(rs, n) -> ledgerRow(rs), ledgerId, customerId);
|
||||
if (rows.isEmpty()) throw error("FIN-WALLET-404", "账户流水不存在");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private RechargeRequest requiredRecharge(long requestId, Long customerId) {
|
||||
String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?");
|
||||
Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId};
|
||||
List<RechargeRequest> rows = jdbc.query(sql, (rs, n) -> rechargeRow(rs), args);
|
||||
if (rows.isEmpty()) throw error("FIN-WALLET-404", "充值申请不存在");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private long customerIdOf(long requestId) { return beforeCustomerId(requestId); }
|
||||
private long beforeCustomerId(long requestId) {
|
||||
Long value = jdbc.queryForObject("SELECT customer_id FROM fin_recharge_request WHERE id=?", Long.class, requestId);
|
||||
if (value == null || value <= 0) throw error("FIN-WALLET-404", "充值申请不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String requestHash) {
|
||||
if (!StringUtils.hasText(key) || key.trim().length() > 128) throw bad("Idempotency-Key不能为空且不能超过128字符");
|
||||
String normalizedKey = key.trim();
|
||||
List<Map<String, Object>> existing = jdbc.queryForList(
|
||||
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
|
||||
customerId, operatorId, operation, normalizedKey);
|
||||
if (!existing.isEmpty()) return replay(existing.get(0), requestHash);
|
||||
try {
|
||||
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
|
||||
customerId, operatorId, operation, normalizedKey, requestHash);
|
||||
return null;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
Map<String, Object> row = jdbc.queryForMap(
|
||||
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
|
||||
customerId, operatorId, operation, normalizedKey);
|
||||
return replay(row, requestHash);
|
||||
}
|
||||
}
|
||||
|
||||
private Long replay(Map<String, Object> row, String requestHash) {
|
||||
if (!requestHash.equals(String.valueOf(row.get("request_hash")))) throw error("FIN-IDEMPOTENCY-CONFLICT", "同一幂等键的请求内容不一致");
|
||||
if (!"SUCCEEDED".equals(String.valueOf(row.get("status"))) || row.get("result_resource_id") == null) {
|
||||
throw error("FIN-IDEMPOTENCY-PROCESSING", "相同请求正在处理中,请稍后重试");
|
||||
}
|
||||
return ((Number) row.get("result_resource_id")).longValue();
|
||||
}
|
||||
|
||||
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
|
||||
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
|
||||
resourceId, customerId, operatorId, operation, key.trim());
|
||||
}
|
||||
|
||||
private void audit(long customerId, String resourceType, long resourceId, String action, String reason,
|
||||
Object before, Object after, long operatorId, String operatorName) {
|
||||
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
customerId, resourceType, resourceId, action, clean(reason, 500), json(before), json(after), operatorId,
|
||||
clean(operatorName, 128), org.slf4j.MDC.get("requestId"), org.slf4j.MDC.get("traceId"));
|
||||
}
|
||||
|
||||
private WalletLedgerEntry ledgerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
return new WalletLedgerEntry(rs.getLong("id"), rs.getString("business_no"), rs.getString("change_type"),
|
||||
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
|
||||
rs.getBigDecimal("available_balance_after"), rs.getString("source_type"), rs.getString("source_no"),
|
||||
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
|
||||
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
|
||||
}
|
||||
|
||||
private RechargeRequest rechargeRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
return new RechargeRequest(rs.getLong("id"), rs.getString("request_no"), rs.getBigDecimal("amount"),
|
||||
rs.getString("payment_method"), rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"),
|
||||
rs.getString("status"), rs.getInt("version"), rs.getString("submitted_by_name"),
|
||||
rs.getString("reviewed_by_name"), rs.getString("review_remark"), timestamp(rs.getTimestamp("created_at")),
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
try { return sha256(file.getBytes()); }
|
||||
catch (Exception exception) { throw bad("付款截图无法读取"); }
|
||||
}
|
||||
private static String sha256(byte[] bytes) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw error("FIN_500", "审计快照生成失败"); }
|
||||
}
|
||||
|
||||
private static BigDecimal money(BigDecimal value) {
|
||||
if (value == null || value.signum() <= 0 || value.scale() > 2 || value.compareTo(new BigDecimal("999999999999.99")) > 0) {
|
||||
throw bad("金额必须大于0且最多保留两位小数");
|
||||
}
|
||||
return value.setScale(2, RoundingMode.UNNECESSARY);
|
||||
}
|
||||
private static String paymentMethod(String value) {
|
||||
String normalized = upper(value);
|
||||
if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效");
|
||||
return normalized;
|
||||
}
|
||||
private static void validateImage(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw bad("请上传付款截图");
|
||||
if (!IMAGE_TYPES.contains(String.valueOf(file.getContentType()).toLowerCase(Locale.ROOT))) throw bad("付款截图仅支持 JPG 或 PNG");
|
||||
}
|
||||
private static void validatePage(int page, int size) { if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效"); }
|
||||
private static void requirePositive(long value, String label) { if (value <= 0) throw bad(label + "标识无效"); }
|
||||
private static long generatedId(KeyHolder holder) {
|
||||
Number key = holder.getKey(); if (key == null || key.longValue() <= 0) throw error("FIN_500", "业务记录创建失败"); return key.longValue();
|
||||
}
|
||||
private static String upper(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
|
||||
private static String blankToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String clean(String value, int max) {
|
||||
if (!StringUtils.hasText(value)) return null; String normalized = value.trim();
|
||||
if (normalized.length() > max) throw bad("文本长度不能超过" + max + "个字符"); return normalized;
|
||||
}
|
||||
private static LocalDateTime timestamp(Timestamp value) { return value == null ? null : value.toLocalDateTime(); }
|
||||
private static FinanceEventException bad(String message) { return error("FIN-REQ-400", message); }
|
||||
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user