From 5792630231fc5522a675f694d3728ee3f7781d27 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 5 Sep 2026 08:34:40 +0800 Subject: [PATCH] feat(finance): add customer wallet recharge flow --- config/nacos/cn/finance-service.yml | 2 + config/nacos/local/finance-service.yml | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 223 +++++++++- finance/AGENTS.md | 7 +- .../finance/client/FinanceFileClient.java | 5 + .../ClientFinanceWalletController.java | 64 +++ .../PlatformFinanceWalletController.java | 54 +++ .../baiyue/finance/dto/FinancePhase1Dtos.java | 9 +- .../baiyue/finance/dto/FinanceWalletDtos.java | 43 ++ .../FinanceCustomerAccountService.java | 43 +- .../FinanceDocumentNumberGenerator.java | 2 +- .../FinancePhase1OperationService.java | 25 +- .../finance/service/FinanceWalletService.java | 381 ++++++++++++++++++ finance/src/main/resources/db/finance.sql | 75 ++++ .../FinancePhase1OperationBehaviorTest.java | 1 + .../FinanceSqlCommentContractTest.java | 4 +- .../finance/FinanceWalletBehaviorTest.java | 77 ++++ .../finance/FinanceWalletContractTest.java | 41 ++ .../main/resources/db/wallet-permissions.sql | 122 ++++++ iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../controller/IamOpenApiContractTest.java | 5 + .../WalletPermissionSqlContractTest.java | 88 ++++ .../AuthorizationSnapshotServiceTest.java | 29 ++ scripts/agents-index-contract.ps1 | 3 +- 24 files changed, 1285 insertions(+), 24 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java create mode 100644 finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java create mode 100644 finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java create mode 100644 iam/src/main/resources/db/wallet-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml index a41070aa9..730d2c738 100644 --- a/config/nacos/cn/finance-service.yml +++ b/config/nacos/cn/finance-service.yml @@ -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:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml index 562d7e0a8..91f8be351 100644 --- a/config/nacos/local/finance-service.yml +++ b/config/nacos/local/finance-service.yml @@ -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:} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7ea688916..18fb8c8bd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 41fb12894..767321054 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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` | diff --git a/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java index 8dafd480d..4ea174f4b 100644 --- a/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.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(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, @PathVariable long customerId, @PathVariable long fileId); + @GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}/content") + ResponseEntity 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) {} } diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java new file mode 100644 index 000000000..2f6d44b2e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java @@ -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 voucher(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long requestId) { + return service.clientVoucher(user.tenantId(), requestId); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java new file mode 100644 index 000000000..7b000a9c6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java @@ -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 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 voucher(@PathVariable long requestId) { return service.voucher(requestId); } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index 0d0342ec8..458722ae1 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -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, diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java new file mode 100644 index 000000000..7f2dc16fa --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java @@ -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(List records, long total, int page, int size, int pages) { + public static WalletPage of(List 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) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java index da40b1969..c4c42e293 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -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 frozen = new HashMap<>(); result.forEach((id, value) -> frozen.put(id, value.freeze())); return frozen; } + private static void walletMetric(Map result, Map 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 result, Map 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 result, Map 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); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index 2a98556fc..d9c02ac8a 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -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 SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP"); + private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC"); private static final long MAX_DAILY_SEQUENCE = 9_999L; private final JdbcTemplate jdbc; diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index da7761d6f..77c02dff5 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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()); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java new file mode 100644 index 000000000..a9220610a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -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 PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY"); + private static final Set ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); + private static final Set 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 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 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 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 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 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 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 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 voucher(long requestId) { + RechargeRequest request = requiredRecharge(requestId, null); + long customerId = beforeCustomerId(requestId); + return fileClient.download(internalToken, customerId, customerId, request.voucherFileId()); + } + + public ResponseEntity 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 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 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> 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 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 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); } +} diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index ea9b2d17a..a1d49b76e 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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='客户资金账户不可变余额流水'; diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index d78234a15..82efc8dc7 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest { 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); } if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 952f007de..78ec93a87 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest { missing.add(currentTable + "." + column.group(1)); } } - assertThat(tableCount).isEqualTo(17); - assertThat(columnCount).isEqualTo(265); + assertThat(tableCount).isEqualTo(20); + assertThat(columnCount).isEqualTo(310); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java new file mode 100644 index 000000000..33a080c02 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java @@ -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 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); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java new file mode 100644 index 000000000..35e5f5d85 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -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"); + } +} diff --git a/iam/src/main/resources/db/wallet-permissions.sql b/iam/src/main/resources/db/wallet-permissions.sql new file mode 100644 index 000000000..d076b723c --- /dev/null +++ b/iam/src/main/resources/db/wallet-permissions.sql @@ -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; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 663898983..a514bfa67 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 4bc5cba81..a100dd534 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -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:") diff --git a/iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java new file mode 100644 index 000000000..b51377eb1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java @@ -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); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index fbec39b3f..b62d26115 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -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 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index fb7e57e03..db1f39048 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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 })