Compare commits

...
90 Commits
Author SHA1 Message Date
dzc af755b390e feat:余额区间查询 2026-11-06 14:40:23 +08:00
dzc 886d23e3db feat:余额排序 2026-11-06 08:53:33 +08:00
dzc 94a0cd5f92 feat:批量充值扣款审批回调优化 2026-11-02 10:44:51 +08:00
dzc be93d4ae50 fix:发票盖章用到底图被图片加密拦截的问题 2026-10-30 16:28:49 +08:00
dzc 9347410909 111 2026-10-29 23:50:19 +08:00
dzc 8b5b564292 Merge branch 'master' of https://gitea.zianxin.com/zianxin/pj_service_cloud 2026-10-29 22:21:36 +08:00
dzc e3ede5f5f2 feat:图片加密忽略面板页面 2026-10-29 22:21:34 +08:00
zjf a663c30028 Merge remote-tracking branch 'origin/master' 2026-10-29 22:12:23 +08:00
zjf b31a77334a 修复漏洞 2026-10-29 21:59:11 +08:00
dzc 18ebd245a5 feat:图片上传修改 2026-10-29 19:29:37 +08:00
dzc 26d968ec55 feat:日志敏感信息过滤 2026-10-29 15:46:08 +08:00
dzc 2c1bf9b967 Merge branch 'master' of https://gitea.zianxin.com/zianxin/pj_service_cloud
# Conflicts:
#	pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysClientController.java
2026-10-29 14:29:13 +08:00
dzc bbde6cc87f fix:图片访问
fix:权限修改
2026-10-29 14:28:38 +08:00
zjf bb0b8bd7ef 修复漏洞 2026-10-29 13:27:20 +08:00
dzc 710e4cfb90 feat:消费机支付卡顿的问题 2026-10-23 17:13:48 +08:00
dzc 42e0323080 feat:下单操作写入日志 2026-10-23 16:07:22 +08:00
dzc 0259445866 111 2026-10-23 14:29:24 +08:00
dzc 9f9e3c6d04 feat:钱包余额统计查询 2026-10-23 10:28:31 +08:00
dzc c0285edd4c feat:流水错误修改 2026-10-22 19:22:16 +08:00
dzc 3ae6fdbfb4 111 2026-10-22 16:17:46 +08:00
dzc 4a3c8c1938 11 2026-10-22 14:16:46 +08:00
dzc f4c4390352 111 2026-10-21 19:39:59 +08:00
dzc c34b249d7f feat:对账以wallet_transaction表为准 2026-09-30 16:35:46 +08:00
dzc b0b16ae58f 排序 2026-09-30 15:40:04 +08:00
dzc 8e5c8ea24c feat:店铺折扣 2026-09-30 15:08:07 +08:00
dzc aa3897a651 fix:人脸质量上报bug修改 2026-09-30 10:10:15 +08:00
dzc da167a46ef Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud 2026-09-29 19:55:58 +08:00
dzc 7ae8d3e14b feat:浦江优化 2026-09-29 19:55:51 +08:00
zjf 731a4f8b8d seata测试 2026-09-19 23:46:43 +08:00
zjf da549b19aa Merge remote-tracking branch 'origin/master' 2026-09-14 17:43:59 +08:00
zjf dd0c661223 增加浙政钉接口日志 2026-09-14 17:43:45 +08:00
dzc cb8f5fc2e6 feat:代码 2026-09-11 16:35:08 +08:00
dzc ddfc970928 feat:修改流水记录加上乐观锁 2026-09-11 15:28:21 +08:00
dzc 9dbd345fc9 123123 2026-09-10 21:01:30 +08:00
dzc 535ede3c6f 11 2026-09-10 18:50:46 +08:00
dzc c714612ace feat:人脸质量同步、人脸支付上传凭证 2026-09-10 17:50:10 +08:00
dzc 037ce89cca 11 2026-09-10 16:42:34 +08:00
dzc bea1783c7c 11 2026-09-10 16:38:26 +08:00
dzc 10eec976c2 feat:机器订单支付速度优化 2026-09-10 16:23:11 +08:00
dzc 241e9b0c46 feat:人脸质量 2026-09-10 15:34:34 +08:00
dzc 3effd6f9be 11 2026-09-10 13:55:01 +08:00
dzc b254ef0783 11 2026-09-10 13:09:14 +08:00
dzc 2ca14aa1e0 feat:消费机更新
feat:人员调用钱包部门不调动的bug
2026-09-09 11:56:03 +08:00
dzc 1c86c227ee feat:智能柜手动结算 2026-09-08 15:57:47 +08:00
dzc 1ecbcf540c Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud 2026-09-08 15:25:28 +08:00
dzc 8995bf89d6 feat:更改页面 2026-09-08 15:25:23 +08:00
zjf e587cbc7cd Merge remote-tracking branch 'origin/master' 2026-09-01 20:52:35 +08:00
zjf 7a2b991aa0 修复短信问题 2026-09-01 20:52:22 +08:00
dzc 4e804a17cd 11 2026-08-25 14:05:02 +08:00
zjf 287e8134db 单点日志 2026-08-10 00:39:46 +08:00
zjf e260c015ab 单点日志 2026-08-10 00:24:38 +08:00
zjf 17015621e1 Merge remote-tracking branch 'origin/master' 2026-08-10 00:07:37 +08:00
zjf 60446146f9 单点日志,菜单 2026-08-10 00:07:21 +08:00
dzc 83fb0dd2a6 feat:查询优化 2026-08-09 14:44:53 +08:00
dzc 2b2969dc92 feat:刷新全部二维码 2026-08-09 13:47:59 +08:00
dzc 2560fb1ace 11 2026-08-09 13:26:37 +08:00
dzc be3be6179f feat:增加语音日志 2026-08-06 09:44:52 +08:00
dzc f8f938b04c 111 2026-08-02 19:24:09 +08:00
dzc d1884d813f 11 2026-08-02 13:28:21 +08:00
dzc 295077a1e8 Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud 2026-08-02 13:04:00 +08:00
dzc 880d19ec94 11 2026-08-02 13:03:56 +08:00
zjf dc80a20b71 Merge remote-tracking branch 'origin/master' 2026-08-02 00:04:32 +08:00
zjf afd56a0586 nacos yml配置 2026-08-02 00:04:03 +08:00
dzc 0925f01b54 Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud 2026-08-01 16:27:47 +08:00
dzc dae713eb0a feat:域名修改 2026-08-01 16:27:44 +08:00
zjf 33a8d5dd6a Merge remote-tracking branch 'origin/master' 2026-07-31 00:12:50 +08:00
zjf febc01042e nacos yml配置 2026-07-30 22:43:54 +08:00
zjf 1c11e78be0 nacos yml配置 2026-07-30 21:54:10 +08:00
zjf 66bedfc7f5 nacos yml配置 2026-07-30 21:42:57 +08:00
zjf 218ca53dd2 nacos yml配置 2026-07-30 17:21:54 +08:00
zjf a857b1213c nacos yml配置 2026-07-30 15:54:45 +08:00
dzc a1c466b18b fix:优惠券扣减问题 2026-07-30 10:01:37 +08:00
zjf 1147ea1e48 诗画调整 2026-07-27 11:40:21 +08:00
zjf 17736d12e2 诗画调整 2026-07-27 10:43:11 +08:00
zjf 986ee20a9e yml 2026-07-27 09:25:18 +08:00
dzc dbf20d66d4 feat:平台流水抽成 2026-07-14 16:06:10 +08:00
dzc 77d7700709 feat:增加钱包类型搜索条件 2026-07-13 16:08:51 +08:00
dzc 67823d9644 fix:充值钱包统计优化 2026-07-13 12:36:54 +08:00
dzc 19e7b42c4f feat:重新生成发票位置 2026-07-13 11:24:07 +08:00
dzc 640a075742 11 2026-07-13 10:17:35 +08:00
dzc f2f719d608 fix:盖章bug,商户统计bug 2026-07-11 17:31:03 +08:00
dzc d29393692c feat:智能柜子商品排序 2026-06-18 16:28:20 +08:00
dzc 41819feaa1 feat:商户部门查询 2026-06-18 09:30:24 +08:00
dzc 4faa7bb759 Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud 2026-06-17 20:22:41 +08:00
dzc db02c79d9b 1 2026-06-17 20:22:38 +08:00
zjf df841a80f7 超市模块系统反馈修改 2026-06-16 21:52:33 +08:00
dzc 78aeb77cd6 feat:订单钱包类型查询 2026-06-02 10:32:13 +08:00
dzc cda3fff6f4 11 2026-05-28 16:26:55 +08:00
dzc 42e3e273fe feat:过期时间判断 2026-05-28 15:59:00 +08:00
dzc fc820ba3b1 11 2026-05-28 15:29:35 +08:00
627 changed files with 47447 additions and 3091 deletions

No files matched your search

+3
View File
@@ -61,3 +61,6 @@ gen_code
### node ### ### node ###
node_modules node_modules
# Local file-access credentials
.file-access/
+5
View File
@@ -86,6 +86,11 @@ CREATE TABLE `third_pay_log`
COLLATE = utf8mb4_general_ci COLLATE = utf8mb4_general_ci
ROW_FORMAT = DYNAMIC COMMENT ='第三方支付日志'; ROW_FORMAT = DYNAMIC COMMENT ='第三方支付日志';
CREATE INDEX idx_tpl_tenant_del_ct_id
ON third_pay_log (tenant_id, del_flag, create_time, id);
CREATE INDEX idx_tpl_tenant_del_source_biz_ct_id
ON third_pay_log (tenant_id, del_flag, pay_source_type, biz_type, create_time, id);
-- ---------------------------- -- ----------------------------
-- Table structure for third_key_manage -- Table structure for third_key_manage
-- ---------------------------- -- ----------------------------
@@ -0,0 +1,44 @@
-- 订单识别证据图片表(迁移自 hq-platform 20260823_add_hard_order_evidence_image.sql)。
-- 本表独立于 hard_order,退款、撤销和订单逻辑删除均不自动清理证据文件或证据记录。
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_order_evidence_image`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
`order_id` bigint(20) NOT NULL COMMENT '订单主键,来源hard_order.id',
`android_order_code` varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
`evidence_type` varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
`evidence_role` varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
`sequence_no` int(11) NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
`user_id` bigint(20) DEFAULT NULL COMMENT '识别用户主键快照',
`face_sample_id` varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
`reference_image_url` varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
`similarity` decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
`similarity_threshold` decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
`hard_id` bigint(20) NOT NULL COMMENT '产生证据的设备主键',
`captured_at` datetime DEFAULT NULL COMMENT '终端实际采集时间',
`sys_file_id` bigint(20) DEFAULT NULL COMMENT '文件中心sys_file记录主键',
`storage_file_name` varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
`image_url` varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
`image_hash` char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
`image_size` bigint(20) DEFAULT NULL COMMENT '实际文件大小,单位字节',
`image_width` int(11) DEFAULT NULL COMMENT '实际图片宽度,单位像素',
`image_height` int(11) DEFAULT NULL COMMENT '实际图片高度,单位像素',
`status` varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
`failure_reason` varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
`app_version` varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint(20) NOT NULL COMMENT '租户主键',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hard_order_evidence_position`
(`tenant_id`, `order_id`, `evidence_type`, `evidence_role`, `sequence_no`, `del_flag`),
KEY `idx_hard_order_evidence_android_order`
(`tenant_id`, `hard_id`, `android_order_code`, `del_flag`),
KEY `idx_hard_order_evidence_status`
(`tenant_id`, `status`, `update_time`, `del_flag`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '硬件订单识别证据图片';
@@ -0,0 +1,23 @@
-- 硬件订单新增独立核销时间(迁移自 hq-platform 20260823_add_hard_order_verification_time.sql)。
-- 历史二维码核销订单只能使用最后更新时间近似回填;外卖等非核销完成状态不参与回填。
-- 新核销订单由业务事务写入准确时间,后续订单更新不再改变该值。
SET @verification_time_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_order'
AND `column_name` = 'verification_time') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `verification_time` datetime DEFAULT NULL COMMENT ''订单实际核销完成时间,后续订单更新不得覆盖'' AFTER `refund_status`, ADD KEY `idx_hard_order_verification_time` (`tenant_id`, `verification_time`, `del_flag`)',
'SELECT 1'
);
PREPARE verification_time_stmt FROM @verification_time_ddl;
EXECUTE verification_time_stmt;
DEALLOCATE PREPARE verification_time_stmt;
UPDATE `pjcloudx_hardware`.`hard_order`
SET `verification_time` = `update_time`
WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4'))
OR `pay_type` IN ('5', '6', '50'))
AND `verification_time` IS NULL
AND `update_time` IS NOT NULL;
SET @verification_time_ddl = NULL;
+206
View File
@@ -0,0 +1,206 @@
-- 尔印独立打印终端、终端一对一绑定及持久化打印任务(迁移自 hq-platform 20260904_add_er_yin_printer.sql,仅保留 DDL)。
-- 字典(hard_type 类型12、hard_print_type、hard_print_status)与按钮权限请按 db/20260908_权限菜单字典待添加.txt 手动添加。
-- 每个结构变更独立判断,可在兼容数据库自动提交部分DDL后安全重跑整个脚本。
ALTER TABLE `pjcloudx_hardware`.`hard_invoice`
MODIFY COLUMN `hard_id` bigint(20) NULL COMMENT '来源业务或核销终端id',
MODIFY COLUMN `pic` varchar(512) NULL COMMENT '后端生成的打印PNG地址';
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'printer_hard_id') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `printer_hard_id` bigint(20) NULL COMMENT ''目标尔印打印终端id'' AFTER `hard_id`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'print_type') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `print_type` char(2) NOT NULL DEFAULT ''1'' COMMENT ''打印类型,1发票、2兑换券'' AFTER `order_id`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'print_no') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `print_no` int(11) NOT NULL DEFAULT 1 COMMENT ''同订单同类型打印序号'' AFTER `print_type`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'retry_version') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `retry_version` int(11) NOT NULL DEFAULT 0 COMMENT ''人工失败重试版本'' AFTER `print_no`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'attempt_count') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT ''终端USB累计尝试次数'' AFTER `retry_version`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'failure_stage') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `failure_stage` varchar(32) NULL COMMENT ''BINDING、IMAGE_GENERATION、MQTT_DISPATCH或TERMINAL_PRINT'' AFTER `status`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'last_error') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `last_error` varchar(2000) NULL COMMENT ''最近失败摘要'' AFTER `failure_stage`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'printed_at') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `printed_at` datetime NULL COMMENT ''终端确认完整USB数据和切纸命令写入成功时间'' AFTER `last_error`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`columns`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `column_name` = 'reprint_reason') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `reprint_reason` varchar(500) NULL COMMENT ''人工重打或再次核销原因'' AFTER `printed_at`',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
-- 唯一索引尚未建立才回填历史发票,避免迁移完成后重跑时覆盖新产生的兑换券任务。
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`statistics`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `index_name` = 'uk_hard_invoice_order_type_no') = 0,
'UPDATE `pjcloudx_hardware`.`hard_invoice` invoice
JOIN (
SELECT ranked.`id`, ranked.`print_no`
FROM (
SELECT `id`, ROW_NUMBER() OVER (
PARTITION BY `tenant_id`, `order_id`
ORDER BY `create_time`, `id`
) AS `print_no`
FROM `pjcloudx_hardware`.`hard_invoice`
) ranked
) ranked_rows ON ranked_rows.`id` = invoice.`id`
SET invoice.`print_type` = ''1'',
invoice.`print_no` = ranked_rows.`print_no`,
invoice.`retry_version` = 0,
invoice.`attempt_count` = 0',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`statistics`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `index_name` = 'uk_hard_invoice_order_type_no') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD UNIQUE KEY `uk_hard_invoice_order_type_no` (`tenant_id`, `order_id`, `print_type`, `print_no`)',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`statistics`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `index_name` = 'idx_hard_invoice_printer_status') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD KEY `idx_hard_invoice_printer_status` (`tenant_id`, `printer_hard_id`, `status`, `update_time`, `del_flag`)',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
SET @er_yin_ddl = IF(
(SELECT COUNT(*) FROM `information_schema`.`statistics`
WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice'
AND `index_name` = 'idx_hard_invoice_source_status') = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD KEY `idx_hard_invoice_source_status` (`tenant_id`, `hard_id`, `status`, `del_flag`)',
'SELECT 1'
);
PREPARE er_yin_stmt FROM @er_yin_ddl;
EXECUTE er_yin_stmt;
DEALLOCATE PREPARE er_yin_stmt;
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_printer_binding`
(
`id` bigint(20) NOT NULL COMMENT '绑定关系主键',
`source_hard_id` bigint(20) NOT NULL COMMENT 'SUPERMARKET或CANTEEN来源业务终端id',
`printer_hard_id` bigint(20) NOT NULL COMMENT 'EY_PRINT目标打印终端id',
`merchant_id` bigint(20) NOT NULL COMMENT '双方共同所属商户id快照',
`dept_id` bigint(20) NOT NULL COMMENT '双方共同所属商户部门id快照',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hard_printer_binding_source` (`tenant_id`, `source_hard_id`),
UNIQUE KEY `uk_hard_printer_binding_printer` (`tenant_id`, `printer_hard_id`),
KEY `idx_hard_printer_binding_merchant` (`tenant_id`, `merchant_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci
COMMENT = '业务终端与尔印打印终端一对一绑定';
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_printer_config`
(
`id` bigint(20) NOT NULL COMMENT '配置主键',
`printer_hard_id` bigint(20) NOT NULL COMMENT 'EY_PRINT打印终端id',
`printer_model` varchar(128) DEFAULT NULL COMMENT '打印机型号',
`usb_vendor_id` int(11) DEFAULT NULL COMMENT 'USB Vendor ID十进制值',
`usb_product_id` int(11) DEFAULT NULL COMMENT 'USB Product ID十进制值',
`paper_width` int(11) NOT NULL DEFAULT 80 COMMENT '纸宽,单位mm,首期固定80',
`auto_cut` char(1) NOT NULL DEFAULT '1' COMMENT '自动切刀开关,1开启、0关闭',
`image_mode` varchar(16) NOT NULL DEFAULT 'GS_V0' COMMENT '图片指令模式,GS_V0或ESC_STAR',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hard_printer_config_printer` (`tenant_id`, `printer_hard_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci
COMMENT = '尔印打印终端USB与ESC/POS配置';
SET @er_yin_ddl = NULL;
@@ -0,0 +1,38 @@
-- 终端日志文件表(hq-platform 无独立建表脚本,本 DDL 依据 HardTerminalLogFileEntity 与查询场景编写)。
-- 记录 Android 终端上传到文件中心的日志文件索引,同一文件重复上传时累加 upload_count 并更新地址。
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_terminal_log_file`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`hard_id` bigint NOT NULL COMMENT '设备ID(hard_manager.id)',
`hard_name` varchar(128) DEFAULT NULL COMMENT '上传时设备名称快照',
`unique_code` varchar(128) DEFAULT NULL COMMENT '上传时设备唯一编号快照',
`hard_type` char(10) DEFAULT NULL COMMENT '上传时设备类型快照',
`merchant_id` bigint DEFAULT NULL COMMENT '上传时商户ID快照',
`dept_id` bigint DEFAULT NULL COMMENT '上传时部门ID快照',
`log_date` date NOT NULL COMMENT '日志所属日期',
`relative_path` varchar(512) NOT NULL COMMENT '终端日志根目录下的相对路径',
`original_name` varchar(255) DEFAULT NULL COMMENT '原日志文件名',
`file_size` bigint DEFAULT NULL COMMENT '文件大小(字节)',
`file_hash` char(32) DEFAULT NULL COMMENT '文件内容哈希(当前使用MD5)',
`file_modified_time` datetime DEFAULT NULL COMMENT '终端本地文件最后修改时间',
`sys_file_id` bigint DEFAULT NULL COMMENT '文件中心sys_file记录ID',
`storage_file_name` varchar(255) DEFAULT NULL COMMENT 'OSS对象文件名',
`storage_url` varchar(512) DEFAULT NULL COMMENT '文件访问相对地址',
`app_version` varchar(64) DEFAULT NULL COMMENT '上传时应用版本',
`sanitize_version` varchar(64) DEFAULT NULL COMMENT '日志脱敏规则版本',
`upload_time` datetime DEFAULT NULL COMMENT '最近成功上传时间',
`upload_count` int NOT NULL DEFAULT 1 COMMENT '成功上传次数',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` char(1) DEFAULT '0' COMMENT '删除标记',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hard_terminal_log_position` (`tenant_id`, `hard_id`, `relative_path`, `del_flag`),
KEY `idx_hard_terminal_log_date` (`tenant_id`, `hard_id`, `log_date`, `del_flag`),
KEY `idx_hard_terminal_log_hash` (`tenant_id`, `file_hash`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT ='终端日志文件';
+25
View File
@@ -0,0 +1,25 @@
-- 终端心跳状态表(迁移自 hq-platform 20260730_add_hard_terminal_status.sql)。
-- 食堂、超市 Android 终端心跳状态。在线状态由服务端按最后心跳时间实时计算,不冗余落库。
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_terminal_status` (
`hard_id` bigint NOT NULL COMMENT '设备ID,对应hard_manager.id',
`last_online_time` datetime NOT NULL COMMENT '服务端最近收到心跳的时间',
`heartbeat_scene` varchar(32) NOT NULL COMMENT '最近心跳场景(ONLINE,AUTO_UPDATE)',
`disconnect_reason` varchar(128) DEFAULT NULL COMMENT '计划内断开原因,目前用于标记自动更新重启',
`apk_version_name` varchar(64) DEFAULT NULL COMMENT '终端上报的APK展示版本号',
`apk_version_code` bigint DEFAULT NULL COMMENT '终端上报的APK内部版本码',
`system_uptime_seconds` bigint DEFAULT NULL COMMENT 'Android系统本次启动后运行秒数',
`app_uptime_seconds` bigint DEFAULT NULL COMMENT '终端App本次进程运行秒数',
`network_type` varchar(32) DEFAULT NULL COMMENT '最近心跳使用的网络类型',
`available_storage_bytes` bigint DEFAULT NULL COMMENT '应用存储卷最近可用空间,单位字节',
`root_status` varchar(16) DEFAULT NULL COMMENT 'Root状态(unknown,available,unavailable)',
`device_owner` tinyint(1) DEFAULT NULL COMMENT '当前应用是否为Device Owner',
`kiosk_active` tinyint(1) DEFAULT NULL COMMENT '最近心跳时是否处于Kiosk锁定任务模式',
`device_status_json` json DEFAULT NULL COMMENT '打印机、电子秤、读卡器、副屏等可选硬件状态快照',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`hard_id`),
KEY `idx_hard_terminal_status_tenant_online` (`tenant_id`, `last_online_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='食堂超市终端心跳状态';
+30
View File
@@ -0,0 +1,30 @@
-- 消费机日志表(迁移自 hq-platform 800pjcloudx_hardware.sql 末段)。
-- 记录消费机终端 HTTP 调用日志(req/res/error/耗时),unique_code 唯一。
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_xfj_log`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`unique_code` varchar(64) NOT NULL COMMENT '设备唯一编号',
`hard_type` varchar(32) NOT NULL COMMENT '设备类型',
`title` varchar(64) NOT NULL COMMENT '接口标题',
`headers` varchar(2048) COMMENT 'headers',
`res` varchar(2048) COMMENT 'res',
`req` varchar(2048) COMMENT 'req',
`error` varchar(2048) COMMENT 'error',
`start_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`cost` int DEFAULT NULL COMMENT '耗时(毫秒)',
`create_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记',
`tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique_code` (`unique_code`),
KEY `idx_hard_type` (`hard_type`),
KEY `idx_title` (`title`),
KEY `idx_start_time` (`start_time`),
KEY `idx_end_time` (`end_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT ='消费机日志';
@@ -0,0 +1,7 @@
-- 为钱包流水补充财务统计标签和业务备注;历史数据保持为空。
-- tags 保存 wallet_transaction_tag 字典值,多个值使用英文逗号分隔;remark 前后端统一限制为 256 个字符。
ALTER TABLE wallet_transaction
ADD COLUMN tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔' AFTER balance;
ALTER TABLE wallet_transaction
ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags;
@@ -0,0 +1,49 @@
================================================================
智能柜订单手动结算(2026-09-08)—— 权限 手动添加清单
================================================================
说明:第三方(旷世)识别不可用后,智能柜免密订单(payType=9 + 设备类型4)
需要在售后订单详情页人工核对视频后手动结算/取消。以下权限需在 pjcloudx
主库 sys_menu 手动添加(菜单管理界面配置或执行文末幂等 SQL)。
----------------------------------------------------------------
一、按钮权限(sys_menu,menu_type='2')
----------------------------------------------------------------
挂载位置建议:挂在「售后订单详情」可访问的角色/菜单下(订单详情页为
动态路由 order-refund,无独立菜单,建议挂在 hard 订单相关父菜单下,
与 hard_hardwareOrder_view 同级或同父)。
1. hard_hardwareOrder_manualSettle 订单-智能柜手动结算
(售后订单详情页「手动结算」区块:手动结算 + 未拿商品取消订单,共用一个权限)
----------------------------------------------------------------
二、页面菜单
----------------------------------------------------------------
无新增页面(功能内嵌在已有售后订单详情页 order-refund 中),无需配菜单。
----------------------------------------------------------------
三、字典
----------------------------------------------------------------
无新增字典。
----------------------------------------------------------------
四、附:可直接执行的幂等 SQL(按需使用,执行前请备份)
----------------------------------------------------------------
-- 按钮权限(挂在 hard_hardwareOrder_view 所在的父菜单下)
SET @menu_id_base = (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026090801000000000) FROM `pjcloudx`.`sys_menu`);
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT @menu_id_base + ROW_NUMBER() OVER (ORDER BY p.`tenant_id`, p.`menu_id`),
'智能柜手动结算', 'hard_hardwareOrder_manualSettle', p.`menu_id`, '1', 20, '0', '0', '2', 'system', NOW(), '0', p.`tenant_id`
FROM `pjcloudx`.`sys_menu` p
WHERE p.`permission` = 'hard_hardwareOrder_view' AND p.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` e
WHERE e.`tenant_id` = p.`tenant_id` AND e.`permission` = 'hard_hardwareOrder_manualSettle' AND e.`del_flag` = '0'
);
SET @menu_id_base = NULL;
----------------------------------------------------------------
五、数据库迁移
----------------------------------------------------------------
无(复用 hard_door_flow.skuCallbackVo / skuCallbackTime 字段记录手动操作留痕,
不涉及表结构变更,无需执行任何 SQL)。
+158
View File
@@ -0,0 +1,158 @@
================================================================
hard 模块迁移(2026-09-08)—— 权限/菜单/字典 手动添加清单
================================================================
说明:以下内容需要在 pjcloudx 主库(sys_menu / sys_dict / sys_dict_item)手动添加,
可以直接在「系统管理-菜单管理 / 字典管理」界面配置,也可以执行文末附带的 SQL(幂等,可重复执行)。
每项都标注了用途,若目标环境已存在同名项请跳过。
----------------------------------------------------------------
一、按钮权限(sys_menu,menu_type='2')
----------------------------------------------------------------
挂载位置建议:挂在对应页面的父菜单下(与 hard_hardManager_view 同级)。
1. hard_hardTerminalLogFile_view 终端日志-查看 (终端日志页面 + 设备详情页日志区)
2. hard_hardTerminalLogFile_preview 终端日志-预览
3. hard_hardTerminalLogFile_down 终端日志-下载
4. hard_hardTerminalLogFile_pull 终端日志-拉取
5. hard_hardManager_printer_bind 设备管理-绑定打印机 (设备详情页打印配置区)
6. hard_hardManager_export 设备管理-导出 (设备列表导出按钮,若已有则跳过)
7. hard_hardInvoice_voucher_reprint 发票凭证-重打兑换券(打印任务弹窗重打,若不需要可不加)
----------------------------------------------------------------
二、页面菜单(sys_menu,menu_type='1',动态路由)
----------------------------------------------------------------
1. 终端日志(机器日志)
路径 path:/hard/hard-terminal-log-file/index
权限标识:hard_hardTerminalLogFile_view
2. 消费机日志
路径 path:/hard/xfj/xfj-log/index
权限标识:(查看权限可留空或自定 hard_xfjLog_view,页面本身无按钮权限)
说明:
- 设备详情页 /hard/hard-manager/details 已由前端静态路由注册(isHide),无需配菜单。
- 售后订单详情 order-refund 沿用目标库已有菜单,无需改动。
----------------------------------------------------------------
三、字典(sys_dict + sys_dict_item)
----------------------------------------------------------------
1. hard_type(设备类型,已有字典,补充缺失项):
- 9 云版消费机
- 12 尔印打印机器
(若已有 10/11 云音响等项则不动)
2. 新增 hard_print_type(打印凭证类型):
- 1 发票
- 2 兑换券
3. 新增 hard_print_status(打印任务状态):
- 0 待生成
- 1 待打印
- 2 已打印
- 3 打印失败
- 4 已取消
4. 可选:hard_appt_reserve_mode(预约模式,售后详情页预约信息列显示用;
目标库若无此字典该列显示空,不影响其他功能)
----------------------------------------------------------------
四、附:可直接执行的幂等 SQL(按需使用,执行前请备份)
----------------------------------------------------------------
-- 1. hard_type 补类型 9/12(每个已配置 hard_type 的租户幂等插入)
SET @dict_item_id_base = (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090800000000000) FROM `pjcloudx`.`sys_dict_item`);
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`)
SELECT @dict_item_id_base + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`item_value`),
d.`id`, item.`item_value`, item.`label`, 'hard_type', item.`label`, CAST(item.`item_value` AS SIGNED),
'system', NOW(), item.`label`, '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
JOIN (
SELECT '9' AS `item_value`, '云版消费机' AS `label`
UNION ALL SELECT '12', '尔印打印机器'
) item
WHERE d.`dict_type` = 'hard_type' AND d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_type'
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
SET @dict_item_id_base = NULL;
-- 2. hard_print_type / hard_print_status 字典(按租户幂等创建)
SET @dict_id_base = (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090800000000000) FROM `pjcloudx`.`sys_dict`);
INSERT INTO `pjcloudx`.`sys_dict`
(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
SELECT @dict_id_base + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`, t.`dict_type`),
t.`dict_type`, t.`description`, 'system', NOW(), t.`description`, '1', '0', tenant.`tenant_id`
FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
CROSS JOIN (
SELECT 'hard_print_type' AS `dict_type`, '打印凭证类型' AS `description`
UNION ALL SELECT 'hard_print_status', '打印任务状态'
) t
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict` d
WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = t.`dict_type` AND d.`del_flag` = '0'
);
SET @dict_id_base = NULL;
SET @dict_item_id_base = (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090800000000000) FROM `pjcloudx`.`sys_dict_item`);
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT @dict_item_id_base + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, d.`dict_type`, item.`sort_order`, item.`item_value`),
d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
'system', NOW(), '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
JOIN (
SELECT 'hard_print_type' AS `dict_type`, '1' AS `item_value`, '发票' AS `label`, 1 AS `sort_order`
UNION ALL SELECT 'hard_print_type', '2', '兑换券', 2
UNION ALL SELECT 'hard_print_status', '0', '待生成', 0
UNION ALL SELECT 'hard_print_status', '1', '待打印', 1
UNION ALL SELECT 'hard_print_status', '2', '已打印', 2
UNION ALL SELECT 'hard_print_status', '3', '打印失败', 3
UNION ALL SELECT 'hard_print_status', '4', '已取消', 4
) item ON item.`dict_type` = d.`dict_type`
WHERE d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = d.`dict_type`
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
SET @dict_item_id_base = NULL;
-- 3. 按钮权限(挂到已有父权限所在菜单下,menu_type='2')
-- 先在界面确认父菜单,或使用下面按 permission 定位父菜单的幂等插入:
SET @menu_id_base = (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026090800000000000) FROM `pjcloudx`.`sys_menu`);
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT @menu_id_base + ROW_NUMBER() OVER (ORDER BY p.`tenant_id`, p.`menu_id`, item.`permission`),
item.`name`, item.`permission`, p.`menu_id`, '1', 20, '0', '0', '2', 'system', NOW(), '0', p.`tenant_id`
FROM `pjcloudx`.`sys_menu` p
JOIN (
SELECT 'hard_hardTerminalLogFile_view' AS `permission`, '终端日志查看' AS `name`, 'hard_hardManager_view' AS `parent_permission`
UNION ALL SELECT 'hard_hardTerminalLogFile_preview', '终端日志预览', 'hard_hardManager_view'
UNION ALL SELECT 'hard_hardTerminalLogFile_down', '终端日志下载', 'hard_hardManager_view'
UNION ALL SELECT 'hard_hardTerminalLogFile_pull', '终端日志拉取', 'hard_hardManager_view'
UNION ALL SELECT 'hard_hardManager_printer_bind', '绑定打印机', 'hard_hardManager_view'
UNION ALL SELECT 'hard_hardManager_export', '导出设备', 'hard_hardManager_view'
UNION ALL SELECT 'hard_hardInvoice_voucher_reprint', '重打兑换券', 'hard_hardInvoice_view'
) item ON item.`parent_permission` = p.`permission`
WHERE p.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` e
WHERE e.`tenant_id` = p.`tenant_id` AND e.`permission` = item.`permission` AND e.`del_flag` = '0'
);
SET @menu_id_base = NULL;
-- 4. 页面菜单(终端日志 / 消费机日志)—— parent_id 请替换为实际的「设备管理/硬件」目录菜单ID
-- (建议直接在菜单管理界面添加,下面仅作字段参考)
-- INSERT INTO `pjcloudx`.`sys_menu`
-- (`menu_id`,`name`,`permission`,`path`,`parent_id`,`icon`,`visible`,`sort_order`,`keep_alive`,`embedded`,`menu_type`,`create_by`,`create_time`,`del_flag`,`tenant_id`)
-- VALUES
-- (自定义ID, '终端日志', 'hard_hardTerminalLogFile_view', '/hard/hard-terminal-log-file/index', <父菜单ID>, NULL, '1', 100, '0', NULL, '1', 'admin', NOW(), '0', 1),
-- (自定义ID, '消费机日志', NULL, '/hard/xfj/xfj-log/index', <父菜单ID>, NULL, '1', 101, '0', NULL, '1', 'admin', NOW(), '0', 1);
----------------------------------------------------------------
五、数据库迁移 SQL 执行顺序(pj_service_cloud/db/ 下本次新增)
----------------------------------------------------------------
1. 20260908_add_hard_terminal_status.sql
2. 20260908_add_hard_terminal_log_file.sql
3. 20260908_add_hard_order_evidence_image.sql
4. 20260908_add_hard_printer.sql (含 hard_invoice 打印任务化,注意先备份)
5. 20260908_add_hard_xfj_log.sql
6. 20260908_add_hard_order_verification_time.sql
@@ -0,0 +1,18 @@
-- 订单分页(POST hard/order/page-join)性能优化配套索引(OceanBase MySQL 模式)
-- 背景:HardOrderMapper.xml 已参考 biz-api-cloud-zax 将"每单最新售后"的全表 GROUP BY
-- 派生表改写为相关标量子查询:
-- select max(id) from hard_after_sale where order_id = ? and del_flag = '0'
-- 该写法需要 (order_id, del_flag, id) 前导索引支撑;此前 slowlog_optimization_20260826
-- 推荐的 (tenant_id, order_id, id) 因租户列在前导位,无法服务新子查询。
-- 执行前请确认同名索引不存在;建议低峰期执行并观察建索引耗时。本文件只包含索引 DDL。
CREATE INDEX idx_hard_after_sale_order_del_id
ON pjcloudx_hardware.hard_after_sale (order_id, del_flag, id);
-- 说明:
-- 1) 若 db/slowlog_optimization_20260826/01_add_indexes.sql 尚未执行,请一并执行其中
-- idx_hard_order_tenant_del_ct_uid (tenant_id, del_flag, create_time, user_id):
-- 订单列表 ORDER BY create_time desc, user_id desc, id desc 依赖该索引做反向范围扫描
-- (InnoDB/OceanBase 二级索引隐含主键 id 后缀,id desc 排序可直接复用,无需额外索引)。
-- 2) 20260826 中的 idx_hard_after_sale_tenant_order_id 服务于旧派生表写法;本次新索引
-- 上线后如确认无其他统计 SQL 依赖,可自行评估删除,本文件不强制处理。
@@ -0,0 +1,6 @@
-- 智能柜手动结算「操作快照」字段(2026-09-09)
-- 用途:手动结算/取消订单时,在 hard_door_flow 保存完整操作快照
-- (操作人id/姓名/时间/购物车明细含商品名单价/合计/支付方式/备注),供事后二次判断复核
-- 与旷世识别回调字段(sku_callback_vo)互不干扰。
ALTER TABLE hard_door_flow
ADD COLUMN manual_settle_vo text DEFAULT NULL COMMENT '手动结算/取消操作快照(操作人+购物车明细+金额+备注)';
@@ -0,0 +1,8 @@
-- 终端虹软静态图片质量结果列(迁移自 hq-platform/biz-api-cloud-zax db/20260901_add_sys_user_face_quality.sql)。
-- 保存终端虹软静态图片质量结果;旧终端和历史人脸保持 NULL,不影响现有人脸业务。
ALTER TABLE sys_user_face
ADD COLUMN quality_score decimal(5, 4) DEFAULT NULL COMMENT '虹软静态图片质量分,范围0-1' AFTER face_img,
ADD COLUMN quality_status varchar(16) DEFAULT NULL COMMENT '终端检测状态:detected/failed' AFTER quality_score,
ADD COLUMN quality_checked_at datetime DEFAULT NULL COMMENT '终端完成质量检测的时间' AFTER quality_status,
ADD COLUMN quality_error_code int DEFAULT NULL COMMENT '虹软或终端质量检测错误码' AFTER quality_checked_at,
ADD COLUMN quality_device_id bigint DEFAULT NULL COMMENT '最后一次上报质量的终端ID' AFTER quality_error_code;
@@ -0,0 +1,206 @@
-- ================================================================
-- 智能柜订单手动结算 —— 测试数据脚本(2026-09-09)
-- ================================================================
-- 用途:在【测试环境】生成 10 笔可手动结算的智能柜免密订单
-- pay_type='9'(智能柜免密) + sys_type='4'(生鲜柜) + order_status='1'(待结算)
-- 覆盖表:hard_order(订单)/ hard_auth(用户授权)/ hard_door_flow(智能柜流程)
-- 标记:三张表 create_by='test-manual-settle',可重复执行(先清理再生成)
-- 执行:mysql -h127.0.0.1 -P33905 -uroot -p pjcloudx_hardware < 本文件
--
-- 前置条件(缺一不可,脚本会自动挑选并检查):
-- 1. 已有智能柜设备:hard_door_manager + hard_door_cabinet 有数据
-- 2. 柜内已配置商品:hard_spu_join 有 spu_id 指向有效 hard_spu 的记录
-- (否则订单详情页手动结算区显示"未找到该柜的商品配置",无法结算)
-- 3. 钱包库存在消费钱包(wallet_type.unique_code='2',同租户优先)
-- —— 结算时会真实扣款(force 强制扣款允许透支),并真实生成钱包流水
-- 说明:
-- - 本地 docker 为分库架构:硬件=pjcloudx_hardware 钱包=pjcloudx_wallet 用户=pjcloudx;
-- 单库环境请去掉 SQL 里的 pjcloudx_wallet. / pjcloudx. 库名前缀
-- - 订单 order_type='1'(BELOW 线下购买),与生产 TOB 用户走钱包扣款一致;
-- 不要用 TOC_VISITOR('8'),那会走支付宝免密扣款,测试环境无第三方单会结算失败
-- - 流程 flow_status='50'(已提交旷世识别、等待回调),即旷世跑路后的真实卡死状态
-- - 视频为公网示例视频(主摄 vjs.zencdn.net / 辅摄 MDN,浏览器实测可达;
-- media.w3.org / Google 桶在部分网络环境会被阻断,勿用),每单主摄/辅摄两路
-- - 极老库若报 parent_car_id 不能为 NULL,先执行:
-- ALTER TABLE hard_order MODIFY parent_car_id bigint NULL DEFAULT NULL;
-- ================================================================
-- ---------- 一、清理旧测试数据(脚本可重复执行) ----------
DELETE FROM hard_door_flow WHERE create_by = 'test-manual-settle';
DELETE FROM hard_auth WHERE create_by = 'test-manual-settle';
DELETE FROM hard_order WHERE create_by = 'test-manual-settle';
-- ---------- 二、自动挑选基础数据 ----------
-- 智能柜:优先挑有商品配置的柜子(hard_door_manager.id / 设备id / 租户 / 设备编号)
SELECT dm.id, dm.hard_id, dm.tenant_id, dm.device_code
INTO @hard_door_id, @hard_id, @tenant_id, @device_code
FROM hard_door_manager dm
WHERE dm.del_flag = '0'
AND EXISTS (
SELECT 1 FROM hard_spu_join j
WHERE j.hard_door_id = dm.id AND j.del_flag = '0' AND j.spu_id IS NOT NULL
)
ORDER BY dm.id
LIMIT 1;
-- 货柜号:挑该柜配置商品最多的货柜
SELECT j.cabinet_no INTO @cabinet_no
FROM hard_spu_join j
WHERE j.hard_door_id = @hard_door_id AND j.del_flag = '0' AND j.spu_id IS NOT NULL
GROUP BY j.cabinet_no
ORDER BY COUNT(*) DESC
LIMIT 1;
-- 商户信息:从设备取
SELECT MAX(m.merchant_id), MAX(m.dept_id) INTO @merchant_id, @merchant_dept_id
FROM hard_manager m
WHERE m.id = @hard_id AND m.del_flag = '0';
-- 商户类型:优先沿用同商户已有订单的 merchant_type(查不到则为 NULL,不影响结算)
SELECT MAX(o.merchant_type) INTO @merchant_type
FROM hard_order o
WHERE o.merchant_id = @merchant_id AND o.merchant_type IS NOT NULL;
-- 测试用户 + 消费钱包(wallet_type.unique_code='2'),同租户优先
SELECT w.user_id, w.id INTO @user_id, @wallet_id
FROM pjcloudx_wallet.wallet_manage w
JOIN pjcloudx_wallet.wallet_type t ON t.id = w.wallet_type_id AND t.del_flag = '0' AND t.unique_code = '2'
WHERE w.del_flag = '0'
ORDER BY (w.tenant_id = @tenant_id) DESC, w.id
LIMIT 1;
-- 用户部门(sys_user 不在本实例时可注释掉,手工 SET @user_dept_id)
SELECT MAX(u.dept_id) INTO @user_dept_id
FROM pjcloudx.sys_user u
WHERE u.user_id = @user_id;
-- 测试主键基址:各表当前最大 id + 100 亿,避开现有与后续雪花 id
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @order_id_base FROM hard_order;
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @flow_id_base FROM hard_door_flow;
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @auth_id_base FROM hard_auth;
-- ★ 检查:除 @merchant_type 外下列都不能为 NULL;为 NULL 说明缺前置数据,
-- 请手工 SET 对应变量(或先在 柜机管理页 给柜子上架商品)后重跑
SELECT @hard_door_id AS 柜配置id,
@hard_id AS 设备id,
@cabinet_no AS 货柜号,
@tenant_id AS 租户,
@merchant_id AS 商户id,
@merchant_dept_id AS 商户部门,
@merchant_type AS 商户类型_可空,
@user_id AS 用户id,
@wallet_id AS 钱包id,
@order_id_base AS 订单id基址;
-- ---------- 三、生成 10 组 授权 / 流程 / 订单 ----------
-- 1. 用户授权(hard_auth):已授权、未完成
INSERT INTO hard_auth
(id, user_id, hard_id, cabinet_no, auth_type, auth_status, auth_time, is_done, create_by, create_time, del_flag, tenant_id)
SELECT @auth_id_base + n, @user_id, @hard_id, @cabinet_no, 'TEST', '1',
NOW() - INTERVAL (n * 2) HOUR, '0',
'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- 2. 智能柜流程(hard_door_flow):卡在"已提交识别、等待旷世回调"(flow_status='50')
INSERT INTO hard_door_flow
(id, user_id, user_type, biz_type, hard_id, hard_door_id, cabinet_no, order_id, auth_id,
request_open_dto, request_open_vo, request_open_time,
is_open, open_callback_time, open_callback_vo,
video_callback_vo, video_callback_time,
sku_submit_dto, sku_submit_vo, sku_submit_recog_id, sku_submit_time,
flow_status, create_by, create_time, del_flag, tenant_id)
SELECT
@flow_id_base + n, @user_id, '0', '10', @hard_id, @hard_door_id, @cabinet_no,
@order_id_base + n, @auth_id_base + n,
-- 请求开门参数/返回
CONCAT('{"outOpenNo":"', @flow_id_base + n, '","hardDoorId":', @hard_door_id, ',"cabinetNo":', @cabinet_no, ',"userId":', @user_id, '}'),
CONCAT('{"code":0,"msg":"成功","data":{"outOpenNo":"', @flow_id_base + n, '","isOpen":true}}'),
NOW() - INTERVAL (n * 2 - 2) MINUTE,
-- 开门回调(成功开门)
'1', NOW() - INTERVAL (n * 2 - 1) MINUTE,
CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0), '","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS"}'),
-- 视频回调(公网示例视频,主摄/辅摄两路)
CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0),
'","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS","resultMsg":null,"videoList":[',
'{"cameraNo":1,"videoUrl":"https://vjs.zencdn.net/v/oceans.mp4"},',
'{"cameraNo":2,"videoUrl":"https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"}]}'),
NOW() - INTERVAL (n * 2) HOUR + INTERVAL 3 MINUTE,
-- 已提交旷世识别(模拟旷世跑路前的最后一步,识别回调永远不回来)
CONCAT('{"recog_id":"', @flow_id_base + n, '","resource_type":"1","container_id":"', @hard_door_id,
'","sub_mch_id":"', @order_id_base + n,
'","resource_urls":["https://media.w3.org/2010/05/sintel/trailer.mp4"]}'),
CONCAT('{"code":0,"is_success":true,"ks_recog_id":"TEST-RECOG-', n, '","msg":"提交成功"}'),
CONCAT('TEST-RECOG-', n),
NOW() - INTERVAL (n * 2) HOUR + INTERVAL 4 MINUTE,
-- 流程状态 50=已提交识别;sku_callback_*/pay_time 留空(未结算)
'50', 'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- 3. 订单(hard_order):待结算的智能柜免密订单(形态对齐后端 createHardDoorOrder)
INSERT INTO hard_order
(id, user_id, user_dept_id, merchant_id, merchant_type, merchant_dept_id, hard_id, wallet_id,
order_type, order_status, pay_type, sys_type,
total_price, extra_price, pay_price, is_offline_order, product_desc,
create_by, create_time, del_flag, tenant_id)
SELECT @order_id_base + n, @user_id, @user_dept_id, @merchant_id, @merchant_type, @merchant_dept_id,
@hard_id, @wallet_id,
'1', '1', '9', '4',
0, 0, 0, '0', CONCAT('智能柜手动结算测试订单-', n),
'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- ---------- 四、生成结果检查 ----------
SELECT o.id AS 订单id,
o.product_desc AS 商品描述,
o.order_status AS 订单状态,
o.pay_type AS 支付方式,
o.sys_type AS 设备类型,
f.id AS 流程id,
f.flow_status AS 流程状态,
f.cabinet_no AS 货柜号,
a.id AS 授权id,
a.is_done AS 授权完成
FROM hard_order o
JOIN hard_door_flow f ON f.order_id = o.id
JOIN hard_auth a ON a.id = f.auth_id
WHERE o.create_by = 'test-manual-settle'
ORDER BY o.id;
-- ================================================================
-- 测试入口与验证步骤
-- ================================================================
-- 1. 用对该订单商户有数据范围的管理员账号登录后台
-- 2. 入口一:订单管理 → 未识别订单(hard-recog,本批次 10 笔会出现在列表)→ 订单详情
-- 入口二:订单管理 → 订单列表 → 订单详情(售后订单详情页 order-refund)
-- 3. 详情页右侧导航应出现「手动结算」:
-- - 左侧为所选货柜按层商品(来自 hard_spu_join 配置),可调数量
-- - 右侧为已选购物车 + 自动播放的公网示例视频(主摄/辅摄可切换)
-- 4. 点「手动结算」→ 确认 → 期望:
-- - 订单 order_status 变为 5(已支付)、pay_time/pay_price/total_price 写入
-- - 生成 hard_parent_car / hard_car 商品明细,详情页"购物车"区出现商品
-- - 智能柜库存扣减(hard_spu.repertory_count / hard_spu_join.repertory_count 减少)
-- - hard_door_flow.flow_status 变 70、pay_time 写入、sku_callback_vo 记录手动结算留痕
-- - hard_auth.is_done 变 1
-- - 钱包库生成一笔 PAYMENT 扣款流水(允许透支)
-- 5. 点「用户未拿商品,取消订单」→ 确认 → 期望:order_status 变 30(已取消)、
-- flow 的 sku_callback_vo 记录手动取消留痕
--
-- ---------- 测试完成后的清理 ----------
-- 重新执行本脚本第一、二节即可重置 10 笔新订单;
-- 已结算订单产生的购物车明细按需清理:
-- DELETE pc, c FROM hard_parent_car pc
-- LEFT JOIN hard_car c ON c.parent_car_id = pc.id
-- WHERE pc.id IN (SELECT parent_car_id FROM hard_order
-- WHERE create_by = 'test-manual-settle' AND parent_car_id IS NOT NULL);
-- 钱包流水在钱包库 wallet_transaction(biz_type='PAYMENT', biz_id=订单id),按需处理
@@ -0,0 +1,42 @@
-- =====================================================================
-- 打印任务补偿扫描:由 hard 模块内 @Scheduled 改为 quartz 调度中心统一触发
-- 接口:GET /hard-invoice/task/process-due(HardInvoiceController#processDueTasks)
--
-- 上线步骤:
-- 1. 按部署环境修改 execute_path(默认为本机直连示例):
-- - 直连硬件服务(推荐,调度中心与服务同内网):
-- http://<hard服务地址>:6080/hard-invoice/task/process-due
-- - 走网关:
-- http://<网关地址>:9999/hard/hard-invoice/task/process-due
-- 2. 执行本 INSERT;
-- 3. 在管理端「工具 / 任务管理」页面找到该任务点击「启动」
-- (job_status=1 已发布不会自动运行,启动后变为 2 运行中)。
--
-- 注意:
-- * pjcloud-daemon-quartz 当前 quartz isClustered=false,调度中心必须单实例部署;
-- 调度中心多实例会导致同一触发点被多次执行(接口内分布式锁仅兜底重叠执行,
-- 不能区分多次正常触发)。
-- * 任务必须挂在租户 1 下管理;接口内部已跳过租户过滤,扫描全部租户的任务。
-- * 该 SQL 不含 ON DUPLICATE KEY,重复执行会主键冲突;若已在页面调整过任务,
-- 请勿重复执行,以免覆盖手工配置。
-- =====================================================================
INSERT INTO `pjcloudx_job`.`sys_job` (
`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`,
`cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
`job_execute_status`, `create_by`, `tenant_id`, `remark`
) VALUES (
1949000000000000001,
'hard-print-task-process-due',
'DEFAULT',
'1',
'3',
'http://127.0.0.1:6080/hard-invoice/task/process-due',
'0/10 * * * * ?',
'3',
'2',
'1',
'0',
'admin',
1,
'智能柜打印任务补偿扫描(原 HardPrintTaskJob,每10秒扫描待生成/待补偿下发的打印任务)'
);
+21
View File
@@ -0,0 +1,21 @@
-- wallet_code_use 补二级索引(饭点卡顿波次 2026-09-14 诊断配套,PolarDB 分布式版 MySQL8 兼容模式)
-- 背景:该表此前仅有主键,慢日志实测 UPDATE 全表扫描并锁定全部扫描行:
-- UPDATE wallet_code_use SET del_flag = '1'
-- WHERE del_flag = '0' AND user_id = ? AND biz_type = '5' AND biz_id = ?
-- 扫描 14,653 行、单次耗时最长达 11 秒(波次期间),锁面放大拥堵。
-- 索引设计说明:biz_type 为 tinytext 无法进入普通二级索引;user_id + biz_id 组合
-- 已可把扫描范围缩到该用户名下几行,biz_type 在索引外过滤,无需进索引。
-- idx_wcu_biz 单列索引服务 WalletCodeUseServiceImpl.getInfoByShortCode 按 biz_id 查询。
-- 执行前请确认同名索引不存在:SHOW INDEX FROM pjcloudx_wallet.wallet_code_use;
-- 表仅约 1.5 万行,Online DDL 秒级完成,无锁风险;本文件只包含索引 DDL。
ALTER TABLE pjcloudx_wallet.wallet_code_use
ADD INDEX idx_wcu_user_biz (user_id, biz_id),
ADD INDEX idx_wcu_biz (biz_id);
-- 说明:
-- 1) 全量建库脚本 db/800pjcloudx_wallet.sql 已同步补上这两个索引,新建环境无需再执行本文件。
-- 2) 两条索引合并在一条 ALTER 中执行,只做一次表重建;如需拆开单独执行(参考
-- hard_order verification_time 加列时的生产执行惯例),可改为两条独立的 CREATE INDEX 语句:
-- CREATE INDEX idx_wcu_user_biz ON pjcloudx_wallet.wallet_code_use (user_id, biz_id);
-- CREATE INDEX idx_wcu_biz ON pjcloudx_wallet.wallet_code_use (biz_id);
@@ -0,0 +1,4 @@
-- 商户表新增优惠折扣字段:取值 1-99,写 10 表示 1 折(值越小折扣越大),为空表示无折扣。
-- app 端商户列表(/wallet/mch-manage/app/page-join)按"有折扣优先、折扣大(值小)优先"排序。
ALTER TABLE mch_manage
ADD COLUMN discounts_rate int DEFAULT NULL COMMENT '优惠折扣(1-99,10=1折)' AFTER discounts_price;
@@ -0,0 +1,32 @@
-- 回填 mch_manage.discounts_rate:
-- 从 discounts_price 文案(如"享9.5折"、"享菜品7.8折(不含酒水)"、"全场95折")中提取"折"前面的数字,
-- 折算规则与 discounts_rate 语义(1-99,10=1折)一致:
-- 小数或个位整数("9.5折"->95、"9折"->90、"0.5折"->5) 乘以10;
-- 两位整数("95折")本身即百分比,保持原值 -> 95。
-- 仅填充 discounts_rate 为空的行,已配置的值不覆盖;提取不到"数字+折"或折算结果不在 1-99 的行跳过。
-- ① 预览:执行前先跑这条,人工确认折算结果
SELECT id, name, discounts_price,
ROUND(CAST(REPLACE(REGEXP_SUBSTR(discounts_price, '[0-9]+(\\.[0-9]+)?折'), '折', '') AS DECIMAL(10, 2))
* IF(CAST(REPLACE(REGEXP_SUBSTR(discounts_price, '[0-9]+(\\.[0-9]+)?折'), '折', '') AS DECIMAL(10, 2)) < 10, 10, 1)) AS will_rate
FROM mch_manage
WHERE discounts_rate IS NULL
AND discounts_price REGEXP '[0-9]+(\\.[0-9]+)?折';
-- ② 执行回填
UPDATE mch_manage m
JOIN (SELECT id,
ROUND(CAST(REPLACE(REGEXP_SUBSTR(discounts_price, '[0-9]+(\\.[0-9]+)?折'), '折', '') AS DECIMAL(10, 2))
* IF(CAST(REPLACE(REGEXP_SUBSTR(discounts_price, '[0-9]+(\\.[0-9]+)?折'), '折', '') AS DECIMAL(10, 2)) < 10,
10, 1)) AS rate
FROM mch_manage
WHERE discounts_rate IS NULL
AND discounts_price REGEXP '[0-9]+(\\.[0-9]+)?折') t ON t.id = m.id
SET m.discounts_rate = t.rate
WHERE t.rate BETWEEN 1 AND 99;
-- ③ 核对:回填结果与原文对照
SELECT id, name, discounts_price, discounts_rate
FROM mch_manage
WHERE discounts_rate IS NOT NULL
ORDER BY update_time DESC;
@@ -0,0 +1,47 @@
-- 钱包流水分页接口慢查询索引优化(生产 PolarDB,只加索引不改代码)
-- 影响接口:/wallet-transaction/page-join 及 page-cz / page-jz / page-xf / page-tk / page-th / app/page
-- (后端全部走 WalletTransactionMapper.scopePageList + count,SQL 形态一致)
--
-- 慢因:wallet_transaction 只有 PRIMARY(id)、idx_dept_id(wallet_id)、
-- idx_wallet_transaction_user_time(user_id,create_time)、idx_version(wallet_id,version),
-- 管理端列表 SQL(WHERE tenant_id=? AND del_flag='0' ORDER BY create_time DESC,user_id DESC LIMIT 10
-- + 同条件 COUNT)无任何可用索引 → 全表扫描 + filesort,大表上每次翻页执行两遍。
--
-- 注意:slowlog_optimization_20260826/01_add_indexes.sql 里提过同样的两条索引,
-- 但测试服与本地库均未建过,生产大概率也没有;执行前先按下面步骤核对。
--
-- ============ 执行前核对 ============
-- SHOW INDEX FROM pjcloudx_wallet.wallet_transaction;
-- 若下述索引名已存在且列一致,跳过对应条目;列不一致先停下人工确认,不要盲目重建。
--
-- ============ 执行方式 ============
-- InnoDB 二级索引创建默认 ONLINE(不锁 DML),但大表建索引有 IO 消耗,
-- 生产 IOPS 上限 8000,请低峰期逐条执行、每条间隔观察,不要并发跑。
-- P0-1:默认列表 / 时间范围 / COUNT。
-- 覆盖 WHERE tenant_id=? AND del_flag='0' 的等值前缀,且 (create_time,user_id) 与
-- ORDER BY create_time DESC, user_id DESC 完全同序,可 Backward index scan,LIMIT 10 只读 10 行;
-- COUNT 走该索引即为覆盖索引扫描(Using index)。
CREATE INDEX idx_wallet_tx_tenant_del_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, create_time, user_id);
-- P0-2:按业务类型过滤的页面(充值/记账/消费/退款/退还)。
-- biz_type 为 tinytext,必须前缀索引;稀有类型(如退款)优化器会用本索引 ref 定位后再倒序扫。
CREATE INDEX idx_wallet_tx_tenant_del_biz_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, biz_type(8), create_time, user_id);
-- ============ 执行后验证(本地已 EXPLAIN 验证,生产建完再跑一遍确认) ============
-- 期望:key = idx_wallet_tx_tenant_del_ct_uid,Extra 含 Backward index scan 且无 Using filesort
-- EXPLAIN SELECT a.* FROM pjcloudx_wallet.wallet_transaction a
-- LEFT JOIN pjcloudx_wallet.wallet_manage m ON m.id = a.wallet_id AND m.del_flag='0'
-- WHERE a.del_flag='0' AND a.tenant_id=1
-- ORDER BY a.create_time DESC, a.user_id DESC LIMIT 10;
--
-- 期望:key = idx_wallet_tx_tenant_del_ct_uid,Extra = Using index
-- EXPLAIN SELECT COUNT(*) FROM pjcloudx_wallet.wallet_transaction a
-- WHERE a.del_flag='0' AND a.tenant_id=1;
--
-- 期望:key = idx_wallet_tx_tenant_del_biz_ct_uid,Extra 含 Backward index scan
-- EXPLAIN SELECT a.* FROM pjcloudx_wallet.wallet_transaction a
-- WHERE a.del_flag='0' AND a.tenant_id=1 AND a.biz_type IN ('9')
-- ORDER BY a.create_time DESC, a.user_id DESC LIMIT 10;
@@ -0,0 +1,25 @@
-- 订单实时统计配套索引;仅在确认执行计划后安排低峰期上线,不由应用自动执行。
-- 已有创建时间索引 idx_hard_order_tenant_del_ct_uid 可直接支持创建时间范围过滤。
-- 支付时间索引只在没有同名前导列的等效索引时创建,不重复建立宽覆盖索引。
SET @statistics_pay_index_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
GROUP BY INDEX_NAME
) existing_indexes
WHERE columns_in_order = 'tenant_id,del_flag,pay_time'
OR columns_in_order LIKE 'tenant_id,del_flag,pay_time,%'
);
SET @statistics_pay_index_ddl = IF(@statistics_pay_index_exists = 0,
'CREATE INDEX idx_hard_order_tenant_del_pay_time ON pjcloudx_hardware.hard_order (tenant_id, del_flag, pay_time)',
'SELECT ''支付时间范围索引已存在,跳过'' AS message');
PREPARE statistics_pay_index_statement FROM @statistics_pay_index_ddl;
EXECUTE statistics_pay_index_statement;
DEALLOCATE PREPARE statistics_pay_index_statement;
-- 上线验收:SHOW INDEX FROM pjcloudx_hardware.hard_order;
-- 对接口实际生成的 SQL(含租户、数据权限、日期和商户/部门/钱包筛选)执行 EXPLAIN。
-- 确认订单表使用日期范围扫描,且未出现用户/购物车展示关联、全表售后聚合或分组 COUNT。
-- 记录近三个月的大租户耗时、扫描行数和并发请求下业务查询延迟;单条统计 SQL 超时为15秒。
+27
View File
@@ -0,0 +1,27 @@
-- 第三方支付日志分页索引;在 pjcloudx_thirdparty 库执行。
-- 默认列表按租户、逻辑删除标识和创建时间查询;组合筛选增加发起方、接口类型。
-- req/res/header 等内容的 LIKE '%关键字%' 不使用 B-tree 定位,请配合请求时间缩小范围。
SET @tpl_index_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'third_pay_log'
AND index_name = 'idx_tpl_tenant_del_ct_id') = 0,
'ALTER TABLE third_pay_log ADD INDEX idx_tpl_tenant_del_ct_id (tenant_id, del_flag, create_time, id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE tpl_index_stmt FROM @tpl_index_ddl;
EXECUTE tpl_index_stmt;
DEALLOCATE PREPARE tpl_index_stmt;
SET @tpl_index_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'third_pay_log'
AND index_name = 'idx_tpl_tenant_del_source_biz_ct_id') = 0,
'ALTER TABLE third_pay_log ADD INDEX idx_tpl_tenant_del_source_biz_ct_id (tenant_id, del_flag, pay_source_type, biz_type, create_time, id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE tpl_index_stmt FROM @tpl_index_ddl;
EXECUTE tpl_index_stmt;
DEALLOCATE PREPARE tpl_index_stmt;
SET @tpl_index_ddl = NULL;
@@ -0,0 +1,57 @@
-- /hard/hard-total/page-list-cs 配套索引,线上目标为 PolarDB MySQL 8;已在 MySQL 8.0.46 验证。
-- 应与本次 SQL 改造一同发布。大表低峰期执行,先核对剩余磁盘和实际版本的在线 DDL 支持。
-- 不删除现有索引;只跳过前导列及覆盖列均等效的可见完整 BTREE 索引。
-- 窄索引 (tenant_id,del_flag,pay_time) 不能替代本次支付覆盖索引。
-- 同一个支付索引覆盖页面的钱包、用户部门和用餐时段筛选,避免这些场景重新逐行读取订单主表。
-- PolarDB DDL 行为参考:https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql
SET @hard_cs_pay_cover_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order,
SUM(COLUMN_NAME IN ('tenant_id','del_flag','pay_time','order_status','merchant_type','merchant_id','merchant_dept_id','pay_price','wallet_id','user_dept_id','hour_type')) AS covered_columns,
SUM(SUB_PART IS NOT NULL) AS prefix_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES'
GROUP BY INDEX_NAME
) existing_indexes
WHERE columns_in_order LIKE 'tenant_id,del_flag,pay_time,%'
AND covered_columns = 11 AND prefix_columns = 0
);
SET @hard_cs_pay_cover_ddl = IF(@hard_cs_pay_cover_exists = 0,
'CREATE INDEX idx_hard_order_cs_pay_cover ON pjcloudx_hardware.hard_order (tenant_id,del_flag,pay_time,order_status,merchant_type,merchant_id,merchant_dept_id,pay_price,wallet_id,user_dept_id,hour_type) ALGORITHM=INPLACE LOCK=NONE',
'SELECT ''商户支付覆盖索引已存在,跳过'' AS message');
PREPARE hard_cs_pay_cover_statement FROM @hard_cs_pay_cover_ddl;
EXECUTE hard_cs_pay_cover_statement;
DEALLOCATE PREPARE hard_cs_pay_cover_statement;
SET @hard_cs_refund_audit_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order,
SUM(COLUMN_NAME IN ('tenant_id','del_flag','status','audit_time','order_id','refund_price')) AS covered_columns,
SUM(SUB_PART IS NOT NULL) AS prefix_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_after_sale'
AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES'
GROUP BY INDEX_NAME
) existing_indexes
WHERE columns_in_order LIKE 'tenant_id,del_flag,status,audit_time,%'
AND covered_columns = 6 AND prefix_columns = 0
);
SET @hard_cs_refund_audit_ddl = IF(@hard_cs_refund_audit_exists = 0,
'CREATE INDEX idx_hard_after_sale_cs_audit ON pjcloudx_hardware.hard_after_sale (tenant_id,del_flag,status,audit_time,order_id,refund_price) ALGORITHM=INPLACE LOCK=NONE',
'SELECT ''商户退款时间覆盖索引已存在,跳过'' AS message');
PREPARE hard_cs_refund_audit_statement FROM @hard_cs_refund_audit_ddl;
EXECUTE hard_cs_refund_audit_statement;
DEALLOCATE PREPARE hard_cs_refund_audit_statement;
-- 发布后对真实租户、日期和筛选参数生成的 SQL 执行 EXPLAIN ANALYZE(执行真实查询)。
-- 默认时间汇总:支付走 idx_hard_order_cs_pay_cover 范围覆盖扫描;退款先走
-- idx_hard_after_sale_cs_audit 的 audit_time 范围,再按主键读取关联订单。
-- 定向商户、钱包等筛选允许选择其他更合适的索引,不要求强制使用上述索引。
-- SQL 中 NO_GROUP_INDEX / JOIN_PREFIX 提示已在 MySQL 8.0.46 验证;PolarDB 上用实际小版本、租户参数重新验计划。
-- 若先建索引再灰度代码,过渡期旧 SQL 可能改变执行计划,应检查旧服务延迟,尽快完成同批灰度。
@@ -0,0 +1,111 @@
# 商户交易汇总优化与验证记录
2026-10-22,接口 `POST /hard/hard-total/page-list-cs`。线上数据库由用户确认为 PolarDB MySQL 8 兼容版;本次直接连接的测试数据库为 MySQL Community 8.0.46。本次没有连接生产数据库,也没有重启正在运行的服务。
## 查询链路与瓶颈
前端商户统计页面默认查询近三个月,返回整个商户汇总列表。接口名称虽然带 `page`,实际响应为 `List<HardOrderCsVo>`,没有分页 COUNT SQL。
请求依次经过子商户展开、用户可见商户类型处理、支付汇总、退款汇总、Java 合并、商户及部门信息补全、平台抽成,最后由前端计算表尾合计。商户信息已按 200 个 ID 分批查询;钱包服务补全商户时还会查询部门,因此接口总耗时不能直接等同于两条 SQL 的耗时。
原查询存在以下开销:
- 未筛选钱包类型时,支付和退款仍逐条关联 `wallet_manage`。
- 售后表只有 `(order_id,del_flag,id)` 索引,无法直接按退款统计时间定位。
- 已有支付时间窄索引需要读取订单主表;钱包、用户部门和用餐时段筛选也需要额外字段。
- 实测发现优化器可能为商户分组选择商户索引,扫描全部历史订单;增加售后索引后,也可能改为从全部历史订单逐条查售后。仅加索引或仅删关联并不稳定。
- Java 合并退款及匹配抽成商户反复遍历列表;前端挂载和切换子表有多个查询触发点。
## 最终实现
`HardOrderMapper.xml` 仅在筛选钱包类型时关联钱包表,移除 VO 不使用的 `max(user_dept_id)`,支付聚合使用 `NO_GROUP_INDEX(a)` 避免为分组顺序扫描整个历史商户索引。退款有日期范围且未指定订单、商户、用户或设备时使用 `JOIN_PREFIX(r)`,先扫描售后日期范围;定向筛选保留优化器自行选择驱动表的能力。
配套脚本 `db/20261022_hard_cs_statistics_indexes.sql` 建立两个索引,并识别同名或异名的等效索引,避免重复创建:
```sql
hard_order (
tenant_id, del_flag, pay_time, order_status, merchant_type,
merchant_id, merchant_dept_id, pay_price, wallet_id, user_dept_id, hour_type
)
hard_after_sale (
tenant_id, del_flag, status, audit_time, order_id, refund_price
)
```
支付索引同时覆盖页面常用筛选,退款索引覆盖售后时间、金额及订单关联字段。新增索引会增加磁盘占用和写入维护成本,脚本保留所有既有索引,未自动删除窄支付时间索引。
`HardOrderTotalServiceImpl` 使用保留插入顺序的 Map 合并商户,抽成匹配也改为 Map,合并开销为 O(支付商户数 + 退款商户数)。保留每 200 个 ID 的远程查询边界。Controller 和 Service 对超过一秒的请求记录子商户解析、权限、支付查询、退款查询、合并、商户 RPC、部门 RPC 的分段耗时。
前端统一在异步子表挂载后查询,移除重复的 mounted/tab-click 触发,日/月切换每次只请求一次,并保留手动刷新和部门筛选。
同时修复两个边界问题:整个区间只有退款时原实现会漏掉全部商户;指定设备类型时原支付 SQL 引用了未关联的 `m` 别名。现在退款商户能显示负净额,设备类型筛选同时作用于支付和退款。
## 统计口径与正确性
支付仍使用状态 `5、20、10` 和订单 `pay_time`;退款仍使用完成状态 `50` 和原 SQL 的售后 `audit_time`,独立于订单支付时间。一笔订单多次退款按退款记录分别计次。支付与退款分别汇总后按商户相减,金额使用 BigDecimal,抽成计算及取舍位置保持原实现。
金额单位、时间范围的包含边界、商户类型排序规则、商户子级展开、用户部门和商户部门的包含/排除、缺失或已删除钱包的历史行为均有回归覆盖。继续使用现有租户及 DataScope 插件;没有将原先分组后的部门权限改为不同的行级语义。
保留优化前两条 SQL 作为测试对照,36 组筛选逐项比较支付及退款结果。MySQL 测试库现有业务数据另做 9 组对比。HTTP 回归实际执行 Controller、Service、Mapper 和权限插件,外部服务使用替身;没有将该测试当作真实网关或远程服务的性能测量。
还比较了“退款先按订单归集再关联”的候选。在一单一退款分布、已建立退款索引时,交替测量中直接关联约 175ms、预聚合约 274ms;最终没有将该物化方案用于业务查询。测试资源保留候选,供以后重新评估其他数据分布。
## 百万级 MySQL 测试
连接内临时表复制真实业务表的 `SHOW CREATE TABLE`,包括原有索引,再生成 100 万订单、100 万售后及合成钱包数据。数据覆盖两年、两个租户、997 个商户、不同状态及软删除标记,包含日期与支付状态过滤。最终验收使用一笔订单一条售后,避免多次退款的缓存复用使测试过于乐观;多次部分退款另有功能回归及前轮百万级数据验证。
最终 SQL 和索引下,每个场景先与旧 SQL 比较完整商户金额/次数,再测量三次取中位数。下表时间只包含 Mapper 查询及 SQL,不包含真实权限服务、商户/部门 RPC、网关和浏览器网络。
| 场景 | 支付中位数 | 退款中位数 | 两条查询合计 |
| --- | ---: | ---: | ---: |
| 三个月,全部商户 | 144.52ms | 354.35ms | 498.87ms |
| 两天范围 | 5.27ms | 10.78ms | 16.05ms |
| 指定一个商户 | 4.80ms | 7.12ms | 11.92ms |
| 商户部门筛选 | 39.63ms | 593.03ms | 632.66ms |
| 用户部门筛选 | 39.51ms | 279.40ms | 318.91ms |
| 用餐时段筛选 | 58.90ms | 374.20ms | 433.10ms |
| 钱包类型筛选 | 143.37ms | 745.15ms | 888.52ms |
同一合成数据生成规则的原索引、旧 SQL 阶段,三个月支付约 1461.51ms、退款约 566.49ms,合计约 2028ms。不同轮次受本机并发负载、缓存和临时表统计信息影响,以上为离线阶段对比,不能推算线上接口必然从 5s 降至 0.5s。
最终实际执行计划:
```text
支付:Covering index range scan on a using idx_hard_order_cs_pay_cover
日期范围扫描 111182 条索引记录,筛选后 106348 笔支付,997 个商户
退款:Covering index range scan on r using idx_hard_after_sale_cs_audit
日期范围 101079 条售后,再按 hard_order PRIMARY 查对应订单
钱包筛选:订单仍使用支付覆盖索引,之后按 wallet_manage PRIMARY 检查钱包类型
```
后端验收共 44 项:43 项执行通过,0 失败、0 错误;1 项与本次无关的旧 H2 性能用例默认跳过。本次 MySQL 百万级性能用例已实际执行。前端 19 项测试通过,其中新增 3 项执行页面真实 setup、异步子组件挂载及切换生命周期;页面 SFC 脚本和模板编译通过。
索引迁移在独立测试 schema 中连续执行,并重命名为异名等效索引再次执行,均未重复建索引。百万级临时表随连接关闭清理,独立 schema 已删除。验收后业务表仍为 833 条订单、158 条售后,业务表没有新增本次候选索引。
## PolarDB 发布与验收
PolarDB 官方 [Statement Outline 文档](https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/statement-outline)明确提供 `JOIN_PREFIX` 并说明支持 MySQL 8.0 的 Hint 类型;[DDL 指南](https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql)列明 8.0.1/8.0.2 创建二级索引支持并发 DML。脚本使用 `ALGORITHM=INPLACE LOCK=NONE`,不支持时会失败,不会自动降级为锁写入的 COPY。实际建索引开始/结束仍有元数据锁,并消耗 I/O 和 CPU,应安排低峰执行。
建议先灰度后端查询改造,再在同一发布窗口建立两个配套索引,随后发布前端。不要只给旧服务加索引:本次实测旧 SQL 曾因新增索引改变驱动表,明显变慢。不要自动回滚或删除其他既有索引。
在实际 PolarDB 节点上记录内核小版本及路由节点,用大租户近三个月、商户、钱包和部门条件执行 EXPLAIN,紧接 SHOW WARNINGS 检查提示是否被忽略;确认默认支付为日期覆盖范围扫描、默认退款先走售后时间索引。EXPLAIN ANALYZE 会实际执行查询,应选择验收时段。若该内核支持 EXPLAIN DDL,也可先预览建索引的算法和并发 DML 行为。
灰度时比较同一筛选的商户数、支付/退款金额及次数、负净额、表尾合计;结合新增分段日志记录端到端 P50/P95、数据库扫描行数和并发负载。线上执行计划、端到端性能和真实 RPC 的验证仍需在发布环境完成;本次没有使用生产连接或执行生产数据库操作。
复现后端测试前设置提供的 `MYSQL_HOST、MYSQL_PORT、MYSQL_USER、MYSQL_PWD` 环境变量,不将密码写入文件。在仓库根目录运行:
```sh
HARD_CS_MYSQL_TEST=true HARD_CS_MYSQL_BENCHMARK=true mvn \
-pl pjcloud-hardware/pjcloud-hardware-biz -am \
-Dtest=HardOrderCsSqlTest,HardOrderCsServiceTest,HardOrderCsMySqlTest,HardBatchExportQueryTest,HardBatchExportFileTest,HardOrderStatisticsSqlTest,HardOrderStatisticsServiceTest,HardOrderStatisticsScopeTest,HardOrderStatisticsControllerTest \
-Dhard.cs.benchmark.finalOnly=true \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false test
```
不设置 `hard.cs.benchmark.finalOnly` 可重跑原索引、只加退款索引、完整索引三个阶段,并交替比较预聚合候选,耗时更长。管理端仓库执行下列命令可复现本次 19 项前端回归:
```sh
node --test tests/batch-export.test.cjs tests/hard-sh-query-lifecycle.test.cjs \
tests/hard-third-order.test.cjs tests/wallet-ledger-exports.test.cjs \
tests/wallet-manage-export.test.cjs
```
@@ -0,0 +1,88 @@
-- /hard/hard-total/page-list-dept 与部门导出配套索引;目标:PolarDB MySQL 8。
-- 本脚本只读取索引元数据并按需创建索引,不写业务数据,不删除或替换现有索引。
-- 索引名称不是等效判断依据:已有其他名称的可见 BTREE 索引满足条件时同样跳过。
-- 若目标名称已被不同定义占用且不存在等效索引,CREATE INDEX 会报错并停止该条;
-- 应先核对 SHOW INDEX,不能把不同定义的同名索引当作本次所需索引。
-- 大表低峰逐条执行。INPLACE/LOCK=NONE 仍需短暂元数据锁,应先排查长事务,
-- 核对实际 PolarDB 小版本的在线 DDL 支持、可用空间和复制延迟,不并行建大表索引。
-- PolarDB DDL:https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql
--
-- 代码发布依赖:先发布 Wallet 的 /wallet-transaction/department-statistics 新内部接口,
-- 再发布 Hardware。Hardware 新支付聚合直接读取 pjcloudx_wallet.wallet_transaction,
-- 两库须位于同一实例,Hardware 应用数据库账号须具备该表 SELECT 权限。
-- 测试使用的 root 账号权限不能作为生产应用账号权限的验证依据。
-- 一、订单按创建时间做范围覆盖扫描。
-- 等效索引须以前三列 tenant_id,del_flag,create_time 按此顺序开头,包含以下全部七列,
-- 所有显式索引列均为完整列(无前缀截断),且索引可见、类型为 BTREE。
-- 后续覆盖列允许不同顺序或额外列;只有前三列的窄索引不能替代本次覆盖索引。
SET @hard_dept_order_cover_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME,
SUM(SEQ_IN_INDEX = 1 AND COLUMN_NAME = 'tenant_id') AS tenant_first,
SUM(SEQ_IN_INDEX = 2 AND COLUMN_NAME = 'del_flag') AS deletion_second,
SUM(SEQ_IN_INDEX = 3 AND COLUMN_NAME = 'create_time') AS created_third,
COUNT(DISTINCT CASE WHEN COLUMN_NAME IN
('tenant_id','del_flag','create_time','order_status','wallet_id','user_dept_id','pay_price')
THEN COLUMN_NAME END) AS covered_columns,
SUM(SUB_PART IS NOT NULL) AS prefix_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES'
GROUP BY INDEX_NAME
) existing_indexes
WHERE tenant_first = 1 AND deletion_second = 1 AND created_third = 1
AND covered_columns = 7 AND prefix_columns = 0
);
SET @hard_dept_order_cover_ddl = IF(@hard_dept_order_cover_exists = 0,
'CREATE INDEX idx_hard_order_dept_ct_cover ON pjcloudx_hardware.hard_order (tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price) ALGORITHM=INPLACE LOCK=NONE',
'SELECT ''部门订单创建时间覆盖索引已存在,跳过'' AS message');
PREPARE hard_dept_order_cover_statement FROM @hard_dept_order_cover_ddl;
EXECUTE hard_dept_order_cover_statement;
DEALLOCATE PREPARE hard_dept_order_cover_statement;
-- 二、按租户和业务编号定位少量支付流水,再校验钱包、业务类型、删除状态和部门,取最新有效记录。
-- 复用 20261022_wallet_rule_transaction_page_index.sql 中的 idx_wt_tenant_bizid_type,
-- 不为部门报表再创建另一条更宽的同类索引。
-- 任何以完整 tenant_id,biz_id 两列开头的可见 BTREE 索引均视为可复用;后续列可有前缀。
-- biz_type 是 TINYTEXT,新增候选必须写 biz_type(8),该候选不是完整覆盖索引。
-- biz_id 高度选择性,用索引定位后执行精确字节匹配及最新有效快照筛选。
SET @hard_dept_wallet_biz_lookup_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME,
SUM(SEQ_IN_INDEX = 1 AND COLUMN_NAME = 'tenant_id' AND SUB_PART IS NULL) AS tenant_first,
SUM(SEQ_IN_INDEX = 2 AND COLUMN_NAME = 'biz_id' AND SUB_PART IS NULL) AS complete_biz_second
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction'
AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES'
GROUP BY INDEX_NAME
) existing_indexes
WHERE tenant_first = 1 AND complete_biz_second = 1
);
SET @hard_dept_wallet_biz_lookup_ddl = IF(@hard_dept_wallet_biz_lookup_exists = 0,
'CREATE INDEX idx_wt_tenant_bizid_type ON pjcloudx_wallet.wallet_transaction (tenant_id,biz_id,biz_type(8)) ALGORITHM=INPLACE LOCK=NONE',
'SELECT ''钱包租户业务编号索引已存在,复用并跳过'' AS message');
PREPARE hard_dept_wallet_biz_lookup_statement FROM @hard_dept_wallet_biz_lookup_ddl;
EXECUTE hard_dept_wallet_biz_lookup_statement;
DEALLOCATE PREPARE hard_dept_wallet_biz_lookup_statement;
-- 三、钱包六类流水合并统计先复用既有 idx_wallet_tx_tenant_del_biz_ct_uid
-- (tenant_id,del_flag,biz_type(8),create_time,user_id),本脚本不额外创建宽聚合索引。
-- 该索引来自 20261004_wallet_transaction_page_index.sql / 20260826 索引脚本,
-- 不是包含 dept_id、price 的覆盖索引。日期范围汇总使用 INDEX 优化器提示建议采用该索引,
-- 防止业务号索引仅按 tenant_id 扫描整个租户。请核实同名索引存在且定义正确;
-- 提示要求引擎支持 MySQL 8.0.20+ 的 INDEX hint,未知提示或缺失索引时会忽略并回退,
-- 因此发布须以实际 PolarDB 的 EXPLAIN 和 SHOW WARNINGS 验收,不能仅凭 SQL 成功。
-- 定向钱包或没有合法日期范围时不加此提示。
-- 对大批导入/临时造数后索引统计失真,应刷新统计后再核对计划;
-- 本脚本不自动对业务大表执行 ANALYZE,生产按维护窗口和实际统计情况处理。
--
-- 发布验收使用真实租户、日期、部门包含/排除及钱包类型参数执行 EXPLAIN ANALYZE:
-- 1. 订单时间范围扫描应能使用创建时间覆盖索引,避免逐条回表读取订单字段。
-- 2. 历史流水应通过 tenant_id,biz_id 等值定位,不能逐单扫描全部钱包流水;
-- 源 SQL 保留普通 biz_id 字符串等值,额外 BINARY 等值只作精确匹配校验。
-- 3. 检查 NO_MERGE 派生表及相关子查询实际 loops,确认历史部门不会被重复求值。
-- 4. 钱包合并统计只执行一次;定向钱包或其他高选择性条件允许选择更合适的既有索引。
-- 5. 核对页面与部门导出全部金额、次数、删除部门及零交易部门结果后再扩大灰度。
@@ -0,0 +1,129 @@
# 部门交易统计优化验证记录(2026-10-22)
> **历史记录:下文验证的是旧订单统计口径的性能优化,已由本次钱包账本统计替代。** 下文的 106 项回归证明与旧口径保持一致,不能作为商户、部门报表已经统一到 `wallet_transaction` 的证据。当前支付与退款均按流水发生时间、历史部门、原始金额和流水条数汇总;生产差额须使用 [只读对账脚本](20261022_wallet_ledger_reconciliation.sql) 按实际租户和权限另行核实。本次未执行生产对账。
## 范围与数据库
目标接口:`POST /hard/hard-total/page-list-dept`,同时优化共用统计逻辑的同步/异步部门导出。用户反馈线上超过 30 秒未返回。线上为 PolarDB MySQL 8 兼容版;本次实际连接的是用户提供的本机 MySQL 8.0.46 测试库,未连接或修改生产数据库。
本次仅新增部门统计链路;保留此前 `page-list-cs` 的改动和工作区其他并行任务的改动。部门前端 `src/views/hard/hard-dept-total/index.vue` 初始化没有商户统计页面的重复请求问题,因此本次不修改部门前端。
## 根因与实现
旧链路先查询 UPMS 部门,再依次执行六次 Wallet 汇总 RPC;随后把全部候选订单明细读入 JVM,按每组最多 10000 订单、每 RPC 最多 10 组请求历史流水,Wallet 每组单独查询和排序,最后在 Java 按历史部门累加。订单量和历史流水量同时影响数据库扫描、结果映射、RPC 传输及 JVM 内存。原流水索引不能直接按租户、业务编号定位,订单缺少以租户/删除标记/创建时间开头的覆盖索引。
新链路:一次 UPMS 部门查询 + 一次 Wallet 条件聚合 RPC + 一次 Hardware 支付部门聚合 SQL。只返回部门级金额、次数,不再回传逐笔订单和流水快照。导出共用这条链路,不再每 200 订单重复请求历史快照;Excel 仍按部门结果分批写入。
- Wallet 新增内部 `/wallet-transaction/department-statistics`,用 CASE 同时统计六类收支;原内部接口保留。
- Hardware 新增 `HardDepartmentStatisticsMapper`,逐单用高选择性业务编号定位最新有效支付流水,回退订单部门后再按部门筛选/汇总。
- 原始 `biz_id` 普通等值保证可用索引,额外 BINARY 等值模拟旧 Java Map 的精确字符串匹配;`biz_type` 同样校验精确字节。订单编号用 `concat('', a.id)`,保持完整大整数并让列侧排序规则优先,避免连接与历史列的字符排序规则冲突。
- 显式限制订单租户、历史流水租户和钱包表租户,避免带库名前缀的表被租户插件忽略。入口要求有效租户上下文。对于历史异常的跨租户钱包引用,有钱包类型筛选时新查询会按同租户条件排除;这是对异常关联的隔离收紧。
- 超过 1 秒的成功统计记录部门 RPC、钱包聚合 RPC、订单聚合、合并各段耗时,便于灰度定位。
- 部门/钱包 RPC 失败或 SQL 异常终止统计,不返回假零。空部门结果保留原行为。
## 保留的统计口径
| 项目 | 口径 |
|---|---|
| 订单范围 | `hard_order.create_time`,状态 `5,20,10`;金额 `pay_price`、每单一次,包含零金额 |
| 历史部门 | 同租户、同钱包、精确字符串业务编号、PAYMENT=`4`,有效且部门非空的最大流水 ID;快照不限时间和金额符号;没有快照回退 `user_dept_id` |
| 部门筛选 | 还原历史部门后才做包含/排除,`isInclude=null` 与包含一致;`deptId` 继续只控制展示部门查询 |
| 钱包类型 | 当前钱包类型,标量和列表同时传入时取交集;不新增删除钱包过滤 |
| 批量充值/扣减 | BATCH=`2`,严格按金额正/负拆分 |
| 单次充值/扣减 | UPDATE=`1` 和 RECHARGE=`3` 合并,严格按金额正/负拆分 |
| 退款 | REFUND=`5` 原符号合计,零金额也计次数 |
| 退还食堂 | RETURN_CANTEEN=`8` 先带符号求和,组装时取绝对值;零金额也计次数 |
| 部门列表 | 保留 UPMS 顺序、已删除标记、零交易部门;支付或退款单边存在也保留 |
| 隔离范围 | 保持租户和所选部门条件;未为此接口新增原本不存在的 DataScope 权限规则 |
部门 Excel 同时修正原有问题:8 个次数合计未累计、Long 类型扣减金额转元时截断小数、转换直接修改原 VO;改为独立导出 Map,全部金额/次数完整累计,并修正消费净金额标题。页面响应仍用原字段和分单位。
## 正确性与文件验证
- 新 Hardware SQL 回归 11 项:逐笔旧 SQL + 独立 Java 历史归属为基线,覆盖 21 组可选条件、24 组最终部门组合、时间/状态边界、现金回退、删除钱包、大整数编号、异常字符串和租户隔离。
- 新 Hardware Service/Controller 回归 13 项:16 个金额/次数字段、RPC 次数/失败、筛选快照、空/删除部门、退款单边结果、页面与导出一致性和进度。
- 新 Wallet SQL 回归 8 项:真实聚合 XML + 生产租户插件,对照原六次聚合,覆盖符号/零金额/钱包类型交集。
- 部门真实 Excel 回归:201 部门跨 200 行批边界逐列验证 16 个数值及合计,重复导出不污染统计对象,统计异常不返回部分附件;原订单/商户导出测试保留。
- 真实 MySQL 回归 3 项全部通过:既有数据 23 组筛选(14 个初始部门);源 SQL 精确业务号/业务类型、最新有效快照和大整数边界;索引迁移重复、改名、覆盖列重排、窄索引、业务号前缀截断和不可见索引判断。后两项写入只在 TEMPORARY 表或独立 scratch 库。
- H2 测试只适配其 AS CHAR/AS BINARY 默认长度为 1 的方言差异;真实 MySQL 测试直接执行源 XML,无字符转换影子 SQL。
最终联合回归 **106 项实际执行通过,0 失败/错误**,覆盖 18 个测试类。另 2 项独立性能用例未在联合轮启用:原订单统计的百万性能开关未开启;钱包索引候选测试已在独立轮实际执行通过,不把联合轮跳过算作通过。
联合日志:`/private/tmp/hard-dept-final-verification.log`;Wallet 候选对照日志:`/private/tmp/hard-dept-mysql-wallet-candidates.log`。真实 MySQL 3 项回归和百万部门性能测试均实际启用并执行。索引 scratch 库清理后数量为 0;所有连接临时表在测试结束关闭连接后删除;任务文件凭证检查未发现提供的数据库密码。
## 真实 MySQL 性能验证
压测克隆测试库真实表结构及原索引到连接临时表,生成 100 万订单、100 万钱包流水、1 万钱包,时间分布 730 天、两个租户;包含历史部门变化、重复业务号、尾空格、错钱包、区间外快照、现金支付、删除记录及零金额。默认查询最近三个月,再分别测试部门包含、部门排除和钱包类型筛选。每项交替执行新/旧版本 3 次取中位数;计时旧订单链路总预算 30 秒、新 SQL 单次预算不超过 30 秒。部门包含/排除在旧链路仅改变最终 Java 过滤,最终回归复用默认查询已验证的全部门旧结果作为对照,避免重复搬运相同 10 万订单。旧计时样本超时会明确记录;若没有完整旧结果,仅为正确性另给旧链路 90 秒预算,绝不把该额外执行伪装为性能样本。
**必须代码与索引配套上线。** 原索引下默认三个月旧支付链路 12372.47 ms、新支付 SQL 14921.99 ms,说明仅修改 SQL 不能解决历史流水定位成本;钱包六次汇总则为 2222.59 ms,新一次聚合为 175.51 ms。支付必须添加业务号定位及订单时间覆盖索引后验收,不能仅发布代码。
造数后必须刷新临时表统计。初轮未刷新时,原空表索引误估每个钱包只有 1 条流水,新支付查询误选钱包编号索引并在新增索引阶段超过 30 秒。ANALYZE 仅作用于本次连接临时表;刷新后支付查询按业务编号定位,默认三个月中位数 550.01 ms。生产不自动执行 ANALYZE,应结合实际统计维护窗口验收计划。
还发现业务号索引会干扰 Wallet 汇总:它仅按 tenant_id 扫描 90 万行,默认约 1.2 秒。对照现有业务时间索引与普通时间索引后,前者只读取约 62015 行(后者约 110551 行),采用有日期范围且未指定 walletId 时的 INDEX 优化器提示,保留定向钱包自由选择索引。提示针对 MySQL 8.0.20+;不支持提示或没有同名索引时可回退,正确性不依赖提示,但灰度必须确认实际 PolarDB 计划已采用时间范围扫描。
钱包候选 3 次采样中位数(ms),每项均完整对照原六次查询的 12 个字段:
| 场景 | 无提示 | 业务类型时间索引 | 普通时间索引 |
|---|---:|---:|---:|
| 默认三个月 | 966.70 | 186.04 | 179.19 |
| 部门包含 | 1016.21 | 83.94 | 129.76 |
| 部门排除 | 1161.30 | 130.97 | 164.09 |
| 钱包类型 | 1095.75 | 120.85 | 150.13 |
默认场景约 7ms 的两候选差距不作为决策依据;业务类型时间索引扫描量更少,其余三场景更快,故选择该既有索引。
最终源 SQL + 配套索引 + 刷新临时统计后的验收结果(ms):
| 场景 | 旧支付链路 | 新支付聚合 | 旧钱包六次汇总 | 新钱包一次汇总 | 新核心阶段中位数之和 |
|---|---:|---:|---:|---:|---:|
| 默认三个月 | 19442.08 | 508.69 | 2222.16 | 159.73 | 668.42 |
| 部门包含 | 复用全部门基线 | 477.83 | 2278.88 | 159.31 | 637.14 |
| 部门排除 | 复用全部门基线 | 475.08 | 2243.06 | 177.53 | 652.61 |
| 钱包类型 | 948.86 | 242.78 | 1762.37 | 140.42 | 383.20 |
默认场景旧、新共用相同数据与配套索引,核心阶段中位数之和约 **21.66 秒 → 0.67 秒**;“之和”是两个阶段各自 3 次中位数相加,**不是完整 HTTP 接口中位数**。新增索引也会改变旧大 IN 查询的计划,因此不能假定仅给旧链路加索引就会提速。前面原索引的 12.37 秒支付样本与这里 19.44 秒样本属于不同索引/统计阶段,应分开解释。
所有新支付结果与旧逐单归属一致;钱包全部 12 个统计字段与旧六次 SQL 一致。默认旧流程向 JVM/服务间返回 106348 订单、43922 快照,执行 11 次快照 SQL;新 SQL 返回 57 个支付部门和 25 个钱包部门,服务中保留 UPMS 的零交易部门。钱包类型筛选旧流程返回 34294 订单、14561 快照并执行 4 次快照 SQL。
最终 EXPLAIN ANALYZE 已确认:
- 订单走 `idx_hard_order_dept_ct_cover` 创建时间覆盖范围扫描,默认读 111182 索引项、106348 候选订单。
- 历史流水走 `idx_wt_tenant_bizid_type` 的 tenant_id + biz_id + biz_type 定位;默认 loops=106348,平均每次仅约 0.432 条候选;四场景均不存在逐单全表扫描。
- NO_MERGE 生效,先物化部门结果,只有一个历史标量子查询;外层部门筛选/分组没有再次执行该表达式。
- Wallet 走现有 `idx_wallet_tx_tenant_del_biz_ct_uid` 的五个业务类型/日期区间,默认读取约 62015 行后聚合,不再按租户扫描 90 万行。
- 缺失索引提示的只读探针在真实 MySQL 返回 warning 3128 并正常生成回退计划,无 SQL 执行错误;PolarDB 是否识别提示仍需按具体版本验收。
## 索引、权限与发布顺序
配套脚本:`db/20261022_hard_dept_statistics_indexes.sql`。本次未在任何业务表加索引;脚本实际执行验证仅使用测试专用 scratch 库,结束删除;百万数据仅在当前连接 TEMPORARY 表中生成并建索引,关闭连接自动删除。
1. 订单候选覆盖索引 `(tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price)`;普通窄时间索引不足以覆盖读取字段。
2. 钱包业务编号定位索引 `(tenant_id,biz_id,biz_type(8))`,复用其他任务的 `idx_wt_tenant_bizid_type`,不创建第二条同类宽索引。`biz_type` 为 TINYTEXT,前缀必须保留,该索引不是完整覆盖索引。
3. 钱包六类合并统计复用现有 `idx_wallet_tx_tenant_del_biz_ct_uid`,日期范围查询使用前述优化器提示,不追加大宽索引;需确认同名索引及提示在具体 PolarDB 小版本生效。
4. 脚本识别其他名称的等效可见索引、完整列前缀,重复运行跳过;同名但定义不同会报错,应核对后处理。大表低峰逐条在线建索引,核对长事务、元数据锁、空间和具体 PolarDB 版本支持。
5. **先准备索引,再发布所有 Wallet 实例的新内部接口,最后滚动发布 Hardware。** 原 Wallet 内部 API 未删除,先发布 Wallet 兼容旧 Hardware。
6. **Hardware 与 Wallet 库必须同实例;Hardware 生产数据库账号需要 `pjcloudx_wallet.wallet_transaction` 的 SELECT 权限。** 这是新增依赖,原本查询 wallet_manage 的权限不能代替;本地 root 测试不能证明应用账号权限。请用实际 Hardware 账号先执行只读查询和 EXPLAIN。
7. 在真实租户上灰度验证默认日期、部门包含/排除、钱包类型的 16 项金额/次数与导出;检查历史流水业务编号索引和相关子查询实际 loops。观察分段日志、DB CPU/IO、RPC 时延后扩大范围。
本次结果是本机 MySQL 的 SQL、Java 映射/归集和文件回归结果,**不是生产 PolarDB 接口时延承诺**;不包含网关、UPMS/Wallet 的真实网络与连接池排队。大范围无日期查询仍要读取该范围全部相关订单,不能把百万样本耗时直接外推到线上全部历史数据。
## 复现
先在 shell 中设置本地测试库的 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_USER`、`MYSQL_PWD`,不要把凭证写入仓库。仓库根目录执行:
```bash
HARD_DEPT_MYSQL_TEST=true mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \
-Dtest=HardDepartmentStatisticsMySqlTest \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \
'-DargLine=-Xmx768m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test
HARD_DEPT_MYSQL_TEST=true HARD_DEPT_MYSQL_BENCHMARK=true \
mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \
-Dtest=HardDepartmentStatisticsMySqlBenchmarkTest -Dhard.dept.benchmark.finalOnly=true \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \
'-DargLine=-Xmx1536m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test
```
环境开关未启用时真实 MySQL 测试跳过;不能把跳过视为验证通过。macOS 文件回归需保留 `java.awt.headless=true`,避免 AWT 初始化导致测试 JVM 退出。
去掉 `hard.dept.benchmark.finalOnly` 可连同原索引阶段一起执行;设置 `hard.dept.benchmark.walletCandidates=true` 可单独复现三个钱包索引选择方案。所有模式仍要求两个 MySQL 环境开关显式开启。
+51
View File
@@ -0,0 +1,51 @@
-- 不活跃钱包统计专用索引。仅新增索引,不修改业务数据;大表请低峰逐条执行并观察 IO。
-- 每段先检查可见 BTREE 索引的列前缀,等价索引已存在时跳过。
-- 若同名索引定义不同,CREATE INDEX 将报错,应检查后人工处理,禁止盲目删除重建。
SET @wallet_inactive_ddl = IF(EXISTS (
SELECT 1 FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction'
AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE'
GROUP BY INDEX_NAME
) indexes WHERE cols = 'tenant_id,user_id,del_flag,create_time'
OR cols LIKE 'tenant_id,user_id,del_flag,create_time,%'
), 'SELECT ''wallet inactive: equivalent transaction index exists''',
'CREATE INDEX idx_wallet_inactive_tx_user_time ON pjcloudx_wallet.wallet_transaction (tenant_id,user_id,del_flag,create_time)');
PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl;
EXECUTE wallet_inactive_stmt;
DEALLOCATE PREPARE wallet_inactive_stmt;
SET @wallet_inactive_ddl = IF(EXISTS (
SELECT 1 FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_manage'
AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE'
GROUP BY INDEX_NAME
) indexes WHERE cols = 'tenant_id,del_flag,user_id,create_time'
OR cols LIKE 'tenant_id,del_flag,user_id,create_time,%'
), 'SELECT ''wallet inactive: equivalent user index exists''',
'CREATE INDEX idx_wallet_inactive_user ON pjcloudx_wallet.wallet_manage (tenant_id,del_flag,user_id,create_time)');
PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl;
EXECUTE wallet_inactive_stmt;
DEALLOCATE PREPARE wallet_inactive_stmt;
SET @wallet_inactive_ddl = IF(EXISTS (
SELECT 1 FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_manage'
AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE'
GROUP BY INDEX_NAME
) indexes WHERE cols = 'tenant_id,del_flag,dept_id,user_id,create_time'
OR cols LIKE 'tenant_id,del_flag,dept_id,user_id,create_time,%'
), 'SELECT ''wallet inactive: equivalent department index exists''',
'CREATE INDEX idx_wallet_inactive_dept_user ON pjcloudx_wallet.wallet_manage (tenant_id,del_flag,dept_id,user_id,create_time)');
PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl;
EXECUTE wallet_inactive_stmt;
DEALLOCATE PREPARE wallet_inactive_stmt;
-- 执行后核对 SHOW INDEX,并对 Mapper 的 countUsers / pageUsers / listWallets
-- 分别进行 EXPLAIN ANALYZE,验证近期流水存在性检查和最后流水取首行使用用户时间索引。
@@ -0,0 +1,382 @@
-- 钱包账本核对草稿(2026-10-22);只含 SELECT 和会话变量 SET,不建表、不改数据。
-- 本文件未连接任何数据库、未执行生产核对,不能将下列查询当作已有线上结果。
-- 适用 MySQL 8 / PolarDB MySQL 8。三个库须在同一实例且核对账号有 SELECT 权限。
-- 使用前只填写本租户编号。NULL 默认不会匹配任何业务数据,避免不知租户时查全库。
-- 核对的是用户给出的“全权限、未额外筛选商户/商户类型/钱包类型”场景;
-- 若实际账号有行级权限或商户类型限制,应先将相同限制添加到新旧两侧,不能直接拿全租户结果与页面比较。
-- 日期使用中国业务时间;请确保会话中 DATETIME/TIMESTAMP 的解释与应用一致,本脚本不更改时区。
SET @recon_tenant_id = NULL;
SET @recon_from = CAST('2026-09-01 00:00:00' AS DATETIME);
SET @recon_to = CAST('2026-10-01 00:00:00' AS DATETIME);
SET @recon_excluded_dept_1 = 1875013738914156546;
SET @recon_excluded_dept_2 = 1910218102049226753;
SET @recon_excluded_dept_3 = 1910218128624336898;
SET @recon_excluded_dept_4 = 1942046316044144642;
-- 用户报告的原页面结果,单位分;用于比较是否复现,绝不代表本次已验证。
SET @recon_reported_merchant_cents = 429542126;
SET @recon_reported_department_cents = 429394976;
SET @recon_reported_merchant_count = 64361;
SET @recon_reported_department_count = 64133;
SELECT @recon_tenant_id AS selected_tenant,
@recon_from AS inclusive_start, @recon_to AS exclusive_end,
CASE WHEN @recon_tenant_id IS NULL THEN '请先填写租户编号,后续空结果不是零流水结论' ELSE '已指定租户' END AS setup_status,
@recon_reported_merchant_cents - @recon_reported_department_cents AS reported_delta_cents,
@recon_reported_merchant_count - @recon_reported_department_count AS reported_delta_count;
-- 1. 新口径原始账本基准:PAYMENT=4 价格取反;REFUND=5 保留原始符号;零金额也计次。
-- 部门排除保留 dept_id=NULL,这些交易必须进入“未归属部门/非订单未归属”桶。
WITH ledger_base AS (
SELECT ledger.*,
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
FROM pjcloudx_wallet.wallet_transaction ledger
WHERE @recon_tenant_id IS NOT NULL
AND ledger.tenant_id = @recon_tenant_id
AND ledger.del_flag = '0'
AND ledger.biz_type IN ('4', '5')
AND ledger.create_time >= @recon_from
AND ledger.create_time < @recon_to
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
)
SELECT COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
COALESCE(-SUM(price), 0) AS net_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
FROM ledger_base;
-- 2. 同一批账本按真实商户归属汇总,金额和次数仍完全来自账本。
-- 订单/售后只补商户关系,不限制其软删除、状态、支付金额及支付/创建时间。
-- 精确业务编号 + 同租户 + 同钱包,未匹配项显示“非订单/未归属”;不能漏掉这个桶。
WITH ledger_base AS (
SELECT ledger.*,
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
FROM pjcloudx_wallet.wallet_transaction ledger
WHERE @recon_tenant_id IS NOT NULL
AND ledger.tenant_id = @recon_tenant_id
AND ledger.del_flag = '0'
AND ledger.biz_type IN ('4', '5')
AND ledger.create_time >= @recon_from
AND ledger.create_time < @recon_to
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
),
merchant_ledger AS (
SELECT ledger.*, ledger_order.merchant_id
FROM ledger_base ledger
LEFT JOIN pjcloudx_hardware.hard_after_sale ledger_refund
ON ledger.biz_type = '5'
AND ledger_refund.tenant_id = ledger.tenant_id
AND ledger_refund.id = ledger.numeric_biz_id
AND CAST(ledger.biz_id AS BINARY) = CAST(CONCAT('', ledger_refund.id) AS BINARY)
LEFT JOIN pjcloudx_hardware.hard_order ledger_order
ON ledger_order.tenant_id = ledger.tenant_id
AND ledger_order.wallet_id = ledger.wallet_id
AND ledger_order.id = CASE WHEN ledger.biz_type = '4' THEN ledger.numeric_biz_id
WHEN ledger.biz_type = '5' THEN ledger_refund.order_id ELSE NULL END
AND (ledger.biz_type = '5'
OR CAST(ledger.biz_id AS BINARY) = CAST(CONCAT('', ledger_order.id) AS BINARY))
)
SELECT merchant_id,
CASE WHEN merchant_id IS NULL THEN '非订单/未归属' ELSE CONCAT('商户(', merchant_id, ')') END AS merchant_bucket,
COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
COALESCE(-SUM(price), 0) AS net_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
FROM merchant_ledger
GROUP BY merchant_id
ORDER BY merchant_id;
-- 3. 同一批账本按流水历史部门汇总;各组相加应等于第1组、第2组结果。
-- 不与当前部门列表做 INNER JOIN,物理缺失部门及 NULL 部门仍保留。
WITH ledger_base AS (
SELECT ledger.*,
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
FROM pjcloudx_wallet.wallet_transaction ledger
WHERE @recon_tenant_id IS NOT NULL
AND ledger.tenant_id = @recon_tenant_id
AND ledger.del_flag = '0'
AND ledger.biz_type IN ('4', '5')
AND ledger.create_time >= @recon_from
AND ledger.create_time < @recon_to
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
)
SELECT dept_id,
CASE WHEN dept_id IS NULL THEN '未归属部门' ELSE CONCAT('历史部门(', dept_id, ')') END AS department_bucket,
COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
COALESCE(-SUM(price), 0) AS net_cents,
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
FROM ledger_base
GROUP BY dept_id
ORDER BY dept_id;
-- 4. 旧两个支付链路的完整集合差异(仅解释旧“流水金额/次数”,不把退款混入支付差额)。
-- 旧商户:订单 pay_time BETWEEN(含右端)、订单 user_dept_id、merchant_id非空、pay_price/每单一次。
-- 旧部门:订单 create_time BETWEEN(含右端)、最新有效 PAYMENT 历史部门(不限流水时间/金额符号),
-- 无快照则回退订单部门;最后仅输出 UPMS 存在的部门。UPMS 软删除部门仍存在,不能过滤del_flag。
-- 该查询故意保留旧 BETWEEN;第1至3组的新账本使用半开区间,两者并非相同口径。
-- 本段模拟现有源码的全权限行为;若部署代码版本不同,先比对源码,不可硬解释为生产差额。
WITH order_basis AS (
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
a.pay_price, a.pay_time, a.create_time,
COALESCE((
SELECT history.dept_id
FROM pjcloudx_wallet.wallet_transaction history
WHERE history.tenant_id = a.tenant_id
AND history.biz_id = CONCAT('', a.id)
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
AND history.wallet_id = a.wallet_id
AND history.biz_type = '4'
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
AND history.del_flag = '0'
AND history.dept_id IS NOT NULL
ORDER BY history.id DESC LIMIT 1
), a.user_dept_id) AS historical_dept_id
FROM pjcloudx_hardware.hard_order a
WHERE @recon_tenant_id IS NOT NULL
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
AND a.order_status IN ('5', '20', '10')
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
OR (a.create_time BETWEEN @recon_from AND @recon_to))
),
old_flags AS (
SELECT a.*,
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
CASE WHEN EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
) THEN 1 ELSE 0 END AS historical_department_exists,
CASE WHEN a.merchant_id IS NOT NULL
AND a.pay_time BETWEEN @recon_from AND @recon_to
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
THEN 1 ELSE 0 END AS old_merchant_match,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
AND EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
)
THEN 1 ELSE 0 END AS old_department_match
FROM order_basis a
)
SELECT COALESCE(SUM(CASE WHEN old_merchant_match = 1 THEN pay_price ELSE 0 END), 0) AS old_merchant_cents,
SUM(old_merchant_match) AS old_merchant_count,
COALESCE(SUM(CASE WHEN old_department_match = 1 THEN pay_price ELSE 0 END), 0) AS old_department_cents,
SUM(old_department_match) AS old_department_count,
COALESCE(SUM(COALESCE(pay_price, 0) * (old_merchant_match - old_department_match)), 0) AS reconstructed_delta_cents,
COALESCE(SUM(old_merchant_match - old_department_match), 0) AS reconstructed_delta_count,
@recon_reported_merchant_cents - @recon_reported_department_cents AS reported_delta_cents,
@recon_reported_merchant_count - @recon_reported_department_count AS reported_delta_count,
CASE WHEN @recon_tenant_id IS NOT NULL
AND COALESCE(SUM(COALESCE(pay_price, 0) * (old_merchant_match - old_department_match)), 0)
= @recon_reported_merchant_cents - @recon_reported_department_cents
AND COALESCE(SUM(old_merchant_match - old_department_match), 0)
= @recon_reported_merchant_count - @recon_reported_department_count
THEN '金额及次数差额同时复现,仍需下组逐笔核对' ELSE '未同时复现,检查租户/权限/部署版本/元数据变化' END AS comparison_status
FROM old_flags;
-- 5. 逐笔列出旧链路集合差:正向仅进商户,负向仅进部门。
-- signed_difference_cents/count 相加应等于第4组重建差额;同时查看各时间、部门、缺失元数据列。
WITH order_basis AS (
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
a.pay_price, a.pay_time, a.create_time,
COALESCE((
SELECT history.dept_id
FROM pjcloudx_wallet.wallet_transaction history
WHERE history.tenant_id = a.tenant_id
AND history.biz_id = CONCAT('', a.id)
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
AND history.wallet_id = a.wallet_id
AND history.biz_type = '4'
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
AND history.del_flag = '0'
AND history.dept_id IS NOT NULL
ORDER BY history.id DESC LIMIT 1
), a.user_dept_id) AS historical_dept_id
FROM pjcloudx_hardware.hard_order a
WHERE @recon_tenant_id IS NOT NULL
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
AND a.order_status IN ('5', '20', '10')
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
OR (a.create_time BETWEEN @recon_from AND @recon_to))
),
old_flags AS (
SELECT a.*,
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
CASE WHEN EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
) THEN 1 ELSE 0 END AS historical_department_exists,
CASE WHEN a.merchant_id IS NOT NULL
AND a.pay_time BETWEEN @recon_from AND @recon_to
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
THEN 1 ELSE 0 END AS old_merchant_match,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
AND EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
)
THEN 1 ELSE 0 END AS old_department_match
FROM order_basis a
)
SELECT id AS order_id, merchant_id, wallet_id,
pay_time AS old_merchant_time, create_time AS old_department_time,
user_dept_id AS old_merchant_department, historical_dept_id AS old_department_department,
pay_price AS order_payment_cents,
old_merchant_match, old_department_match,
CASE WHEN old_merchant_match = 1 THEN '仅旧商户链路' ELSE '仅旧部门链路' END AS difference_direction,
COALESCE(pay_price, 0) * (old_merchant_match - old_department_match) AS signed_difference_cents,
old_merchant_match - old_department_match AS signed_difference_count,
pay_time_inside, create_time_inside, order_department_inside, historical_department_inside,
historical_department_exists
FROM old_flags
WHERE old_merchant_match <> old_department_match
ORDER BY difference_direction, id;
-- 6. 单独隔离“订单支付时间 vs 订单创建时间”差集:其他条件固定为旧商户的订单部门、真实商户及有效订单。
-- 这只是时间因素,不与第7组机械相加,时间和部门因素可能交叠。
WITH order_basis AS (
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
a.pay_price, a.pay_time, a.create_time,
COALESCE((
SELECT history.dept_id
FROM pjcloudx_wallet.wallet_transaction history
WHERE history.tenant_id = a.tenant_id
AND history.biz_id = CONCAT('', a.id)
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
AND history.wallet_id = a.wallet_id
AND history.biz_type = '4'
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
AND history.del_flag = '0'
AND history.dept_id IS NOT NULL
ORDER BY history.id DESC LIMIT 1
), a.user_dept_id) AS historical_dept_id
FROM pjcloudx_hardware.hard_order a
WHERE @recon_tenant_id IS NOT NULL
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
AND a.order_status IN ('5', '20', '10')
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
OR (a.create_time BETWEEN @recon_from AND @recon_to))
),
old_flags AS (
SELECT a.*,
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
CASE WHEN EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
) THEN 1 ELSE 0 END AS historical_department_exists,
CASE WHEN a.merchant_id IS NOT NULL
AND a.pay_time BETWEEN @recon_from AND @recon_to
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
THEN 1 ELSE 0 END AS old_merchant_match,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
AND EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
)
THEN 1 ELSE 0 END AS old_department_match
FROM order_basis a
)
SELECT CASE WHEN pay_time_inside = 1 THEN '仅支付时间命中' ELSE '仅创建时间命中' END AS time_difference,
COUNT(*) AS order_count,
COALESCE(SUM(pay_price), 0) AS order_payment_cents,
COALESCE(SUM(COALESCE(pay_price, 0) * (pay_time_inside - create_time_inside)), 0) AS signed_time_delta_cents,
SUM(pay_time_inside - create_time_inside) AS signed_time_delta_count
FROM old_flags
WHERE merchant_id IS NOT NULL AND order_department_inside = 1
AND pay_time_inside <> create_time_inside
GROUP BY pay_time_inside, create_time_inside
ORDER BY pay_time_inside DESC;
-- 7. 单独隔离“订单部门快照 vs 最新钱包支付历史部门”差集:统一使用订单支付时间、有效订单及真实商户。
-- 对照的是历史快照归属,不以当前用户部门代替;部门物理缺失的影响另看第5组 exists 字段。
-- 本组两方向均可能存在,不能因为总差额为正就只查“测试部门迁出”。
WITH order_basis AS (
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
a.pay_price, a.pay_time, a.create_time,
COALESCE((
SELECT history.dept_id
FROM pjcloudx_wallet.wallet_transaction history
WHERE history.tenant_id = a.tenant_id
AND history.biz_id = CONCAT('', a.id)
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
AND history.wallet_id = a.wallet_id
AND history.biz_type = '4'
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
AND history.del_flag = '0'
AND history.dept_id IS NOT NULL
ORDER BY history.id DESC LIMIT 1
), a.user_dept_id) AS historical_dept_id
FROM pjcloudx_hardware.hard_order a
WHERE @recon_tenant_id IS NOT NULL
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
AND a.order_status IN ('5', '20', '10')
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
OR (a.create_time BETWEEN @recon_from AND @recon_to))
),
old_flags AS (
SELECT a.*,
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
CASE WHEN EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
) THEN 1 ELSE 0 END AS historical_department_exists,
CASE WHEN a.merchant_id IS NOT NULL
AND a.pay_time BETWEEN @recon_from AND @recon_to
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
THEN 1 ELSE 0 END AS old_merchant_match,
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
AND EXISTS (
SELECT 1 FROM pjcloudx.sys_dept department
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
)
THEN 1 ELSE 0 END AS old_department_match
FROM order_basis a
)
SELECT id AS order_id, pay_time, wallet_id, merchant_id,
user_dept_id AS order_department, historical_dept_id AS wallet_snapshot_department,
order_department_inside, historical_department_inside,
COALESCE(pay_price, 0) * (order_department_inside - historical_department_inside) AS signed_department_delta_cents,
order_department_inside - historical_department_inside AS signed_department_delta_count
FROM old_flags
WHERE merchant_id IS NOT NULL AND pay_time_inside = 1
AND order_department_inside <> historical_department_inside
ORDER BY signed_department_delta_count DESC, id;
-- 8. 结束时刻探针:旧 BETWEEN 会包含 10月1日00:00:00,新账本半开区间排除它。
-- 这里只展示边界交易,不把结果预设为任何金额或次数。
SELECT ledger.id AS transaction_id, ledger.biz_type, ledger.price AS signed_cents,
ledger.create_time, ledger.dept_id, ledger.wallet_id, ledger.biz_id
FROM pjcloudx_wallet.wallet_transaction ledger
WHERE @recon_tenant_id IS NOT NULL
AND ledger.tenant_id = @recon_tenant_id AND ledger.del_flag = '0'
AND ledger.biz_type IN ('4', '5') AND ledger.create_time = @recon_to
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
ORDER BY ledger.id;
@@ -0,0 +1,43 @@
# 钱包账本交易统计验证记录(2026-10-22)
## 结果与口径
商户、部门和商户月份报表的支付、退款均改为 `wallet_transaction` 聚合:PAYMENT=4 的金额取 `-SUM(price)`,REFUND=5 保留原符号,每条流水分别计次,净额和净次数分别相减。所有报表使用流水 `create_time >= start AND create_time < end`,部门包含/排除使用流水 `dept_id`。
订单、售后只用于商户归属及原有数据权限,不再根据订单金额、状态、创建或支付时间决定账本收支。零元但无流水的订单不计入,实际零金额流水与冲正记录保留。全部 REFUND 都保留,非订单退款及缺失关联归入空商户 ID 的“非订单/未归属”行;该行仅全局权限、未限定商户范围时可见。普通历史商户资料缺失、部门物理缺失或空部门不会导致账本漏数。
部门的批量充值/扣减、单次充值/扣减和退还食堂仍保留原计算规则,改在同一账本查询中聚合。其他钱包接口、数据库结构与生产数据均未修改。
## 接口和导出
- 日商户和部门接口仍返回原列表结构;月份接口仍分页,支付与退款先合并聚合后分页,包含只有退款的月份。
- 现有字段名、金额分单位、租户和商户权限保留。权限在分组前执行;客户端指定商户类型必须与服务端类型授权取交集。
- 页面、同步/异步汇总导出共享查询。自然日分片使用相邻半开边界,支付和退款四个原始汇总字段一起累计;抽成在完整合并后计算。
- 月导出从账本月份分组产生商户集合,包含未归属和退款单边分组;一次固定查询/权限上下文,分页读取,金额全程以 BigDecimal 分值累计后转元。
- 单商户明细导出改为逐条钱包流水,包含原始金额、支付/退款金额与关联业务编号。保留五万笔上限,拒绝空或非正商户 ID,每批 200 笔按流水主键游标读取,完整临时文件生成后响应。
- 三个 Panel report 共用商户统计服务,原 createTimeList 映射为账本时间,保留其商户类型和商户筛选。
- 前端日期标签、半开边界、逐流水计次说明同步更新;未归属行禁止结账和单商户导出;月表明确“本页合计”。
## 实际验证
`mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am -DskipTests -Dfile.encoding=UTF-8 compile` 实际通过。
联合 Maven test 在 testCompile 阶段被原有 `src/test/java/hard/com/bsd/pjcloud/hard/WechatPayTest.java:39` 的未定义 `log` 阻塞,未修改该无关测试。使用该模块 Maven 已解析的依赖类路径、当前 Maven 编译的主代码,在临时目录以 javac 编译本次定向测试,并通过 JUnit Platform 1.10.5 执行:
- 12 个测试类,共发现 88 项,实际执行 **87 项全部通过,0 失败、0 中止**。
- 1 项原订单统计百万行压测因 `hard.order.statistics.performance` 未开启而跳过,不计为通过。
- 其中新账本 SQL 13 项执行真实 XML、租户和分页插件,验证商户合计、部门合计与独立原始账本基准一致;覆盖半开边界、多流水、正负冲正、零金额、非订单退款、精确大整数关联、软删/缺失资料、钱包类型、权限和跨租户。
- Service 与导出回归覆盖元数据分批查询及失败、半开自然日分片、空商户分组跨片合并、月导出跨 200 分组边界、真实 XLSX 全部金额/次数列和合计、重复导出不污染原统计,以及明细导出跨批及失败不响应残缺文件。
- 现有订单统计 SQL、权限、Service 和 Controller 定向回归同时通过;旧订单查询的通过不作为新账本口径正确性依据。
后端运行日志:`/var/folders/ng/w0f04t956sb_29sq0_pbw63c0000gn/T/pj-wallet-ledger-regression-gyqchw17/test.log`。该路径为临时验证文件,不是发布依赖。
管理端相关 Node 测试 **15/15 通过**,目标 ESLint 检查与四个 Vue SFC 模板编译通过,两仓库变更的 whitespace 检查通过。
## 生产核对边界
SQL 执行正确性使用 H2 的 MySQL 兼容模式;另验证未经替换的 MySQL 源 SQL 能被生产使用的 SQL 解析器解析。未连接生产 MySQL/PolarDB,未执行生产查询计划或新查询的大范围压测,不承诺生产时延。
线上用户报告的旧金额差 147150 分、次数差 228 尚未逐笔归因。使用 [只读对账脚本](20261022_wallet_ledger_reconciliation.sql),指定租户后核对原始账本、新商户/部门分组、旧订单时间/部门快照双向差集。脚本默认租户 NULL,空结果不能解释为零流水。
上线前应以实际 Hardware 数据库账号确认跨库 SELECT 权限,并在真实 PolarDB 上检查时间区间扫描、订单/售后主键关联与耗时;本次不自动执行索引或数据变更。新报表不再调用 Wallet 部门聚合 RPC,无需先发布 Wallet 服务。前端和 Hardware 应配套发布以保持时间标签和实际语义一致。本次尚未发布上线。
@@ -0,0 +1,46 @@
-- /wallet/wallet-rule-transaction/page-join 分页、准确计数及当前页详情索引。
-- 先发布配套 SQL 改造才能消除 GROUP BY 和 biz_id 的隐式数字转换。
-- 请在 pjcloudx_wallet 库核对 SHOW INDEX;同名同列索引已存在时跳过对应语句,
-- 同名但定义不同应先核实。大表低峰逐条执行,观察 IO 和复制延迟。
-- LOCK=NONE 仍需要短暂元数据锁;执行前排查长事务,勿并发创建索引。
-- 默认列表、时间范围、COUNT;等值前缀后按 create_time/id 反向扫描。
ALTER TABLE pjcloudx_wallet.wallet_rule_transaction
ADD INDEX idx_wrt_tenant_del_ct_id (tenant_id, del_flag, create_time, id),
ALGORITHM=INPLACE, LOCK=NONE;
-- 按转账类型筛选时仍保持稳定时间排序,并使该条件的 COUNT 使用覆盖索引。
ALTER TABLE pjcloudx_wallet.wallet_rule_transaction
ADD INDEX idx_wrt_tenant_del_biz_ct_id (tenant_id, del_flag, biz_type, create_time, id),
ALGORITHM=INPLACE, LOCK=NONE;
-- 退款/未退款状态查询:避免准确计数时扫描并回表全部转账记录。
ALTER TABLE pjcloudx_wallet.wallet_rule_transaction
ADD INDEX idx_wrt_tenant_del_refund_ct_id (tenant_id, del_flag, is_refund, create_time, id),
ALGORITHM=INPLACE, LOCK=NONE;
-- 退回日期范围:该筛选不要求传 is_refund,使用独立的日期前缀。
ALTER TABLE pjcloudx_wallet.wallet_rule_transaction
ADD INDEX idx_wrt_tenant_del_rt_ct_id (tenant_id, del_flag, refund_time, create_time, id),
ALGORITHM=INPLACE, LOCK=NONE;
-- EXISTS 和当前页流水定位;biz_type 是 TINYTEXT,必须使用前缀长度。
-- 必须保留 biz_id 的精确字符串等值条件;SIGNED 等值仅用于反向主键定位,不能替代它。
ALTER TABLE pjcloudx_wallet.wallet_transaction
ADD INDEX idx_wt_tenant_bizid_type (tenant_id, biz_id, biz_type(8)),
ALGORITHM=INPLACE, LOCK=NONE;
-- 部门树、部门权限的流水存在性判断;也覆盖按租户/类型筛选的子查询。
ALTER TABLE pjcloudx_wallet.wallet_transaction
ADD INDEX idx_wt_tenant_type_dept_bizid (tenant_id, biz_type(8), dept_id, biz_id, user_id),
ALGORITHM=INPLACE, LOCK=NONE;
-- 执行后用实际租户和日期 EXPLAIN 验证:
-- SELECT a.id FROM wallet_rule_transaction a
-- WHERE a.tenant_id = ? AND a.del_flag = '0'
-- AND a.create_time BETWEEN ? AND ?
-- ORDER BY a.create_time DESC, a.id DESC LIMIT 10;
-- 预期使用 idx_wrt_tenant_del_ct_id,无临时表和 filesort。
-- SELECT MIN(t.id) FROM wallet_transaction t
-- WHERE t.tenant_id = ? AND t.biz_id = ? AND t.biz_type = '6';
-- 预期使用 idx_wt_tenant_bizid_type,通过业务编号定位少量流水。
+32
View File
@@ -0,0 +1,32 @@
-- /wallet/wallet-manage/qb-total 历史余额统计索引(MySQL 8)。
-- 先在钱包库执行本脚本,再发布 Wallet 服务;不修改钱包、流水或余额数据。
-- 大流水表建索引会产生 IO/空间开销,请在低峰执行并观察磁盘和复制延迟。
-- 显式 GROUP BY tenant_id,del_flag,wallet_id,user_id 与此前缀配合使用跳组扫描。
-- create_time 确定月末之前的最新时间,id 解决同秒多笔流水的取值。
-- 不替换既有钱包流水/业务查询索引;其他索引承担不同查询场景。
SET @wallet_qb_ddl = IF(EXISTS (
SELECT 1 FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols,
SUM(SUB_PART IS NOT NULL) AS prefix_columns,
SUM(COLLATION != 'A') AS descending_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction'
AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE'
GROUP BY INDEX_NAME
) indexes
WHERE prefix_columns = 0
AND descending_columns = 0
AND (cols = 'tenant_id,del_flag,wallet_id,user_id,create_time,id'
OR LEFT(cols, LENGTH('tenant_id,del_flag,wallet_id,user_id,create_time,id') + 1)
= 'tenant_id,del_flag,wallet_id,user_id,create_time,id,')
), 'SELECT ''wallet qb: equivalent history index exists''',
'CREATE INDEX idx_wallet_qb_latest ON pjcloudx_wallet.wallet_transaction (tenant_id,del_flag,wallet_id,user_id,create_time,id) ALGORITHM=INPLACE LOCK=NONE');
PREPARE wallet_qb_stmt FROM @wallet_qb_ddl;
EXECUTE wallet_qb_stmt;
DEALLOCATE PREPARE wallet_qb_stmt;
-- 同名异构索引会报错,核对后人工处理,不自动删除旧索引。
-- 发布后用真实 tenant、月份及筛选参数执行 EXPLAIN ANALYZE:
-- 分组子查询应显示 Using index for group-by / Covering index skip scan for grouping,
-- 窗口节点处理的是钱包/历史所属用户候选数,不应是整月之前的全部流水数。
@@ -0,0 +1,42 @@
# 批量充值、扣减防重上线说明
本次只修代码和新增防重表。脚本不删除历史流水、不回填防重记录、不调整余额。历史差额、补偿扣款必须另行核账;不能根据 biz_id 重复就直接删除或再次扣钱。
## 修复范围
- 提交、编辑、删除、回调和补记先锁定申请。已经提交或存在资金流水的申请不能再次发起、重置审批字段或更改金额明细;同一发票只能绑定一次。
- 回调在锁内通过“租户 + 申请 + 流程号 + 审核中”条件变更状态,并检查影响行数。状态、发票、整批余额、流水和防重登记使用同一本地事务。任一明细失败全部回滚;钱包缺失不再跳过部分入账。
- `addWalletBalance` 在数据库钱包行锁内读取最新余额,检查更新/流水写入结果和整数溢出。类型 2(批量更新余额)与 8(退还各自食堂)按 `tenant_id + wallet_id + biz_type + biz_id` 防重。正金额、负金额、零金额均覆盖。
- 类型 1 人工调账仍允许使用同一用户业务号多次操作。未对整张 wallet_transaction 添加 biz_id 唯一限制,也未将本次防重规则扩展到所有业务类型。
- 补记仅允许审批通过的申请,与正常回调使用相同金额方向、流水类型和入账防重。草稿、审核中、拒绝的申请不能补记。
- 正元同步改到资金事务提交后,并用独立事务执行;失败记录申请号和租户,不再影响已提交的资金。此同步仍为提交后的同步调用,未新增持久化重试队列,也未保证回调响应在 10 秒内完成。
## 历史数据兼容
防重表上线时为空。首次收到旧业务号时,会在数据库当前读中查询相同租户/钱包/类型/业务号的历史流水,包括逻辑删除的记录:
| 情况 | 行为 |
| --- | --- |
| 无历史流水 | 余额、流水、防重键一起提交 |
| 唯一有效流水,金额相同 | 登记原流水,直接返回原结果,余额不变 |
| 多条流水、金额不同、流水已删除,或防重登记与流水不一致 | 拒绝处理,整笔回滚并记录异常,需要人工核查 |
不要通过删除防重表记录或修改审批状态来“重试”。若正元同步失败,应重试同步用户信息;重放一个已处理回调不会再执行同步。恢复历史数据必须以核账结果为依据。
## 上线顺序
1. 暂停批量充值/扣减提交、审批回调和补记入口,排空在途请求。保留资金数据备份和当前对账结果。
2. 连接目标钱包库,在该库执行 `20261102_wallet_recharge_idempotency.sql`。确认表为 InnoDB,复合主键与脚本一致。脚本可重复执行;不要对历史流水直接添加全局唯一 biz_id。
3. 更新并重启**全部钱包服务实例**后恢复入口。新旧版本混用期间,旧实例不会遵守新的防重协议,因此不能在混用时恢复资金写入。
4. 在预发布环境使用指定测试账户依次验证充值、记账扣款、退还食堂、重复回调、补记,以及中途失败回滚;对照余额、累计金额、流水数量和申请状态。生产验证须使用经业务确认的真实交易,并进行对应对账。
5. 监控“批量入账金额冲突”“批量入账历史流水异常”“充值回调未找到有效申请”“充值回调失败”和“正元同步失败”日志。异常时保留 `tenantId / applyId / processId / taskId / walletId / bizId`,核查后处理。
回退应用版本也必须先停写并排空请求;旧版本本身没有防重能力。保留新增防重表,不能把删表作为回退步骤。此改动未修改工作流引擎的通知发送机制;历史重复通知或重新发起审批的具体触发原因仍需生产请求/审批日志确认。
## 本地验证
`WalletRechargeIdempotencyMySqlTest` 使用真实 MySQL、InnoDB 行锁、项目租户拦截器/自动填充配置、生产 Mapper SQL 和 Spring 事务代理。仅新建并删除 `codex_recharge_*` 隔离测试库,不读取或改动生产数据。需显式提供 `WALLET_RECHARGE_TEST_JDBC_URL`、`MYSQL_USER`、`MYSQL_PWD`,没有连接时该组测试会跳过,不能将跳过当作通过。
主要覆盖并发/顺序重复入账(正数、负数、零、类型8)、同钱包不同业务并发、旧事务快照、历史重复/删除/金额冲突、流水失败/钱包更新失败回滚、租户隔离、重复人工调账、重复提交/重复回调、整批和发票状态回滚、补记方向与审批限制、发票重复绑定、提交后同步与同步失败隔离。
2026-11-02 本地验证结果:39 项真实 MySQL 用例及 20 项相关回归用例全部通过(共 59 项,0 失败、0 错误、0 跳过);钱包服务及依赖模块 Maven package 成功。测试库已清理。未部署、未执行生产 SQL。
@@ -0,0 +1,11 @@
-- 在部署新版 wallet 服务之前,对钱包库执行。只新增空表,不清理或修正历史流水、余额。
CREATE TABLE IF NOT EXISTS wallet_transaction_idempotency (
tenant_id BIGINT NOT NULL,
wallet_id BIGINT NOT NULL,
biz_type VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '仅批量更新2、退还食堂8',
biz_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
price INT NOT NULL COMMENT '有符号变动金额,分',
transaction_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, wallet_id, biz_type, biz_id)
) ENGINE=InnoDB COMMENT='批量钱包入账幂等键,与余额和流水同事务提交';
+150 -14
View File
@@ -177,22 +177,83 @@ CREATE TABLE hard_group
DROP TABLE IF EXISTS `hard_invoice`; DROP TABLE IF EXISTS `hard_invoice`;
CREATE TABLE hard_invoice CREATE TABLE hard_invoice
( (
id bigint NOT NULL COMMENT '编号', id bigint NOT NULL COMMENT '编号',
dept_id bigint NOT NULL COMMENT '部门id', dept_id bigint NOT NULL COMMENT '部门id',
hard_id bigint NOT NULL COMMENT '设备id', hard_id bigint DEFAULT NULL COMMENT '来源业务或核销终端id',
order_id bigint NOT NULL COMMENT '硬件订单id', printer_hard_id bigint DEFAULT NULL COMMENT '目标尔印打印终端id',
status char(10) NOT NULL COMMENT '状态', order_id bigint NOT NULL COMMENT '硬件订单id',
pic varchar(256) NOT NULL COMMENT '发票图片', print_type char(2) NOT NULL DEFAULT '1' COMMENT '打印类型,1发票、2兑换券',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', print_no int NOT NULL DEFAULT 1 COMMENT '同订单同类型打印序号',
create_time datetime DEFAULT NULL COMMENT '创建时间', retry_version int NOT NULL DEFAULT 0 COMMENT '人工失败重试版本',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', attempt_count int NOT NULL DEFAULT 0 COMMENT '终端USB累计尝试次数',
update_time datetime DEFAULT NULL COMMENT '修改时间', status char(10) NOT NULL DEFAULT '0' COMMENT '状态,0待生成、1待打印、2已打印、3打印失败、4已取消',
del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记', failure_stage varchar(32) DEFAULT NULL COMMENT '失败阶段',
tenant_id bigint DEFAULT NULL COMMENT '租户ID', last_error varchar(2000) DEFAULT NULL COMMENT '最近失败摘要',
PRIMARY KEY (id) printed_at datetime DEFAULT NULL COMMENT '终端确认写入成功时间',
reprint_reason varchar(500) DEFAULT NULL COMMENT '人工重打或再次核销原因',
pic varchar(512) DEFAULT NULL COMMENT '后端生成的打印PNG地址',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记',
tenant_id bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (id),
UNIQUE KEY uk_hard_invoice_order_type_no (tenant_id, order_id, print_type, print_no),
KEY idx_hard_invoice_printer_status (tenant_id, printer_hard_id, status, update_time, del_flag),
KEY idx_hard_invoice_source_status (tenant_id, hard_id, status, del_flag)
) ENGINE = InnoDB ) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT ='发票记录'; COLLATE = utf8mb4_general_ci COMMENT ='打印任务与凭证记录';
-- ----------------------------
-- Table structure for hard_printer_binding
-- ----------------------------
DROP TABLE IF EXISTS `hard_printer_binding`;
CREATE TABLE hard_printer_binding
(
id bigint NOT NULL COMMENT '绑定关系主键',
source_hard_id bigint NOT NULL COMMENT 'SUPERMARKET或CANTEEN来源业务终端id',
printer_hard_id bigint NOT NULL COMMENT 'EY_PRINT目标打印终端id',
merchant_id bigint NOT NULL COMMENT '双方共同所属商户id快照',
dept_id bigint NOT NULL COMMENT '双方共同所属商户部门id快照',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
tenant_id bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (id),
UNIQUE KEY uk_hard_printer_binding_source (tenant_id, source_hard_id),
UNIQUE KEY uk_hard_printer_binding_printer (tenant_id, printer_hard_id),
KEY idx_hard_printer_binding_merchant (tenant_id, merchant_id)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT ='业务终端与尔印打印终端一对一绑定';
-- ----------------------------
-- Table structure for hard_printer_config
-- ----------------------------
DROP TABLE IF EXISTS `hard_printer_config`;
CREATE TABLE hard_printer_config
(
id bigint NOT NULL COMMENT '配置主键',
printer_hard_id bigint NOT NULL COMMENT 'EY_PRINT打印终端id',
printer_model varchar(128) DEFAULT NULL COMMENT '打印机型号',
usb_vendor_id int DEFAULT NULL COMMENT 'USB Vendor ID十进制值',
usb_product_id int DEFAULT NULL COMMENT 'USB Product ID十进制值',
paper_width int NOT NULL DEFAULT 80 COMMENT '纸宽,单位mm,首期固定80',
auto_cut char(1) NOT NULL DEFAULT '1' COMMENT '自动切刀开关,1开启、0关闭',
image_mode varchar(16) NOT NULL DEFAULT 'GS_V0' COMMENT '图片指令模式,GS_V0或ESC_STAR',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
tenant_id bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (id),
UNIQUE KEY uk_hard_printer_config_printer (tenant_id, printer_hard_id)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT ='尔印打印终端USB与ESC/POS配置';
-- ---------------------------- -- ----------------------------
-- Table structure for hard_car -- Table structure for hard_car
@@ -311,6 +372,50 @@ CREATE TABLE hard_order
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE = InnoDB COMMENT ='订单表'; ) ENGINE = InnoDB COMMENT ='订单表';
-- ----------------------------
-- Table structure for hard_order_evidence_image
-- ----------------------------
DROP TABLE IF EXISTS `hard_order_evidence_image`;
CREATE TABLE hard_order_evidence_image
(
id bigint NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
order_id bigint NOT NULL COMMENT '订单主键,来源hard_order.id',
android_order_code varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
evidence_type varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
evidence_role varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
sequence_no int NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
user_id bigint DEFAULT NULL COMMENT '识别用户主键快照',
face_sample_id varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
reference_image_url varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
similarity decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
similarity_threshold decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
hard_id bigint NOT NULL COMMENT '产生证据的设备主键',
captured_at datetime DEFAULT NULL COMMENT '终端实际采集时间',
sys_file_id bigint DEFAULT NULL COMMENT '文件中心sys_file记录主键',
storage_file_name varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
image_url varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
image_hash char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
image_size bigint DEFAULT NULL COMMENT '实际文件大小,单位字节',
image_width int DEFAULT NULL COMMENT '实际图片宽度,单位像素',
image_height int DEFAULT NULL COMMENT '实际图片高度,单位像素',
status varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
failure_reason varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
app_version varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
create_by varchar(64) DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
del_flag char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
tenant_id bigint NOT NULL COMMENT '租户主键',
PRIMARY KEY (id),
UNIQUE KEY uk_hard_order_evidence_position
(tenant_id, order_id, evidence_type, evidence_role, sequence_no, del_flag),
KEY idx_hard_order_evidence_android_order
(tenant_id, hard_id, android_order_code, del_flag),
KEY idx_hard_order_evidence_status
(tenant_id, status, update_time, del_flag)
) ENGINE = InnoDB COMMENT ='硬件订单识别证据图片';
-- ---------------------------- -- ----------------------------
-- Table structure for hard_after_sale -- Table structure for hard_after_sale
-- ---------------------------- -- ----------------------------
@@ -600,3 +705,34 @@ CREATE TABLE hard_third_mi_order
tenant_id bigint DEFAULT NULL COMMENT '租户ID', tenant_id bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE = InnoDB COMMENT ='硬件商品免密订单'; ) ENGINE = InnoDB COMMENT ='硬件商品免密订单';
-- ----------------------------
-- Table structure for hard_xfj_log
-- ----------------------------
DROP TABLE IF EXISTS `hard_xfj_log`;
CREATE TABLE hard_xfj_log
(
id bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
unique_code varchar(64) NOT NULL COMMENT '设备唯一编号',
hard_type varchar(32) NOT NULL COMMENT '设备类型',
title varchar(64) NOT NULL COMMENT '接口标题',
headers varchar(2048) COMMENT 'headers',
res varchar(2048) COMMENT 'res',
req varchar(2048) COMMENT 'req',
error varchar(2048) COMMENT 'error',
start_time datetime DEFAULT NULL COMMENT '开始时间',
end_time datetime DEFAULT NULL COMMENT '结束时间',
cost int DEFAULT NULL COMMENT '耗时(毫秒)',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记',
tenant_id bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (id),
UNIQUE KEY idx_unique_code (unique_code),
KEY idx_hard_type (hard_type),
KEY idx_title (title),
KEY idx_start_time (start_time),
KEY idx_end_time (end_time)
) ENGINE = InnoDB COMMENT ='消费机日志';
+18 -1
View File
@@ -107,6 +107,7 @@ CREATE TABLE mch_manage
address_des varchar(256) DEFAULT NULL COMMENT '商户地址', address_des varchar(256) DEFAULT NULL COMMENT '商户地址',
sort int NOT NULL COMMENT '排序', sort int NOT NULL COMMENT '排序',
discounts_price varchar(256) NOT NULL COMMENT '优惠价格(没实际作用)', discounts_price varchar(256) NOT NULL COMMENT '优惠价格(没实际作用)',
discounts_rate int DEFAULT NULL COMMENT '优惠折扣(1-99,10=1折)',
cooperation_status varchar(64) NOT NULL COMMENT '合作状态', cooperation_status varchar(64) NOT NULL COMMENT '合作状态',
mch_count int NOT NULL COMMENT '分店数量', mch_count int NOT NULL COMMENT '分店数量',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
@@ -374,6 +375,8 @@ CREATE TABLE wallet_transaction
title varchar(128) NOT NULL COMMENT '流水标题', title varchar(128) NOT NULL COMMENT '流水标题',
price int NOT NULL COMMENT '交易金额, 单位分', price int NOT NULL COMMENT '交易金额, 单位分',
balance int NOT NULL COMMENT '余额, 单位分', balance int NOT NULL COMMENT '余额, 单位分',
tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔',
remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间', create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
@@ -385,6 +388,18 @@ CREATE TABLE wallet_transaction
DEFAULT CHARSET = utf8mb4 COMMENT ='会员钱包流水表'; DEFAULT CHARSET = utf8mb4 COMMENT ='会员钱包流水表';
CREATE INDEX idx_dept_id ON wallet_transaction (wallet_id); CREATE INDEX idx_dept_id ON wallet_transaction (wallet_id);
-- 批量充值/扣减持久化防重;已有环境执行 20261102_wallet_recharge_idempotency.sql。
CREATE TABLE IF NOT EXISTS wallet_transaction_idempotency (
tenant_id BIGINT NOT NULL,
wallet_id BIGINT NOT NULL,
biz_type VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '仅批量更新2、退还食堂8',
biz_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
price INT NOT NULL COMMENT '有符号变动金额,分',
transaction_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, wallet_id, biz_type, biz_id)
) ENGINE=InnoDB COMMENT='批量钱包入账幂等键,与余额和流水同事务提交';
-- ---------------------------- -- ----------------------------
-- Table structure for wallet_recharge_apply -- Table structure for wallet_recharge_apply
-- ---------------------------- -- ----------------------------
@@ -538,7 +553,9 @@ CREATE TABLE wallet_code_use
update_time datetime DEFAULT NULL COMMENT '修改时间', update_time datetime DEFAULT NULL COMMENT '修改时间',
del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记', del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记',
tenant_id bigint DEFAULT NULL COMMENT '租户ID', tenant_id bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (id) PRIMARY KEY (id),
INDEX idx_wcu_user_biz (user_id, biz_id),
INDEX idx_wcu_biz (biz_id)
) ENGINE = InnoDB ) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='二维码使用表'; DEFAULT CHARSET = utf8mb4 COMMENT ='二维码使用表';
@@ -0,0 +1,44 @@
-- 线上慢 SQL 索引优化(OceanBase MySQL 模式)
-- 执行前请确认同名索引不存在;建议低峰期逐条执行并观察建索引耗时。
-- 本文件只包含索引 DDL,不包含任何数据修改。
-- P0:订单列表按租户、删除标记、创建时间倒序分页。
CREATE INDEX idx_hard_order_tenant_del_ct_uid
ON pjcloudx_hardware.hard_order (tenant_id, del_flag, create_time, user_id);
-- P0:订单号 + 订单类型 + 状态精确计数。
CREATE INDEX idx_hard_order_tenant_del_order_lookup
ON pjcloudx_hardware.hard_order (tenant_id, del_flag, order_no, order_type, order_status);
-- P0:设备侧订单号精确查询。
CREATE INDEX idx_hard_order_tenant_del_android_code
ON pjcloudx_hardware.hard_order (tenant_id, del_flag, android_order_code);
-- P0:每个订单取最新售后记录(GROUP BY order_id + MAX(id))。
CREATE INDEX idx_hard_after_sale_tenant_order_id
ON pjcloudx_hardware.hard_after_sale (tenant_id, order_id, id);
-- P0:钱包流水按业务类型、时间倒序分页;biz_type 为 TINYTEXT,使用前缀索引。
CREATE INDEX idx_wallet_tx_tenant_del_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, create_time, user_id);
-- P0:钱包流水按业务类型、时间倒序分页;biz_type 为 TINYTEXT,使用前缀索引。
CREATE INDEX idx_wallet_tx_tenant_del_biz_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, biz_type(8), create_time, user_id);
-- P0:流程历史按办理人查询并按结束时间倒序。
CREATE INDEX idx_act_hi_actinst_assignee_end
ON pjcloudx_flow.act_hi_actinst (ASSIGNEE_, END_TIME_);
-- P1:支付状态 + 支付时间统计。先观察 P0 上线后的实际收益,再决定是否执行。
-- CREATE INDEX idx_hard_order_tenant_del_status_ptime_mch
-- ON pjcloudx_hardware.hard_order (tenant_id, del_flag, order_status, pay_time, merchant_id);
-- P1:指定商户部门的支付统计。索引较宽,按接口频率决定是否执行。
-- CREATE INDEX idx_hard_order_tenant_del_mdept_status_mt_ptime
-- ON pjcloudx_hardware.hard_order
-- (tenant_id, del_flag, merchant_dept_id, order_status, merchant_type, pay_time);
-- P1:按钱包所属部门定位钱包 ID;维表较小时收益有限。
-- CREATE INDEX idx_wallet_manage_tenant_dept_id
-- ON pjcloudx_wallet.wallet_manage (tenant_id, dept_id, id);
@@ -0,0 +1,54 @@
# 线上慢 SQL 索引优化分析(2026-08-26)
## 数据范围
- 来源:`/Users/dengzhangchen/Downloads/rm-1fyg92tzhbt23g9hw_slowlog_*.csv`
- 原始记录:638 条;按完整请求字段去重后 608 条;63 个 SQL Hash。
- 数据库分布:`pjcloudx_hardware` 451 条、`pjcloudx_wallet` 153 条、`pjcloudx_flow` 4 条。
- 主要扫描表:`hard_order`、`hard_after_sale`、`wallet_transaction`、`act_hi_actinst`。
- 测试连接:OceanBase CE 4.3.5.5,MySQL 兼容模式;读取 `SHOW CREATE TABLE`、`SHOW INDEX`、`EXPLAIN`,并在测试库临时创建/回滚候选索引;未在生产库执行 DDL。
## 结论
### 强推荐,先上线
| 优先级 | 数据库/表 | 建议索引 | 主要覆盖日志 | 依据 |
|---|---|---|---|---|
| P0 | `pjcloudx_hardware.hard_order` | `(tenant_id, del_flag, create_time, user_id)` | 订单列表、分页 COUNT,Hash `270048891%7C1391`、`528608244%7C616` 等 | 现有索引的第三列是 `merchant_id` 或 `user_id`,无法同时支撑租户+删除标记后的时间倒序;临时索引验证后由扫描+TOP-N 变为反向范围扫描。 |
| P0 | `pjcloudx_hardware.hard_order` | `(tenant_id, del_flag, order_no, order_type, order_status)` | 订单号精确 COUNT,Hash `_1019382644%7C138` | 原执行计划只能在统计索引上按 `order_status` 扫描,再回表过滤订单号/类型;临时索引验证为三列等值范围扫描。 |
| P0 | `pjcloudx_hardware.hard_order` | `(tenant_id, del_flag, android_order_code)` | 设备订单号查询,Hash `_986870313%7C628` | 原计划扫描约 98 万行后回表过滤 `android_order_code`;临时索引验证为等值范围扫描。 |
| P0 | `pjcloudx_hardware.hard_after_sale` | `(tenant_id, order_id, id)` | 订单列表中的“每单最新售后”子查询,覆盖 233 条相关记录 | 原表只有主键,`GROUP BY order_id + MAX(id)` 为全表扫描;临时索引验证后为有序范围扫描并使用 Merge Group By。 |
| P0 | `pjcloudx_wallet.wallet_transaction` | `(tenant_id, del_flag, create_time, user_id)` | 无业务类型条件的钱包流水首页,Hash `698590166%7C408` | 原计划全表扫描并排序;临时索引验证后为反向范围扫描,直接取前 10 行。 |
| P0 | `pjcloudx_wallet.wallet_transaction` | `(tenant_id, del_flag, biz_type(8), create_time, user_id)` | 钱包流水按业务类型筛选列表/COUNT,Hash `2036241023%7C466`、`255738862%7C620` 等 | `biz_type` 是 `TINYTEXT`,必须使用前缀索引;临时索引验证后可按租户、删除标记、业务类型及时间范围扫描。 |
| P0 | `pjcloudx_flow.act_hi_actinst` | `(ASSIGNEE_, END_TIME_)` | 流程历史待办查询,Hash `_2096433074%7C126` | 原计划全表扫描并排序;临时索引验证后为反向范围扫描,直接满足 `ORDER BY END_TIME_ DESC LIMIT 10`。 |
### 条件推荐,先观察资源与收益
| 优先级 | 建议索引 | 适用场景 | 说明 |
|---|---|---|---|
| P1 | `pjcloudx_hardware.hard_order (tenant_id, del_flag, order_status, pay_time, merchant_id)` | 按支付状态+支付时间汇总,Hash `_1655261703%7C470` 等 | 临时索引可将范围缩小到状态/时间,但仍需要回表和聚合;建议 P0 上线后按 `EXPLAIN` 和实际耗时决定。 |
| P1 | `pjcloudx_hardware.hard_order (tenant_id, del_flag, merchant_dept_id, order_status, merchant_type, pay_time)` | 指定商户部门的支付统计,Hash `1634334540%7C358` 等 | 对指定部门过滤收益明显;宽索引写放大较大,先对高频租户/接口评估。 |
| P1 | `pjcloudx_wallet.wallet_manage (tenant_id, dept_id, id)` | 钱包按部门聚合 | 仅用于小维表定位钱包 ID;表规模较小时收益有限,适合与钱包统计接口一起验证。 |
## 暂不建议只靠加索引解决
- 统计类 SQL 使用派生表、外层权限过滤和 `ORDER BY CASE`,索引只能减少底层扫描,不能消除派生表物化/聚合成本;建议后续改为先过滤再聚合,并移除 COUNT 查询中的无意义排序。
- `hard_order` 订单详情列表反复 `LEFT JOIN` 最新售后子查询;P0 索引后仍建议将“最新售后 ID”改为窗口/预聚合或单独维护字段。
- `UPDATE hard_door_cabinet ... WHERE id = ? AND del_flag = ? AND tenant_id = ?` 已命中主键 `id`,慢点更可能是锁等待、事务或网络;加索引不能解决,需结合 `SHOW PROCESSLIST`、锁等待和事务耗时排查。
## 上线步骤
1. 低峰期逐条执行 `01_add_indexes.sql`,每条 DDL 单独观察完成时间和租户延迟。
2. 执行前确认同名索引不存在;OceanBase 建索引可能持续几十秒到数分钟,不要重复提交。
3. DDL 完成后对代表性 SQL 执行 `EXPLAIN`,确认使用新索引且不出现 `TABLE FULL SCAN`。
4. 观察 15–30 分钟慢 SQL、CPU、IO、事务提交延迟;若写入延迟明显,再评估 P1 索引。
5. 统计接口仍慢时,优先改写 SQL,再考虑继续增加宽复合索引。
## 验证记录
临时索引均已在测试库创建并回滚,最终检查无 `tmp_%` 索引残留。验证结果:
- 订单时间分页:`TABLE RANGE SCAN ... Reverse`,不再全表扫描/大范围 TOP-N。
- 钱包业务流水:`TABLE RANGE SCAN ... Reverse`,`biz_type(8)` 前缀索引可用。
- 最新售后:`MERGE GROUP BY` + `TABLE RANGE SCAN`,不再全表扫描。
- 流程历史:`TABLE RANGE SCAN ... Reverse`,不再全表扫描。
+58
View File
@@ -0,0 +1,58 @@
# 消费机创建订单分段耗时
外部接口 `/hard/hard-api/ad/create-order` 对应 hard 服务内部
`POST /hard-api/ad/create-order`。本次诊断仅观察该请求,不改变请求协议、
事务注解、分布式锁、支付顺序、重试和返回值。
## 日志策略与开销
- 通过 `System.nanoTime()` 和请求内存中的阶段表计时,不新增 SQL、Redis 或 Feign 请求。
- 正常且小于 1 秒的请求不新增摘要;慢请求、失败及阶段内被业务处理的异常输出一条 warning。
- 摘要在过滤器链返回或抛错后统一生成,不在扣款写事务中逐阶段输出日志。
- 专用 Logback 异步队列容量为 256,`neverBlock=true`。队列满时丢弃诊断摘要,不等待落盘。
- 使用已有 `debug.log` 文件;不写入 DTO、人员资料、金额、令牌、付款码、图片或异常消息。
计时与内存分配存在少量开销,不能承诺零开销。异步队列优先保障请求返回,
日志管道持续拥堵时可能缺少部分诊断记录。
## 取日志与匹配
部署包含 Java 代码和 `logback-spring.xml` 改动的 hard 服务后,在 `debug.log`
搜索 `|终端支付耗时|`。按 `androidOrderCode` 与消费机的 `|支付耗时|` 摘要、
原有订单 SQL 对齐;同号重试可通过不同的 `trace` 区分请求。
`outcome:failed` 是请求未正常完成,不能单凭这一字段判断钱包是否已经扣款;
仍需按原有订单对账逻辑确认。旁路通知或证据登记异常被业务自行处理时,
请求可能保持 `outcome:success`,异常类型保存在 `stageErrorTypes` 中。
## 主要阶段
| 字段 | 范围 |
| --- | --- |
| `pre_controller` | 请求监听器开始至 Controller 进入,含期间租户检查、认证、解析及校验 |
| `controller` | Controller 参数整理、Service 调用和返回对象创建 |
| `redis.lock` / `redis.unlock` | 原有 Redis 加锁、持锁检查及解锁 |
| `idempotency.lookup` / `idempotency.validate` | 原订单查询及重复请求一致性校验 |
| `prepare` | 下单准备整体,含下面的远程及本地查询 |
| `prepare.merchant` / `prepare.hardware` | 商户远程查询、设备本地查询 |
| `prepare.user` / `prepare.wallet` / `prepare.cart` | 用户、钱包及商品计价准备 |
| `transaction` | 原有代理事务调用,包含事务开始、提交及同步 afterCommit |
| `db.cart` / `db.orderInsert` | 购物车和订单写入 |
| `wallet.debit` | 原有钱包扣款远程调用 |
| `db.orderPaid` / `db.cartPaid` | 已支付状态更新 |
| `afterCommit.evidence` | 原有同步人脸证据元数据登记 |
| `postCommit.notify` | 原有提交后打印/播报分支 |
`stagesInclusiveMs` 单位为毫秒,父阶段包含子阶段,不能直接相加。
其他支付方式仍按原逻辑执行,日志可出现核销、库存及第三方支付阶段。
## 时间边界
监听器在 Servlet 过滤器之前记录起点,专用过滤器在原有租户过滤器之后、
安全过滤器之前绑定请求追踪,不调整已有过滤器顺序。
若请求在更早的租户过滤器中直接拒绝,尚未进入计时过滤器,则不会产生本摘要。
`totalMs` 截止过滤器链返回,不是客户端端到端耗时;不覆盖网关和入站网络、
容器调用请求监听器前的排队、最后的响应传输,以及客户端本地处理。
因此“客户端 4500ms、服务摘要 200ms”只能证明还需排查摘要计时区间之外的等待,
不能直接认定为 Flutter 程序卡顿。
+228
View File
@@ -0,0 +1,228 @@
# 文件读取与上传签名接入及发布
本次保护 `/admin/sys-file/oss/file` 的全部文件。默认 `observe` 只记录签名校验结果;只有加载完整配置并切到 `enforce` 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。
上传入口 `/admin/sys-file/upload`、`/admin/sys-file/inner-upload`、`/admin/sys-file/qn/upload` 同步使用应用签名和服务端文件白名单。上传复用这套配置和密钥,不增加环境密钥组;读取签名不能用于上传。
## 配置准备
服务端配置见 `config.example.yml`。密钥为至少 32 字节随机数据的 Base64 字符串,HMAC 使用 **Base64 解码后的字节**。不要复用 OAuth 客户端信息或密码加密密钥。
在 `pj_service_cloud` 执行以下命令可为并列的五个项目生成匹配的本地配置:
```bash
python3 scripts/generate-file-access-config.py \
--public-base-url https://www.pjzhhq.cn/api \
--allowed-origin https://stgl.pj.gov.cn
```
按用户最终要求,所有环境共用一套密钥配置,`admin/h5/mini/flutter/service/package` 各自保留固定密钥;开发、测试、生产不再另建密钥组。只有公网基础地址等部署参数随目标环境填写。脚本统一读取 `.file-access/keys.json`,重复执行不会更换密钥;旧命令的 `--environment` 参数仍接受,但不再切换密钥。
迁移时沿用原生产密钥组,避免已构建客户端失效。脚本只更新受管理的文件访问配置字段,保留其他业务环境变量;移除旧 Vite 模式文件中的文件访问字段,避免其覆盖公共配置。旧文件备份位于 Java 项目的 `.file-access/migration-backup/`,不参与构建。当前活动配置均被 Git 忽略:
首次生成只做一次,其他构建机器和 CI 同步这套现有配置。密钥统一后无需随部署环境重新生成。
| 项目 | 本地文件 | 使用方式 |
| --- | --- | --- |
| Java | `.file-access/config.yml` | Nacos 共享配置或 Spring 外部配置 |
| 后台 | `.env.local` | 所有 Vite 构建模式共用 |
| H5 和小程序 | 各项目的 `.env.local` | 所有 Vite 构建模式共用 |
| Flutter | `.file-access/config.json` | 所有构建使用 `--dart-define-from-file=.file-access/config.json` |
前端的配置项为 `VITE_FILE_ACCESS_APP_ID`、`VITE_FILE_ACCESS_KEY_ID`、`VITE_FILE_ACCESS_KEY`、`VITE_FILE_ACCESS_ALLOWED_ORIGINS`。Flutter 去掉 `VITE_` 前缀。Java 的 `clients` 包含 `admin/h5/mini/flutter/service/package`;`service` 和 `package` 密钥只放服务端。
**自动构建不会取得被 Git 忽略的 `.env.local`。** 后台的 `build`、`build:prod`、`build:docker` 统一使用 `--mode prod`,自动工具执行 `npm run build` / `pnpm build` 时读取 `.env.prod`。自动构建应从已提交的 `.env.prod` 或流水线环境变量取得同一组 admin 配置,继续使用既有密钥;不要为流水线重新生成密钥。缺少必要配置时构建会失败,发布流程应停止,不能继续部署上一次留下的 `dist`。
H5 使用 uni-app 的 `production` 模式,自动构建配置为该项目的 `.env.production`,其中 appId 为 `h5`,复用既有 H5 密钥。`build`、`build:h5` 显式使用 `uni build -p h5 --mode production`;Dockerfile 的 `pnpm build:h5` 同样读取该文件,默认产物目录为 `dist/build/h5`。自动构建所需的新配置文件须与源码一起提交;本地 `.env.local` 仍可用于开发,不作为 CI 输入。
`public-base-url` 是外部可访问的网关基础地址,可以带 `/api`,不含 `/admin/sys-file/oss/file`。现有的图片/API 基础地址继续由各端原有配置决定;`allowed-origins` / `ALLOWED_ORIGINS` 只列确实属于本系统的历史域名。上线前必须逐环境核对域名、基础路径、appId、kid 和密钥。相对 `/api/admin/sys-file/oss/file` 按当前文件基础地址解析,绝对历史地址保留其原有域名和部署路径。
**Java 不会自动读取仓库里的 `.file-access`。** 本地运行可以设置:
```bash
export SPRING_CONFIG_ADDITIONAL_LOCATION=file:/absolute/path/pj_service_cloud/.file-access/config.yml
```
通过现有 Nacos 配置体系加载这套 `file-access` 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 `observe`,已有配置保留当前模式;显式 `--mode` 可更新本地生成配置的模式。先检查配置源优先级;`config.example.yml` 中的环境变量占位符不能代替实际密钥。严格模式要求 `admin/mini/flutter/service/package` 的有效活动密钥及公网基础地址。`h5` 条目可整体省略;一旦配置该条目,密钥也必须完整。未配置 H5 不会自动放行 H5 请求,只有下面明确配置的图片来源可免签。运行中错误配置返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 `package/service` 密钥。
## 协议
请求查询参数为 `appId`、`kid`、`ts`、`sign`、`fileName`。签名原文按 LF 连接,无末尾换行:
```text
v1
oss-file-read
appId
kid
ts
Base64URL无填充编码的UTF-8文件名
```
`ts` 是 Unix 秒;`sign` 是 HMAC-SHA256 的 64 位小写十六进制。`fileName` 只 URL 解码一次,始终放在最后。后端生成的 APK URL 因此仍以 `.apk` 结尾,兼容旧终端的扩展名解析。
默认普通签名有效 1800 秒,`package` 有效 86400 秒,允许签发时间领先最多 300 秒。`GET /admin/sys-file/oss/access-info` 匿名返回 `R.data.serverTime`(**毫秒**)和 `ttlSeconds`,不包含密钥。各端校准时差并在签名剩余不足 5 分钟时续期;校时失败保留上次时差,不能退回裸链接。传输 URL 缓存在内存里,普通渲染不会每次产生新签名。
重复关键参数、缺少字段、非规范时间戳或非规范签名为 400;未知客户端/密钥、错误签名、过期、时钟越界为 403;文件不存在为 404,数据库或存储读取失败为 500。GET 和 HEAD 共用校验。普通登录 Token 不会绕过校验。文件响应最多私有缓存 5 分钟且不超过剩余签名寿命;错误响应禁止缓存。已经输出部分字节后的网络/存储中断无法可靠改写 HTTP 状态,应按中断响应处理。
`block-browser-navigation` 默认为 `true`。严格模式下,浏览器携带 `Sec-Fetch-Mode: navigate` 或 `Sec-Fetch-Dest: document/iframe/frame` 的文件请求返回 403,即使签名正确也不放行;覆盖地址栏粘贴、新窗口打开和直接嵌入文件页面。图片、音视频以及 fetch/XHR 下载继续校验签名后访问。响应的 `Vary` 包含 `Sec-Fetch-Mode` 和 `Sec-Fetch-Dest`,防止浏览器把图片缓存复用为文件页面。观察模式仅记录 `browser_navigation`,不拦截。
后台会截获本系统文件 `<a>` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。
## 旧 mobile H5 的图片来源兼容
旧 H5 无需重新构建或更新。在更新后的后端配置中增加:
```yaml
file-access:
public-image-referer-prefixes:
- https://www.pjzhhq.cn/mobile/
- https://yyfb.idw.jinhua.gov.cn:6443/
```
只对 GET/HEAD 图片读取生效:请求带有单个 `Referer`,其协议、主机、有效端口与配置匹配时,按以下两种范围判断:
- 带目录的配置(如 `/mobile/`)仍要求 Referer 路径位于该目录下,目录必须以 `/` 结尾。
- 根来源配置(如 `https://yyfb.idw.jinhua.gov.cn:6443/`,末尾 `/` 可省略)允许该来源下所有页面,包括浏览器跨域策略只发送根来源的请求。端口必须匹配;未写端口的 HTTPS 表示 443,不会匹配 6443。根来源不能再限制到某个应用目录。
命中后,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 `#/` 不会随 HTTP Referer 发送,也不能写进配置。跨域策略如果省略路径,单纯配置长目录不会自动退回根来源匹配。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。
这是独立的来源白名单,保留或省略 `clients.h5` 都可使用;不能仅根据 `appId=h5` 放行。新版本允许省略整个 H5 客户端配置,但空密钥、错误密钥配置仍报错,其他五类客户端的必要配置保持严格检查。若删除共享配置中的 H5 条目,所有加载旧版公共配置类的服务也需要同步更新;保留现有 H5 配置同样可以启用来源兼容。
配置默认空列表;显式清空列表即可恢复图片签名要求。必须部署新 UPMS 和对应的公共依赖后再启用配置,不能用旧后端只删除 H5 密钥。Referer 必须由浏览器和代理保留;没有 Referer 的匿名请求仍返回 403。Referer 可被脚本模拟,这是一层兼容性的来源检查。成功响应私有缓存最多 5 分钟,`Vary` 额外区分 Referer,代理不得忽略该差异复用放行响应。
根来源支持需要新版 `pjcloud-common-core` 和 UPMS 一起构建发布。建议将这份图片来源白名单放在 UPMS 自己的配置中;若通过共享配置下发给其他启用文件访问配置的服务,这些服务也必须先更新公共依赖,旧版校验类会拒绝根来源。此改动不要求更新 H5 或更换任何密钥。
## 上传身份与文件类型
现有登录鉴权及 `@Inner` 内部调用限制保留。每次 POST multipart 上传只允许一个名为 `file` 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段:
| 请求头 | 内容 |
| --- | --- |
| `X-File-App-Id` | `admin/h5/mini/flutter/service`,`package` 不可上传 |
| `X-File-Key-Id` | 现有 kid,如 `v1` |
| `X-File-Timestamp` | Unix 秒级时间戳 |
| `X-File-Nonce` | 16~64 位字母、数字、`_`、`-`,每次请求重新生成 |
| `X-File-Signature` | HMAC-SHA256 的 64 位小写十六进制 |
签名原文按 LF 连接,无末尾换行;密钥仍使用 Base64 解码后的字节:
```text
v1
oss-file-upload
POST
target
appId
kid
ts
nonce
```
`target` 分别为 `upload`、`inner-upload`、`qn-upload`,三种签名不能互换。`inner-upload` 仅接受 `service`。签名用于应用身份和本次上传操作校验,文件内容由服务端独立检查。默认有效 300 秒,允许客户端时间领先最多 60 秒;复用现有匿名校时接口。严格模式下 Redis 原子 `SET NX` 消耗 nonce,重复请求返回 403;Redis 或配置不可用返回 500,不自动放行。不增加 Dart UA 上传兼容。
`file-access.upload` 默认策略如下,省略配置时 Java 使用相同默认值:
| 文件类别 | 白名单后缀 |
| --- | --- |
| 图片 | jpg/jpeg/png/gif/webp/bmp/ico/svg |
| 文档和安装包 | pdf/apk/doc/docx/xls/xlsx/ppt/pptx |
| 文本和压缩包 | txt/csv/zip |
| 音视频 | mp4/mov/webm/avi/mp3/wav/m4a/aac/ogg/amr/flac |
| 仅内部上传额外允许 | log/gz,供终端日志使用 |
默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。
JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 `.png` 的合法 WebP 保存为 `.webp`、MIME 为 `image/webp`。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。
缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 `observe` 下仍执行,观察模式只放宽应用签名准入。监控为 `file.upload.requests`,标签 `outcome/app/mode`,不记录密钥、签名和登录 Token。
后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 `uni.uploadFile` 拦截器,以及 Flutter `HttpManage` 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。`RemoteFileService` 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。
**已有读取严格模式的环境,先发布客户端及调用文件服务的 Java 服务,再发布 UPMS。** 旧 UPMS 忽略新增头;新版 UPMS 在 `enforce` 下会拒绝没有上传签名的旧网页/服务请求。Java 调用方须随公共依赖一同更新,避免只升级 UPMS。旧 Flutter 当前通过硬件业务接口上传取证和日志,再由 Java 调用文件中心,这条链路无需旧设备立即升级;不要把这一结论扩展到其他直接调用文件中心的旧客户端。必要时切回 `observe` 恢复旧身份请求,文件白名单仍然生效。
## 入口与数据约束
| 部分 | 接入位置与行为 |
| --- | --- |
| Java 文件入口 | `FileAccessGuard`,`SysFileController`,`SysFileServiceImpl`;租户跳过只作用于文件查询并恢复原状态 |
| Java 内部读取 | `RemoteFileService.getFile` 通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证及发票盖章通过相同签名读取 |
| APK | 终端 `/app-version/ad/page` 和 `/app/last-version` 返回对象使用 `package` 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变 |
| 后台 | `src/utils/file-access*.ts`;`FileImage` 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址 |
| 后台编辑器 | 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史 |
| H5 和小程序 | `src/sheep/utils/file-access*.ts`、`filePath/parseHtml`、共享预览下载工具及各页面资源属性;公共算法不依赖 DOM/window;富文本图片样式保留 |
| Flutter | `FileAccess`、`AppNetworkTool`、`SignedNetworkImage`;主副屏独立初始化,缓存键用规范化原始地址,传输 URL 用签名 |
| Flutter APK 重试 | 原生下载收到 403 时重新查询相同 ID、版本码、平台和应用的已发布版本,最多重试一次,不能换成另一个版本继续安装 |
业务对象、表单、上传返回值、SQLite 人脸 `imagePath`、失败记录和 `referenceImageUrl` 继续保留原始引用。本次没有数据库迁移、SQLite 升级或清空人脸库。新业务展示统一调用文件工具,不要在 API 响应拦截器里递归改写业务字符串。
第三方 URL、静态资源、`data:`、`blob:`、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。
### Java 图片合成等内部读取
文件中心图片使用 `RemoteFileService.getFile(fileName)`,每次调用由专属 Feign 拦截器生成 `service` 签名,并通过服务发现访问 UPMS。业务字段如果保存的是完整或相对文件链接,先用 `FileAccessUrl.fileName(source)` 提取原始文件名:
```java
String fileName = FileAccessUrl.fileName(source);
if (fileName == null) throw new IllegalArgumentException("不是文件中心引用");
try (Response response = remoteFileService.getFile(fileName)) {
if (response.status() < 200 || response.status() >= 300 || response.body() == null) {
throw new IOException("文件读取失败");
}
try (InputStream input = response.body().asInputStream()) {
BufferedImage image = ImageIO.read(input);
// 校验 image 非空后合成。
}
}
```
提取工具支持原始文件名、`/sys-file/oss/file`、`/admin/sys-file/oss/file` 及带网关前缀的 `/admin/sys-file/oss/file` 链接;查询中的文件名只解码一次,重复、缺失或非法文件名拒绝处理。链接中的主机和旧签名不参与内部调用,始终读取当前环境的 UPMS,不会向原链接主机发送 `service` 凭据。
`WalletRechargeInvoiceServiceImpl.stamp` 已按此方式读取底图。因此 `http://127.0.0.1:9999/admin/sys-file/oss/file?...`、历史域名链接和相对链接都不再依赖 `IMAGE_BASE_URL` 或公网来源列表。非文件中心图片保留原有 HTTP 读取方式,相对静态资源仍使用 `IMAGE_BASE_URL`。调用方和 UPMS 必须加载相同的 `clients.service` 活动密钥;本次修复需要重新构建并发布 Wallet 及更新后的 common-core,无需为本机地址扩大 `allowed-origins`。
旧终端兼容只接受完整 `Dart/<版本> (dart:io)` UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。
## 发布和回滚
1. 在配置中心准备匹配的环境配置,保持 `mode: observe` 和 `legacy-dart.enabled: true`。发布 UPMS、integrated,以及包含文件调用的 wallet、hardware、canteen 等服务和统一公共依赖。只更新 UPMS 会遗漏内部读取。
2. 发布后台、H5、小程序,并提供携带生产配置的新 Flutter 包。小程序保留原文件域名,真机检查 request/downloadFile 域名配置、图片预览和打开文档。
3. 至少观察一个完整业务日,确认登录前 Logo/背景、人员照片、商品/菜品、富文本、附件下载、导出、票据和旧设备更新链路。旧设备必须实测图片 UA 和 Android 原生 APK 下载。
4. 通过验收后设置 `mode: enforce`。按当前配置中心机制刷新或重启实例,逐个确认配置已生效;旧网页需刷新,小程序需加载新版本。
5. 根据设备版本台账确认覆盖后再关 `legacy-dart.enabled`。没有自动失效日,短期没有旧请求不能证明设备已全部升级。
6. 遗漏影响业务时切回 `observe` 并保留诊断;这会重新允许普通未签名请求。无需数据库回滚,也无需四端同时回退。
代理/CDN 必须把文件路径送到后端校验,保留完整查询参数,不得忽略查询参数缓存文件、强制公共缓存或通过其他公开存储入口绕过该接口。校时接口也必须匿名可达。不要在访问日志中记录完整签名 URL、密钥或登录 Token;可按路径、来源类别和结果计数。
监控 `file.access.requests`(Prometheus 通常呈现为 `file_access_requests_total`),标签 `outcome/app/mode`。结果包括 `signed`、`missing_signature`、`malformed_signature`、`invalid_signature`、`expired`、`clock_skew`、`legacy_dart`、`unknown_client`、`unknown_key`、`configuration_error` 等。文件查询/存储失败另计 `file.access.read.failures`。拒绝/兼容日志按结果采样,计数器保留完整次数。
轮换时先在服务端增加新 kid,同时保留旧 kid,再更新客户端构建;服务端主动签发使用 active-kid。Flutter 的旧 kid 需等设备覆盖后才移除。不要用重新运行生成脚本代替轮换流程。
## 本地验证命令
三个前端分别执行:
```bash
node --test tests/file-access.test.cjs tests/file-upload.test.cjs
```
后台执行 `pnpm build:prod`,H5 执行 `pnpm build:h5`,小程序执行 `pnpm build:mp-weixin`。跨语言固定向量在各项目 `test-vectors/file-access.json` 和 `test-vectors/file-upload.json`,其中的密钥是公开测试数据,不能用于部署。配置生成器回归执行 `python3 -m unittest discover -s scripts/tests`。
Java 定向回归:
```bash
mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \
-am test \
-Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,FileAccessUrlTest,WalletRechargeInvoiceFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \
-Dsurefire.failIfNoSpecifiedTests=false
```
Flutter 使用项目 FVM 3.27.4:
```bash
fvm flutter test --no-pub --dart-define-from-file=.file-access/config.json \
test/file_access_test.dart test/file_upload_test.dart test/package_download_test.dart \
test/face_sync_plan_test.dart test/face_sync_pipeline_test.dart \
test/face_sync_failure_store_test.dart test/face_readiness_cache_test.dart \
test/customer_display_balance_test.dart test/customer_display_balance_widget_test.dart
fvm flutter build apk --release --no-pub --dart-define-from-file=.file-access/config.json
```
验收结果与待现场验证项见 `verification.md`。不要把本地构建成功或源码中的 Dart UA 推断视为旧安装包验收。
+45
View File
@@ -0,0 +1,45 @@
# Load through Nacos shared config or Spring additional-location. No usable keys are committed.
file-access:
mode: observe
block-browser-navigation: true
ttl-seconds: 1800
package-ttl-seconds: 86400
future-skew-seconds: 300
public-base-url: https://files.example.com/api
allowed-origins:
- https://legacy-files.example.com
# Optional: old H5 needs no update; only GET/HEAD image reads bypass signatures.
# Remove this list or set [] to keep all browser sources subject to signing.
public-image-referer-prefixes:
- https://www.pjzhhq.cn/mobile/
# Root origin allows every page on this exact scheme/host/port (trailing slash optional).
# Use only when cross-origin browser requests omit the Referer path; requires updated common-core/UPMS.
# - https://yyfb.idw.jinhua.gov.cn:6443/
legacy-dart:
enabled: true
extensions: [jpg, jpeg, png, gif, webp, bmp, svg, ico]
upload:
ttl-seconds: 300
max-file-bytes: 209715200 # 200 MiB; gateway/servlet limits may be lower
allowed-extensions: [jpg, jpeg, png, gif, webp, bmp, ico, svg, pdf, apk, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip, mp4, mov, webm, avi, mp3, wav, m4a, aac, ogg, amr, flac]
internal-extensions: [log, gz] # service-signed inner-upload only
clients:
admin:
active-kid: v1
keys: {v1: '${FILE_ACCESS_ADMIN_V1:}'}
h5:
# Optional. Remove the whole h5 node if no H5 signing key is configured.
active-kid: v1
keys: {v1: '${FILE_ACCESS_H5_V1:}'}
mini:
active-kid: v1
keys: {v1: '${FILE_ACCESS_MINI_V1:}'}
flutter:
active-kid: v1
keys: {v1: '${FILE_ACCESS_FLUTTER_V1:}'}
service:
active-kid: v1
keys: {v1: '${FILE_ACCESS_SERVICE_V1:}'}
package:
active-kid: v1
keys: {v1: '${FILE_ACCESS_PACKAGE_V1:}'}
+164
View File
@@ -0,0 +1,164 @@
# 五端文件签名验证记录
验证日期:2026-10-29。范围为本地五个项目的实现、定向回归、构建和已登录后台页面检查。未发布服务、修改 Nacos、开启生产拦截、更新真实设备、执行数据库迁移或提交 Git。
## 已完成
| 验证 | 结果 |
| --- | --- |
| Java 请求级与业务回归 | 41 项通过,0 失败;覆盖 UPMS 文件校验/读取、版本地址、钱包和硬件导出,同时编译相关公共模块及 canteen |
| 后台自动测试 | 14 项通过,包含文件协议与已有部门导出测试 |
| H5 文件自动测试 | 14 项通过 |
| 小程序文件自动测试 | 14 项通过 |
| 跨语言协议 | Java、三端 TypeScript、Dart 使用相同公开向量;校验 UTF-8 文件名、秒级时间戳、Base64URL 原文和 HMAC 结果 |
| URL 与运行时 | 覆盖中文、空格、加号、百分号编码、历史域名、相对/绝对地址、已有 `/api` 前缀、重复签名、第三方地址;前端测试执行实际运行时包装器,H5/小程序测试执行富文本预览组件的 JS 方法;Java 使用真实 SpringMvcContract/Feign 客户端与内存传输验证文件名字面 `%2F/%2B`、重复拦截和参数顺序 |
| 后台生产构建 | `pnpm build:prod` 通过 |
| H5 构建 | `pnpm build:h5` 通过 |
| 微信小程序构建 | `pnpm build:mp-weixin` 通过 |
| Flutter 定向回归 | 56 项通过;含签名、安装包一次重试、人脸同步计划/流水线/失败记录/缓存、主副屏余额回归 |
| Android Release | FVM Flutter 3.27.4 构建通过,使用 `.file-access/production.json`;APK 约 101.9 MB |
| 新增后台 TypeScript 工具 | 独立严格类型检查通过 |
| 配置生成器 | 在临时目录重复运行,确认幂等、保留已有文件;开发/测试/生产配置和各客户端密钥相互独立 |
| 密钥与原有变更 | 扫描五个项目全部已跟踪及未忽略候选文件,未发现生成密钥泄漏;后台原有补丁可反向校验,Java 原有补丁除追加忽略规则产生的上下文差异外可反向校验 |
| Diff 检查 | 保留原有 CRLF 文件格式;使用 `git -c core.whitespace=cr-at-eol diff --check` 通过 |
APK 位置(相对本仓库):`../pj_supermarket_cloud/build/app/outputs/flutter-apk/app-release.apk`。本次构建尚未在真实终端安装验证;发布前须核对包内环境与服务端配置一致。
Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。本机详细日志保存在 `/tmp/pj-file-access-work/`,属于临时验证资料,不随仓库发布。
## 静态检查与既有问题
使用任务开始前保存的补丁还原临时代码基线,与当前代码运行同一检查后比较:
| 项目 | 当前结果与基线比较 |
| --- | --- |
| 后台 `vue-tsc` | 6 条既有解析错误,均在 `src/views/admin/system/tenant/index.vue:83`;与基线完全一致 |
| H5 `vue-tsc` | 4 条既有解析错误,在 `src/pages/tabbar/~individualOrder.vue:78-79`;与基线完全一致 |
| 小程序 `vue-tsc` | 17 条既有 TS6504(JS Vue 来源与 allowJs 配置);规范化临时目录路径后与基线完全一致 |
| Flutter `analyze --no-pub` | 69 条提示/警告;忽略因新增代码产生的行号偏移后,诊断集合与基线完全一致,无新增项 |
因此不能把本次结果表述为“全项目类型检查通过”。H5/小程序构建仍有 TM UI 类型导出和循环依赖等构建提示;构建已成功,未在本次跨端文件改造中整理无关依赖。
## 已登录后台检查
使用用户提供的 `http://localhost:8888/` 已登录会话,检查文件管理页:
- 本系统有效图片的缩略图与放大预览加载成功,资源请求带 `appId/kid/ts/sign`,`fileName` 位于最后。
- 实测发现并修复前端 `/api` 前缀被重复保留的问题:当前开发图片服务器不带 `/api`,相对旧路径应转换为 `/admin/sys-file/oss/file`。配置含 `/api` 的部署仍保留一次前缀。
- 旧头像的一条原始文件链接返回 HTTP 200、内容长度为 0,属于当前运行后端/存储的既有异常。新 Java 读取代码已覆盖不存在和存储异常状态,但尚未发布,不能声称现场问题已修复。
- 当前后端匿名 `/admin/sys-file/oss/access-info` 返回 424 鉴权错误,说明运行环境尚未具备本次匿名校时接口;签名客户端会保留本地时间/最近偏移,不降级匿名读取。
- 点击素材下载后未出现页面错误,但浏览器下载事件未在等待窗口内返回。浏览器工具自动安全检查拒绝打开 `chrome://downloads/`(仅允许 HTTP/HTTPS),未采用其他方式绕过此限制,**下载文件实际落盘未确认**。
当前运行后端仍接受裸文件链接。上述浏览器结果证明前端签名与展示链路可用,不代表新后端严格校验已在线生效。
## 发布前仍需完成
1. 核对每个环境的公网基础地址、历史域名、密钥和活动 kid;Java 不会自动加载 `.file-access/*.yml`,须通过 Nacos 或 `SPRING_CONFIG_ADDITIONAL_LOCATION` 加载。先以 `observe` 发布 UPMS、integrated 和所有实际调用文件服务的模块。
2. 用发布后的后端确认匿名校时可达,执行 GET/HEAD、错误参数、错误/过期签名、旧 Dart 兼容、404/500、私有缓存及服务间读取联调。
3. 在真实旧 Flutter 安装包上确认图片请求 UA、图片显示及 Android 原生 APK 下载。新版包确认主副屏、缓存命中、人脸重复同步/离线恢复、取证、打印、手动/自动更新;不能用本地源码或单元测试替代设备验收。
4. 微信真机验证登录前 Logo/背景、长时间停留和恢复前台、图片预览、文件下载及打开文档;核对微信文件域名配置。后台与 H5 补充对应实际业务操作验收。
5. 至少观察一个完整业务日,确认已知入口无遗漏后再切 `enforce`;根据设备版本台账确认升级覆盖后再关闭旧 Dart 图片兼容。
当前默认保持 `observe` 与旧 Dart 图片兼容开启。回滚方法是恢复 `observe` 并保留诊断日志;业务引用和人脸记录未持久化临时签名,无需数据库回滚。
## 后台图片闪烁修复补充
2026-10-29 修复后台单张图片失败重试清空全局签名缓存的问题。重试只刷新当前文件,同一文件的并发重试合并;普通校时保留仍有效的签名。新增公共 `FileImage`,已加载图片在分钟检查、签名续期和恢复前台时保持当前画面;未加载的懒图片继续更新有效地址,放大预览在打开或切换图片时取有效签名。业务引用改变时正常加载新图。
- 后台定向回归 19 项通过,包含真实组件 setup、模板与 Vue 生命周期测试;模拟停留超过 30 分钟、恢复前台、失败重试、预览和懒加载。
- 新组件与文件工具的定向 `vue-tsc` 检查通过;后台生产构建通过。
- 已登录商户页实测:原先已加载的 24 张图片跨分钟检查后地址不变,其余慢图片随后加载完成,共 30 张图片成功、无加载占位;放大预览成功且请求带签名。
## 浏览器直接打开文件地址限制补充
2026-10-29 新增默认开启的 `block-browser-navigation`。严格模式下,携带浏览器页面导航 Fetch Metadata 的文件请求,即使签名有效也返回 403;页面图片、音视频、fetch/XHR 下载和无浏览器 Metadata 的原生/服务请求继续按原签名规则处理。成功响应 `Vary` 区分导航与子资源请求;拒绝响应 `no-store`。观察模式仅记录 `browser_navigation`。
- Java 文件访问/读取请求级测试共 12 项通过,覆盖签名正确的导航、GET/HEAD 在读取存储前拒绝、图片与下载放行、原生包下载、Dart UA 不绕过导航限制、开关和观察模式。
- 后台 20 项、H5 16 项、小程序 16 项定向测试通过。覆盖文件链接的页面内下载、H5 富文本与普通链接拦截、图片预览、本地文档打开及第三方链接保持原行为。
- 后台生产、H5、微信小程序构建通过;后台文件组件/工具定向类型检查通过。
- 配置生成器在临时目录验证:新配置含 `block-browser-navigation: true`,默认仍为 `observe`,没有修改实际运行环境配置。
这项补充尚未部署到文件服务器,不能把本地请求级测试表述为该公网地址已返回 403。上线时代理须保留 `Sec-Fetch-Mode/Dest` 和响应 `Vary`;用新签名或无旧缓存的窗口验证。后端无法收回浏览器已经取得的文件内容。旧浏览器可能不提供 Fetch Metadata,脚本也能模拟请求,因此这项规则不等于逐用户授权或防止已显示图片被保存。
## 最终调整:统一一套密钥与严格模式联调
用户明确要求所有环境使用同一套密钥,取代本文前面记录的开发/测试/生产独立密钥方案。沿用已配置的原生产密钥组,未生成新密钥;各 appId 仍使用各自固定密钥,service/package 密钥仍只存在服务端。三个前端统一使用 `.env.local`,Java 使用 `.file-access/config.yml`,Flutter 使用 `.file-access/config.json`。旧模式文件的签名字段已清理并备份,旧命令 `--environment` 不再切换密钥。
此前严格模式白图的真实原因是:浏览器按开发密钥签名,后端配置的是原生产密钥,实际响应为 `invalid_signature`。本次已通过本地 Nacos REST API 修正 `application-dev.yml`,保留 `enforce`、浏览器导航拦截和旧 Dart 兼容,公网基础地址对齐测试网关。Nacos 完整原配置保存在本机私有临时备份,发布时使用 MD5 比较避免覆盖并发编辑,生产服务配置未修改。
- 使用真实页面中的同一个签名 URL 联调:页面图片请求 200、应用下载请求 200、浏览器导航请求 403,原因为 `browser_navigation`。
- 实际后台重新加载后,首轮观察为 30 张图片中的 24 张加载成功,其余 6 张仍在慢请求中;随后浏览器自动化连接中断,未把该轮全部图片的最终状态作为已验证结果。签名已与统一密钥注册表一致。
- 真实 Vite `loadEnv` 验证三个项目共 18 个模式组合,全部使用统一密钥;生成器迁移、重复执行、原密钥保留与错误配置拒绝共 3 项测试通过。
- 后台生产、H5、微信小程序构建通过;Flutter 使用统一配置执行签名和安装包定向测试,8 项通过。
后续构建与配置准备以 README 中的统一路径为准。本文前面提到的分环境配置路径属于历史验证记录,不再作为当前操作指引。
## 上传身份与文件白名单补充
2026-10-29 在现有读取协议和同一套固定密钥上增加上传身份校验。普通、内部、七牛三个文件中心上传入口均接入,读取签名不能用于上传;上传签名绑定 POST、目标入口、appId、kid、时间和一次性 nonce。默认 5 分钟有效,严格模式通过 Redis 原子占用 nonce 防止重放;内部上传仅接受 service 身份,Dart UA 不作为上传凭据。
文件白名单覆盖图片、PDF、APK、Office、TXT/CSV、ZIP 和常用音视频;仅内部上传额外允许 LOG/GZ。服务层在存储前检查后缀、文件头及适用的容器结构、图片尺寸和静态 SVG 内容,并规范存储扩展名与 MIME。默认最大 200 MiB,SVG 最大 1 MiB,现有网关/Servlet 更低限制仍适用。观察模式只放宽签名准入,文件类型检查始终执行。格式识别不代表病毒或文档宏扫描。
| 验证 | 本次结果 |
| --- | --- |
| Java 上传测试 | 17 项通过,覆盖跨语言向量、入口隔离、重复头、过期、Dart UA 不放行、40 个并发请求只有一次 nonce 成功、Redis/错误配置失败关闭、实际 Feign multipart 编码、MockMvc 存储前拒绝及观察模式白名单 |
| 文件格式 | 覆盖常见图片、PDF、文本、Office 容器、ZIP、APK;拒绝改后缀/伪造 MIME、路径穿越、恶意 SVG、外部实体及过深 XML;最新构建的实际 Flutter Release APK 通过格式校验 |
| Java 相关回归 | 52 项通过,覆盖原文件读取、版本地址、日志导出、钱包导出、硬件导出;UPMS、wallet、hardware、integrated、canteen 及所需公共模块编译通过 |
| 后台 | 31 项通过,执行实际 Axios 拦截器、通用上传和富文本上传 setup;保留登录/租户头和 multipart 业务字段,失败文件不阻塞同批成功回填,签名不写进保存的富文本 |
| H5、小程序 | 各 21 项通过,执行实际 uni.uploadFile 拦截器;覆盖上传向量、每次请求新 nonce、校时及第三方/导入路径不签名 |
| Flutter | 签名、上传、安装包和兼容模式定向回归 21 项通过、2 项条件跳过;本次修改的文件工具、HTTP 管理器和上传测试定向 analyze 无诊断 |
| 构建 | 后台生产、H5、微信小程序及 Android Release 均通过;Flutter 使用 FVM 3.27.4 和 `.file-access/config.json`,APK 约 101.9 MB |
| 配置生成器 | 4 项通过,确认只有一套环境共用密钥、原密钥不变、默认上传策略补齐、已有定制白名单和大小限制不被覆盖 |
| TypeScript | 后台全量仍有原有 6 条解析错误,H5 仍有原有 4 条解析错误,小程序仍有原有 17 条 TS6504;后台上传相关组件及其依赖与 HEAD 基线比较均为 115 条既有诊断,无新增诊断,不能表述为全量类型检查通过 |
| 源码与密钥 | 五个项目 Git 候选文件未发现当前活动密钥,保留并发的日志导出、Flutter 调试兼容等改动;Diff 空白检查通过 |
日志保存在本机 `/tmp/pj-upload-security-work/`。本次上传改造没有发布到运行中的 Java 服务,也没有修改 Nacos 或更换密钥。发布后的真实 multipart 联调、微信真机和旧 Flutter 设备验证仍需执行;MockMvc、内存 Feign 和本地构建不能代替现场验收。
已有 `enforce` 环境先发布客户端及携带新公共依赖的 Java 调用服务,再发布 UPMS,避免仅更新 UPMS 后拒绝旧上传请求。当前旧 Flutter 取证/日志先经过硬件业务接口,再由 Java 调用文件中心,服务端补签后不要求这些旧设备立即升级。`observe` 会允许旧上传身份请求,切到 `enforce` 才真正拦截未签名上传;白名单在两种模式下均有效。发布配置与详细头协议见 README 的“上传身份与文件类型”。
## 普通位图后缀兼容补充
2026-10-29 复现商户 Logo 上传失败:用户提供的 `.png` 文件实际为 RIFF/VP8 WebP,800×440、17,436 字节,独立图片解码验证正常。旧规则按 PNG 文件头判断,因此返回 415。
现对声明为 JPG/JPEG/PNG/GIF/BMP/WebP 的文件先识别实际位图格式,检查实际格式也在白名单后再执行对应内容校验,保存时使用实际扩展名和 MIME。原图片字节不变;限制白名单仍生效,其他文件类别不通过这个规则自动转为图片。后台单图、多图和七牛图片上传默认加入 WebP,并让失败的 Promise 交给 Element Plus 统一处理,避免失败后继续触发成功提示。
- Java 32 项定向测试通过,含这张原图、公开合成 WebP、严格白名单、脚本/文档不自动转为图片、三个上传入口的 `.webp` 存储结果、原读取鉴权及安装包回归。
- 后台 30 项定向测试通过,含三个图片组件的 WebP 选择、错误提示及原始返回地址保存。
- 后台生产构建通过;上传相关组件及其依赖的 TypeScript 诊断由既有 115 条减为 114 条,去掉类型展开差异后无新增诊断。
- 实际图只通过本机可选测试参数读取,没有作为仓库测试素材提交。验证日志在 `/tmp/pj-upload-image-format-work/`。
后端识别逻辑需要发布或重启更新后的 UPMS 才会生效;本次未更改运行环境、密钥或 Nacos。
## 旧 mobile H5 图片免签来源补充
2026-10-29 按用户要求,仅修改后端,为不能更新的 `https://www.pjzhhq.cn/mobile/` H5 提供图片兼容。新增 `file-access.public-image-referer-prefixes`,默认空列表。请求 Referer 的协议、主机、有效端口和路径前缀全部匹配时,GET/HEAD 常见图片可免签,包括没有 appId 的旧请求;匹配来源携带的旧签名不再影响图片读取。Hash 路由不参与匹配。
- 配置整体省略 `clients.h5` 可通过新版本校验;配置了空 H5 密钥仍拒绝启动,其他五类必要客户端维持严格要求。仅移除 H5 密钥不会自动放行任意 H5 请求。
- 重复参数、非法文件名、浏览器页面导航仍先拒绝;其他来源、PDF/APK/日志/压缩包和上传入口不走该例外。
- 已覆盖域名后缀伪造、userinfo、错误协议/端口、相似路径、路径穿越、重复 Referer、开关关闭、GET/HEAD、私有缓存和 `Vary: Referer`。
- Java 跨模块 78 项回归通过,0 失败/跳过;配置生成器 4 项通过,确认默认不开启、已有前缀不被重写。
- 实际检查线上 `/mobile/` 返回 200,未发现禁用 Referer 的响应头或 HTML meta;这不代替后端部署后的真机验收。
精确前缀已加入本机被忽略的生成配置,其余配置和密钥保留。生产 Nacos 和线上服务未修改;需要部署新 UPMS/公共依赖并加载来源白名单,H5 无需重新发布。Referer 可被脚本模拟,此功能为应用级来源兼容。
## 政务 H5 根来源兼容补充
2026-10-29 实际检查政务 H5 的图片请求:页面位于 `https://yyfb.idw.jinhua.gov.cn:6443/web/mgop/gov-open/zj/2002546649/reserved/index.html#/`,但浏览器使用 `strict-origin-when-cross-origin`,发往文件中心的 Referer 仅为 `https://yyfb.idw.jinhua.gov.cn:6443/`。因此原目录前缀无法命中。
现有 `public-image-referer-prefixes` 同时支持目录前缀和显式根来源;根来源末尾 `/` 可省略,允许同协议、同主机、同有效端口下所有页面的图片请求。目录配置仍严格匹配目录,不会因 Referer 省略路径而自动扩大范围。仅允许 GET/HEAD 图片、阻止浏览器导航、上传鉴权、私有缓存和 Vary 规则保持不变。
- 执行 `mvn -pl pjcloud-upms/pjcloud-upms-biz -am test -Dtest=FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:23 项通过,0 失败、错误或跳过,Maven 构建成功。
- 新增测试覆盖实际根 Referer、完整页面 Referer、无末尾斜杠的配置、默认/自定义端口、域名伪造、错误协议、重复/缺失 Referer、目录范围不扩大、关闭白名单、导航及非图片拒绝、上传仍需鉴权、GET/HEAD 请求到存储的边界。
- 本机验证日志为 `/tmp/pj-file-access-origin-tests.log`。未修改线上配置或发布服务,线上恢复情况需在新版 UPMS 和对应公共依赖发布后确认。若根来源配置共享给其他启用文件配置的服务,也需先更新这些服务的公共依赖。
## 发票盖章内部读取修复
2026-10-30 反馈 `WalletRechargeInvoiceServiceImpl.stamp` 访问 `http://127.0.0.1:9999/admin/sys-file/oss/file?fileName=...` 返回 403。原实现使用 `FileAccessSigner.signUrl`,不在公网来源配置中的主机不会被签名,随后 URLConnection 发出的裸请求在严格模式被拒绝。
盖章现在使用 `FileAccessUrl.fileName` 解析文件引用,再由 `RemoteFileService.getFile` 通过服务发现读取 UPMS,并在请求时生成 `service` 签名。文件中心读取不再依赖 `IMAGE_BASE_URL` 或原链接的主机;文件访问入口及其鉴权规则未修改。
- 执行 `mvn -pl pjcloud-wallet/pjcloud-wallet-biz,pjcloud-upms/pjcloud-upms-biz -am test -Dtest=WalletRechargeInvoiceFileAccessTest,FileAccessUrlTest,FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:31 项通过,0 失败、错误或跳过,Maven 构建成功。
- 新回归覆盖本机网关、历史域名及网关前缀、相对路径、原始文件名、旧签名替换、中文/加号/百分号编码、重复与非法文件名、读取失败不上传、源流关闭、PNG 盖章合成,以及第三方图片请求保持原样。
- 本机验证日志为 `/tmp/pj-internal-image-access-tests.log`。测试中的 Feign 文件服务为替身,未访问真实注册中心或存储;未修改 Nacos、密钥或运行中的服务。
需重新构建并发布 Wallet,包含更新后的 common-core;确认 Wallet 与 UPMS 的 `clients.service` 密钥一致,再在实际环境重新生成发票验收。
+4 -1
View File
@@ -1,4 +1,7 @@
MYSQL_HOST=123.157.250.162 MYSQL_HOST=127.0.0.1
MYSQL_PORT=33905 MYSQL_PORT=33905
MYSQL_USER=root@sys MYSQL_USER=root@sys
MYSQL_PWD=bsd.1799 MYSQL_PWD=bsd.1799
NACOS_HOST=127.0.0.1
NACOS_PORT=8848
@@ -15,6 +15,7 @@ public enum AppMenuPlatformEnum {
WECHAT("10", "微信小程序"), WECHAT("10", "微信小程序"),
ZZD("30", "浙政钉"), ZZD("30", "浙政钉"),
SHPJ("40", "诗画浦江"),
; ;
private final String type; private final String type;
@@ -3,12 +3,10 @@ package com.bsd.pjcloud.app.controller;
import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.app.api.constant.menu.AppMenuPlatformEnum; import com.bsd.pjcloud.app.api.constant.menu.AppMenuPlatformEnum;
import com.bsd.pjcloud.app.api.constant.menu.AppMenuTypeEnum; import com.bsd.pjcloud.app.api.constant.menu.AppMenuTypeEnum;
import com.bsd.pjcloud.app.api.dto.AppMenuDto;
import com.bsd.pjcloud.app.api.entity.AppMenuEntity; import com.bsd.pjcloud.app.api.entity.AppMenuEntity;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.core.util.R;
@@ -23,6 +21,7 @@ import org.springframework.http.HttpHeaders;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -30,6 +29,7 @@ import java.util.List;
/** /**
* 菜单管理 * 菜单管理
*/ */
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/app-menu") @RequestMapping("/app-menu")
@@ -42,12 +42,14 @@ public class AppMenuController {
/* ************************************* start app start ************************************* */ /* ************************************* start app start ************************************* */
/** /**
* 列表差 * App 端菜单列表(显式接收 enum,与 Feign 一致,避免 ParameterObject 继承 Entity 绑参歧义)
*/ */
@Operation(summary = "分页查询", description = "分页查询") @Operation(summary = "App菜单列表", description = "按平台/类型查询可见菜单")
@GetMapping("/app/list") @GetMapping("/app/list")
public R<List<AppMenuEntity>> appList(@ParameterObject AppMenuDto dto) { public R<List<AppMenuEntity>> appList(
return listVisibleAppMenus(dto); @RequestParam(value = "typeEnum", required = false) AppMenuTypeEnum typeEnum,
@RequestParam(value = "platformEnum", required = false) AppMenuPlatformEnum platformEnum) {
return listVisibleAppMenus(typeEnum, platformEnum);
} }
/** /**
@@ -56,19 +58,32 @@ public class AppMenuController {
@Inner(value = true) @Inner(value = true)
@Operation(summary = "内部-App菜单列表", description = "供 integrated 等经 Feign 拉取,与 /app-menu/app/list 一致") @Operation(summary = "内部-App菜单列表", description = "供 integrated 等经 Feign 拉取,与 /app-menu/app/list 一致")
@GetMapping("/inner/app/list") @GetMapping("/inner/app/list")
public R<List<AppMenuEntity>> innerAppList(@ParameterObject AppMenuDto dto) { public R<List<AppMenuEntity>> innerAppList(
return listVisibleAppMenus(dto); @RequestParam(value = "typeEnum", required = false) AppMenuTypeEnum typeEnum,
@RequestParam(value = "platformEnum", required = false) AppMenuPlatformEnum platformEnum) {
return listVisibleAppMenus(typeEnum, platformEnum);
} }
private R<List<AppMenuEntity>> listVisibleAppMenus(AppMenuDto dto) { /**
dto.setVisible(YesNoEnum.YES.getCode()); * 仅按 platform/type/visible 查询,使用干净 Entity,避免 DTO 脏字段污染条件。
if (ObjectUtil.isNotEmpty(dto.getPlatformEnum())) { */
dto.setPlatform(dto.getPlatformEnum().getType()); private R<List<AppMenuEntity>> listVisibleAppMenus(AppMenuTypeEnum typeEnum, AppMenuPlatformEnum platformEnum) {
AppMenuEntity query = new AppMenuEntity();
query.setVisible(YesNoEnum.YES.getCode());
if (ObjectUtil.isNotEmpty(platformEnum)) {
query.setPlatform(platformEnum.getType());
} }
if (ObjectUtil.isNotEmpty(dto.getTypeEnum())) { if (ObjectUtil.isNotEmpty(typeEnum)) {
dto.setType(dto.getTypeEnum().getType()); query.setType(typeEnum.getType());
} }
return R.ok(appMenuService.scopeList(dto, null)); List<AppMenuEntity> list = appMenuService.scopeList(query, null);
log.info("[app-menu/list] platformEnum={} platform={} typeEnum={} type={} size={}",
platformEnum,
query.getPlatform(),
typeEnum,
query.getType(),
list == null ? 0 : list.size());
return R.ok(list);
} }
/* ************************************* end app end ************************************* */ /* ************************************* end app end ************************************* */
@@ -115,6 +115,7 @@ public class AppUserController {
*/ */
@Operation(summary = "分页查询", description = "分页查询") @Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page") @GetMapping("/page")
@HasPermission("app_appuser_page")
public R getAppUserPage(Page page, AppUserDTO appUserDTO) { public R getAppUserPage(Page page, AppUserDTO appUserDTO) {
return R.ok(appUserService.getUsersWithRolePage(page, appUserDTO)); return R.ok(appUserService.getUsersWithRolePage(page, appUserDTO));
} }
@@ -19,7 +19,8 @@ public interface AppMenuMapper extends PjcloudBaseMapper<AppMenuEntity> {
.eq(ObjectUtil.isNotEmpty(dto.getPlatform()), AppMenuEntity::getPlatform, dto.getPlatform()) .eq(ObjectUtil.isNotEmpty(dto.getPlatform()), AppMenuEntity::getPlatform, dto.getPlatform())
.eq(ObjectUtil.isNotEmpty(dto.getType()), AppMenuEntity::getType, dto.getType()) .eq(ObjectUtil.isNotEmpty(dto.getType()), AppMenuEntity::getType, dto.getType())
.eq(ObjectUtil.isNotEmpty(dto.getVisible()), AppMenuEntity::getVisible, dto.getVisible()) .eq(ObjectUtil.isNotEmpty(dto.getVisible()), AppMenuEntity::getVisible, dto.getVisible())
.orderByAsc(AppMenuEntity::getSort) // 后台文案:排序越大越靠前
.orderByDesc(AppMenuEntity::getSort)
.orderByDesc(AppMenuEntity::getCreateTime); .orderByDesc(AppMenuEntity::getCreateTime);
return wrapper; return wrapper;
} }
@@ -6,10 +6,10 @@ spring:
name: @artifactId@ name: @artifactId@
cloud: cloud:
nacos: nacos:
username: @nacos.username@ username: nacos
password: @nacos.password@ password: Aa@bsd.321
discovery: discovery:
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848} server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
config: config:
server-addr: ${spring.cloud.nacos.discovery.server-addr} server-addr: ${spring.cloud.nacos.discovery.server-addr}
config: config:
@@ -23,7 +23,7 @@ mini-ali-pay:
public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk2B2NAFtnI2x6Dopw/H+AijCmrA5m0cOAhDGrBuSM3JJnrk6rrdxpscUUZbOts5V0VTgg2IJxocxIQ/5zGMdEzAV9pmKPnx/V1DLWTCmPqdqz0105T5xn20OHnVGKH0dLatboPsBzg+f48bsDtcz3we7fHV94DWuDON30zytpjw3qP1IkQeOQz41Fr2zF+fZbWGDIJgFtEwG15kQBihca5l9x3XSStI0sBKU3B2kVdwuha2FePL1hxJYr4lQVLC0SKcL909xWGZtGtfS3BkLcm3k4V2mgmK4+y8KH35PL/Ay9oNvCAHohbY45FmzxSijArEnvy1ZL3UE7oZ3Lg2skwIDAQAB public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk2B2NAFtnI2x6Dopw/H+AijCmrA5m0cOAhDGrBuSM3JJnrk6rrdxpscUUZbOts5V0VTgg2IJxocxIQ/5zGMdEzAV9pmKPnx/V1DLWTCmPqdqz0105T5xn20OHnVGKH0dLatboPsBzg+f48bsDtcz3we7fHV94DWuDON30zytpjw3qP1IkQeOQz41Fr2zF+fZbWGDIJgFtEwG15kQBihca5l9x3XSStI0sBKU3B2kVdwuha2FePL1hxJYr4lQVLC0SKcL909xWGZtGtfS3BkLcm3k4V2mgmK4+y8KH35PL/Ay9oNvCAHohbY45FmzxSijArEnvy1ZL3UE7oZ3Lg2skwIDAQAB
alipay-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqnCB+N172m5gxf1Rolfx5LErSiVwQCh0XQdDrVy6aTuVF5EtdqahSHtADyEPsBWudMBhzCfqpoZvfnvH7a+PED6uZ3DoXt7ETh8TmNM5yc+WTnD8pSphQvuHDJ3zMks489KNxiX6sXag71k9H1MXklJGPOUaljHqy/QlRn2ZLT7pFgaFH9LEcVoQBPIOurUn95078h848RFD30jZFW3fakxUGAGVVZaHH62z5OEWOmcA+CwxsEDMa1XMt02RGXgAAq4WOj+BqOGiFmhM8rnrUMchTyvx4umOzGvbF2Y9irN3aBZ5lfg+bzYcJpIOMzlM/V00LnEWzCblJzXNyH4hJQIDAQAB alipay-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqnCB+N172m5gxf1Rolfx5LErSiVwQCh0XQdDrVy6aTuVF5EtdqahSHtADyEPsBWudMBhzCfqpoZvfnvH7a+PED6uZ3DoXt7ETh8TmNM5yc+WTnD8pSphQvuHDJ3zMks489KNxiX6sXag71k9H1MXklJGPOUaljHqy/QlRn2ZLT7pFgaFH9LEcVoQBPIOurUn95078h848RFD30jZFW3fakxUGAGVVZaHH62z5OEWOmcA+CwxsEDMa1XMt02RGXgAAq4WOj+BqOGiFmhM8rnrUMchTyvx4umOzGvbF2Y9irN3aBZ5lfg+bzYcJpIOMzlM/V00LnEWzCblJzXNyH4hJQIDAQAB
callback-url: https://rj4jv5rwqs.by.takin.cc/hard/hard-ali-pay/callback callback-url: https://rj4jv5rwqs.by.takin.cc/hard/hard-ali-pay/callback
# callback-url: https://stgl.pj.gov.cn/api/hard/hard-ali-pay/callback # callback-url: https://www.pjzhhq.cn/api/hard/hard-ali-pay/callback
mini-wechat-pay: mini-wechat-pay:
app-id: wx95481f43b14565e5 app-id: wx95481f43b14565e5
@@ -6,10 +6,10 @@ spring:
name: @artifactId@ name: @artifactId@
cloud: cloud:
nacos: nacos:
username: @nacos.username@ username: nacos
password: @nacos.password@ password: Aa@bsd.321
discovery: discovery:
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848} server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
config: config:
server-addr: ${spring.cloud.nacos.discovery.server-addr} server-addr: ${spring.cloud.nacos.discovery.server-addr}
config: config:
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
/**
* 收银终端按日期范围查询每日菜谱的请求参数。
*/
@Data
@Schema(description = "收银终端每日菜谱日期范围查询参数")
public class DailyMenuDateRangeQueryDTO {
/** 菜谱起始日期,查询闭区间包含当天。 */
@NotNull(message = "菜谱开始日期不能为空")
@Schema(description = "菜谱起始日期,格式为 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate startDate;
/** 菜谱结束日期,查询闭区间包含当天。 */
@NotNull(message = "菜谱结束日期不能为空")
@Schema(description = "菜谱结束日期,格式为 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate endDate;
/** 餐段协议编码;为空时查询全部餐段。 */
@Schema(description = "餐段编码;为空表示全部餐段")
private Integer hourType;
/** 菜品分类 ID;为空时查询全部分类。 */
@Schema(description = "菜品分类 ID;为空表示全部分类")
private Long categoryId;
/** 收银终端所属商户 ID,用于隔离不同商户的每日菜谱。 */
@NotNull(message = "每日菜谱商户不能为空")
@Schema(description = "收银终端所属商户 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long merchantId;
}
@@ -3,6 +3,8 @@ package com.bsd.pjcloud.canteen.vo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.time.LocalDate;
@Data @Data
public class ADCanteenDailyMenuListVO { public class ADCanteenDailyMenuListVO {
/** /**
@@ -10,6 +12,11 @@ public class ADCanteenDailyMenuListVO {
*/ */
private Long id; private Long id;
/**
* 菜谱供应日期,yyyy-MM-dd;收银终端多日同步时以此归属到具体日期
*/
private LocalDate saleDate;
/** /**
* spuid * spuid
*/ */
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.canteen.vo.app;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "套餐组成项(App)")
public class OnlineSpuComboItemVO {
@Schema(description = "组成菜品 SPU id")
private Long childSpuId;
@Schema(description = "组成菜品名称")
private String name;
@Schema(description = "组成菜品封面")
private String picUrl;
@Schema(description = "份数")
private Integer quantity;
@Schema(description = "排序")
private Integer sortOrder;
@Schema(description = "组成菜品单价(分,仅展示)")
private Integer price;
}
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.DailyMenuDateRangeQueryDTO;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.service.CanteenSpuService; import com.bsd.pjcloud.canteen.service.CanteenSpuService;
@@ -198,6 +199,20 @@ public class CanteenDailyMenuController {
return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId,merchantId)); return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId,merchantId));
} }
/**
* 按闭区间(含起止两天)查询收银终端多日菜谱,字段与单日接口一致,
* 每条记录额外携带供应日期 saleDate,便于终端按日期落库。
*
* @param request 日期范围、可选筛选条件和收银终端所属商户
* @return 指定日期范围内的每日菜谱列表;无数据时返回空列表
*/
@PostMapping("/ad/getCanteenListByDateRange")
@SysLog("按日期范围查询每日菜品列表")
public R<List<ADCanteenDailyMenuListVO>> getCanteenListByDateRange(
@Validated @RequestBody DailyMenuDateRangeQueryDTO request) {
return R.ok(canteenDailyMenuService.getCanteenMenuListByDateRange(request));
}
// --------------APP--------------- // --------------APP---------------
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.canteen.mapper; package com.bsd.pjcloud.canteen.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.DailyMenuDateRangeQueryDTO;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO; import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
@@ -31,6 +32,16 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
List<ADCanteenDailyMenuListVO> getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType, List<ADCanteenDailyMenuListVO> getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType,
@Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId,DataScope dataScope); @Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId,DataScope dataScope);
/**
* 按闭区间查询收银终端多日菜谱展示数据,字段与单日查询保持一致。
*
* @param request 日期范围、可选筛选条件和商户 ID
* @param dataScope 当前数据权限范围
* @return 按日期、餐段和商品排序的菜谱列表
*/
List<ADCanteenDailyMenuListVO> getCanteenMenuListByDateRange(@Param("request") DailyMenuDateRangeQueryDTO request,
DataScope dataScope);
Page<DailyMenuAppVO> selectTimeFoodPage(@Param("page")Page page, DailyMenuDTO dailyMenuDTO); Page<DailyMenuAppVO> selectTimeFoodPage(@Param("page")Page page, DailyMenuDTO dailyMenuDTO);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.DailyMenuDateRangeQueryDTO;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
@@ -51,6 +52,15 @@ public interface CanteenDailyMenuService extends IService<CanteenDailyMenuEntity
*/ */
List<ADCanteenDailyMenuListVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId); List<ADCanteenDailyMenuListVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId);
/**
* 按闭区间查询收银终端多日菜谱,返回字段与单日查询保持一致,
* 每条记录额外携带供应日期,供终端按日期分桶落库。
*
* @param request 日期范围、可选筛选条件和收银终端所属商户
* @return 按日期、餐段和商品排序的菜谱列表
*/
List<ADCanteenDailyMenuListVO> getCanteenMenuListByDateRange(DailyMenuDateRangeQueryDTO request);
Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO); Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO);
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.DailyMenuDateRangeQueryDTO;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper; import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
@@ -142,6 +143,26 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
return baseMapper.getCanteenMenuList(day, hourType, categoryId,merchantId, DataScope.of()); return baseMapper.getCanteenMenuList(day, hourType, categoryId,merchantId, DataScope.of());
} }
/**
* 按闭区间查询收银终端多日菜谱。
*
* @param request 日期范围、可选筛选条件和收银终端所属商户
* @return 按日期、餐段和商品排序升序排列的菜谱列表
*/
@Override
public List<ADCanteenDailyMenuListVO> getCanteenMenuListByDateRange(DailyMenuDateRangeQueryDTO request) {
if (request == null || request.getStartDate() == null || request.getEndDate() == null) {
throw new IllegalArgumentException("菜谱开始日期与结束日期不能为空");
}
if (request.getMerchantId() == null) {
throw new IllegalArgumentException("每日菜谱商户不能为空");
}
if (request.getEndDate().isBefore(request.getStartDate())) {
throw new IllegalArgumentException("菜谱结束日期不能早于开始日期");
}
return baseMapper.getCanteenMenuListByDateRange(request, DataScope.of());
}
@Override @Override
public Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO) { public Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO) {
Page<DailyMenuAppVO> pageData = baseMapper.selectTimeFoodPage(page, dailyMenuDTO); Page<DailyMenuAppVO> pageData = baseMapper.selectTimeFoodPage(page, dailyMenuDTO);
@@ -6,10 +6,10 @@ spring:
name: @artifactId@ name: @artifactId@
cloud: cloud:
nacos: nacos:
username: @nacos.username@ username: nacos
password: @nacos.password@ password: Aa@bsd.321
discovery: discovery:
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848} server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
config: config:
server-addr: ${spring.cloud.nacos.discovery.server-addr} server-addr: ${spring.cloud.nacos.discovery.server-addr}
config: config:
@@ -19,6 +19,7 @@
<resultMap id="adCanteenDailyMenuListVO" type="com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO"> <resultMap id="adCanteenDailyMenuListVO" type="com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO">
<id property="id" column="id"/> <id property="id" column="id"/>
<result property="saleDate" column="start_time"/>
<result property="name" column="name"/> <result property="name" column="name"/>
<result property="spuId" column="spu_id"/> <result property="spuId" column="spu_id"/>
<result property="hourType" column="hour_type"/> <result property="hourType" column="hour_type"/>
@@ -30,33 +31,38 @@
<result property="hourType" column="hour_type"/> <result property="hourType" column="hour_type"/>
<result property="isPriceIncreased" column="is_price_increased"/> <result property="isPriceIncreased" column="is_price_increased"/>
</resultMap> </resultMap>
<select id="selectTimeFood" resultType="com.bsd.pjcloud.canteen.vo.DailyMenuVO"> <select id="selectTimeFood"
SELECT resultType="com.bsd.pjcloud.canteen.vo.DailyMenuVO">
DISTINCT(sp.id), SELECT DISTINCT
t.id as tid, sp.id,
t.id AS tid,
sp.name, sp.name,
sp.pic_url, sp.pic_url,
t.hour_type, t.hour_type,
sp.price, sp.price,
sp.category_id sp.category_id,
sp.sort
FROM canteen_daily_menu t FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp LEFT JOIN canteen_spu sp
on t.spu_id=sp.id ON t.spu_id = sp.id
<where> <where>
AND t.merchant_id = #{merchantId} AND t.merchant_id = #{merchantId}
<if test="categoryId != null"> <if test="categoryId != null">
AND sp.category_id = #{categoryId} AND sp.category_id = #{categoryId}
</if> </if>
AND sp.del_flag = 0 AND sp.del_flag = 0
AND t.start_time = #{time} AND t.start_time = #{time}
And t.del_flag = 0 AND t.del_flag = 0
</where> </where>
order by sp.sort asc ORDER BY sp.sort ASC
</select> </select>
<select id="getCanteenMenuList" resultMap="adCanteenDailyMenuListVO"> <select id="getCanteenMenuList" resultMap="adCanteenDailyMenuListVO">
SELECT SELECT
sp.id as spu_id, sp.id as spu_id,
t.id, t.id,
t.start_time,
sp.name, sp.name,
sp.pic_url, sp.pic_url,
t.hour_type, t.hour_type,
@@ -86,6 +92,38 @@
</if> </if>
</where> </where>
</select> </select>
<select id="getCanteenMenuListByDateRange" resultMap="adCanteenDailyMenuListVO">
SELECT
sp.id as spu_id,
t.id,
t.start_time,
sp.name,
sp.pic_url,
t.hour_type,
sp.price,
sp.stock,
sp.category_id,
sp.sort,
sp.is_price_increased,
<!-- 数据权限拦截器会在当前查询外层按部门和创建人过滤,必须在临时结果集中透传这两个字段。 -->
t.dept_id,
t.create_by
FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp
on t.spu_id=sp.id
<where>
sp.del_flag = 0 and t.del_flag = 0
AND t.start_time BETWEEN #{request.startDate} AND #{request.endDate}
<if test="request.categoryId != null">
AND sp.category_id = #{request.categoryId}
</if>
<if test="request.hourType != null">
AND t.hour_type = #{request.hourType}
</if>
AND t.merchant_id = #{request.merchantId}
</where>
order by t.start_time asc, t.hour_type asc, sp.sort asc
</select>
<select id="selectTimeFoodPage" resultType="com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO" <select id="selectTimeFoodPage" resultType="com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO"
parameterType="com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO"> parameterType="com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO">
SELECT m.id, SELECT m.id,
@@ -0,0 +1,79 @@
# 通用异步任务模块
`pjcloud-common-async-task` 提供可由各业务模块复用的异步事务执行、Redis 进度保存和统一进度查询接口。
## 接入方式
业务模块增加依赖后,Spring Boot 会自动装配 `AsyncTaskExecutorService`、`AsyncTaskProgressService` 和查询接口:
```xml
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
```
提交任务时由业务方指定稳定的 Redis 业务命名空间、展示名称和总数。接口同步返回初始进度,业务动作进入公共模块的受控专用线程池并在单个事务中执行:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(
"user:import:teacher", "老师用户导入", users.size(),
reporter -> users.forEach(user -> {
importUser(user);
reporter.advance("正在导入:" + user.getName());
}));
```
导出等需要在成功终态返回结果文件的任务使用兼容扩展入口,原有无结果任务的 `submit` 契约保持不变:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 3, reporter -> {
String fileUrl = exportAndUpload(reporter);
return AsyncTaskResult.file(fileUrl, "用户信息.xlsx");
});
```
查询数据本身耗时且提交接口需要立即返回时,可以先用大于零的占位总数创建任务,再在异步线程查询完成后更新真实总数:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 1, reporter -> {
List<UserVO> users = queryUsers();
reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户");
return exportUsers(users, reporter);
});
```
调用端统一轮询:
```text
GET /async-task/progress?redisKey={业务命名空间}&taskId={任务ID}
```
## Redis 租户 Key 约定
项目 Redis 基础设施会根据当前租户上下文自动为 Redis Key 增加租户前缀。业务代码只负责定义业务命名空间和业务唯一标识,**不需要也不得再次手工拼接 `tenantId`**。
正确的业务 Key:
```text
async:task:progress:user:import:teacher:{userId}:{taskId}
```
错误的业务 Key:
```text
async:task:progress:user:import:teacher:{tenantId}:{userId}:{taskId}
```
错误示例会导致租户维度重复出现在最终 Redis Key 中,并可能造成同一业务在不同调用方式下无法读取彼此写入的数据。
“业务 Key 不拼接租户 ID”不代表异步线程不需要租户上下文。异步任务在访问 Redis、数据库或其他租户资源前,仍需恢复 `TenantContextHolder`,并在任务结束后清理上下文。本模块执行器已经统一处理这一步,业务动作不应重复设置租户上下文。
## 事务与进度语义
- 整个业务动作由一个 Spring 事务包裹,业务抛出异常时整体回滚。
- `completed` 表示当前执行位置;失败并回滚时不代表最终成功写入数量。
- Redis 进度默认保留 24 小时,供页面刷新或短时重连后继续查询。
- 任务进度按当前登录用户隔离,调用端不能查询其他用户创建的任务。
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common</artifactId>
<version>5.7.0</version>
</parent>
<artifactId>pjcloud-common-async-task</artifactId>
<packaging>jar</packaging>
<description>pjcloud 通用异步任务与 Redis 进度管理</description>
<dependencies>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-excel</artifactId>
</dependency>
<!--统一响应、线程池及基础类型-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId>
</dependency>
<!--Redis 模板与异步线程所需的租户上下文-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-data</artifactId>
</dependency>
<!--登录用户、安全上下文及非 Web Feign Token 传递-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-security</artifactId>
</dependency>
<!--通用异步任务事务边界-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<!--通用进度查询接口-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskProgressService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 通用异步任务自动配置。
* <p>业务模块引入本公共模块后,在 Redis 和事务模板均可用时自动装配专用线程池、进度服务与事务执行器。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(name = {
"com.bsd.pjcloud.common.data.cache.RedisTemplateConfiguration",
"org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration"
})
@ConditionalOnClass({RedisTemplate.class, TransactionTemplate.class})
@ConditionalOnBean({RedisTemplate.class, TransactionTemplate.class})
public class AsyncTaskAutoConfiguration {
/** 公共异步任务专用工作线程池的 Bean 名称,业务模块可按该名称替换默认线程池。 */
public static final String ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME = "commonAsyncTaskWorkerExecutor";
/** 默认核心线程数,兼顾常规导入导出并发与业务服务资源占用。 */
private static final int DEFAULT_CORE_POOL_SIZE = 2;
/** 默认最大线程数,限制大批量异步任务同时占用数据库连接和远程调用资源。 */
private static final int DEFAULT_MAX_POOL_SIZE = 8;
/** 默认等待队列容量,超过线程与队列总容量后由执行器拒绝并把任务标记为提交失败。 */
private static final int DEFAULT_QUEUE_CAPACITY = 200;
/** 服务关闭时等待正在执行任务结束的最长秒数,超过后由容器继续关闭流程。 */
private static final int DEFAULT_SHUTDOWN_AWAIT_SECONDS = 30;
/** 公共异步任务线程名称前缀,便于线程转储和日志定位。 */
private static final String DEFAULT_THREAD_NAME_PREFIX = "common-async-task-";
/**
* 装配公共异步任务专用线程池,避免 Spring Boot 默认线程池因其他业务 Executor 存在而不创建。
*
* @return 已完成容量与优雅停机配置的工作线程池
*/
@Bean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
@ConditionalOnMissingBean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
public TaskExecutor commonAsyncTaskWorkerExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(DEFAULT_CORE_POOL_SIZE);
executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE);
executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY);
executor.setThreadNamePrefix(DEFAULT_THREAD_NAME_PREFIX);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(DEFAULT_SHUTDOWN_AWAIT_SECONDS);
return executor;
}
/**
* 装配默认 Redis 进度服务,业务模块可通过声明同类型 Bean 完整替换默认实现。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
* @return 通用异步任务 Redis 进度服务
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressService asyncTaskProgressService(RedisTemplate<String, Object> redisTemplate) {
return new DefaultAsyncTaskProgressService(redisTemplate);
}
/**
* 装配默认异步事务执行器,显式使用公共异步任务专用线程池以隔离其他业务线程池。
*
* @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
* @param asyncTaskProgressService Redis 任务进度服务
* @param transactionTemplate Spring 编程式事务模板
* @return 通用异步事务任务执行器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskExecutorService asyncTaskExecutorService(
@Qualifier(ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME) TaskExecutor asyncTaskWorkerExecutor,
AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
return new DefaultAsyncTaskExecutorService(asyncTaskWorkerExecutor, asyncTaskProgressService, transactionTemplate);
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.controller.AsyncTaskProgressController;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
/**
* 通用异步任务 Servlet Web 接口自动配置。
* <p>仅在 Web 应用已装配 Redis 进度服务时注册统一查询接口,非 Web 任务消费者不会引入无用端点。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(AsyncTaskAutoConfiguration.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnBean(AsyncTaskProgressService.class)
public class AsyncTaskWebAutoConfiguration {
/**
* 装配统一进度查询接口,所有业务模块共享 {@code /async-task/progress} 契约。
*
* @param asyncTaskProgressService Redis 任务进度服务
* @return 通用异步任务进度控制器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressController asyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
return new AsyncTaskProgressController(asyncTaskProgressService);
}
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.constant;
import lombok.Getter;
/**
* 通用异步任务进度状态协议。
* <p>状态编码会写入 Redis 并返回调用端,已有编码不得随意变更。</p>
*/
@Getter
public enum AsyncTaskStatusEnum {
/** 等待后台线程开始执行,Redis 与接口编码为 {@code PENDING}。 */
PENDING("PENDING", "等待执行"),
/** 后台线程正在处理任务,Redis 与接口编码为 {@code RUNNING}。 */
RUNNING("RUNNING", "执行中"),
/** 任务已完整执行成功,Redis 与接口编码为 {@code SUCCESS}。 */
SUCCESS("SUCCESS", "执行成功"),
/** 任务执行异常,Redis 与接口编码为 {@code FAILED}。 */
FAILED("FAILED", "执行失败");
/** Redis 与调用端接口共同使用的固定状态编码。 */
private final String status;
/** 当前状态未提供自定义消息时使用的中文说明。 */
private final String description;
/**
* 创建异步任务状态定义。
*
* @param status Redis 与调用端接口共同使用的固定状态编码
* @param description 当前状态的默认中文说明
*/
AsyncTaskStatusEnum(String status, String description) {
this.status = status;
this.description = description;
}
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.common.asynctask.controller;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 通用异步任务进度查询接口。
* <p>任务业务 Key 按登录用户隔离,租户 ID 显式写入服务端任务键,只能查询本人创建且尚未过期的任务。</p>
*/
@RestController
@RequestMapping("/async-task")
@Tag(description = "async-task", name = "通用异步任务进度")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class AsyncTaskProgressController {
/** 通用 Redis 任务进度服务。 */
private final AsyncTaskProgressService asyncTaskProgressService;
/**
* 创建异步任务进度查询接口。
*
* @param asyncTaskProgressService 通用 Redis 任务进度服务
*/
public AsyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
this.asyncTaskProgressService = asyncTaskProgressService;
}
/**
* 查询当前用户的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 上传或任务提交接口返回的不透明任务 ID
* @return 当前任务进度;任务不存在或已过期时返回失败响应
*/
@GetMapping("/progress")
@Operation(summary = "查询异步任务进度")
public R<AsyncTaskProgressVO> progress(@RequestParam String redisKey, @RequestParam String taskId) {
AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId);
return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress);
}
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务业务动作。
* <p>动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskAction {
/**
* 执行具体异步业务。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
*/
void execute(AsyncTaskProgressReporter reporter);
}
@@ -0,0 +1,7 @@
package com.bsd.pjcloud.common.asynctask.service;
/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */
public class AsyncTaskException extends RuntimeException {
/** 使用业务可读提示终止任务,不暴露底层异常。 */
public AsyncTaskException(String message) { super(message); }
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步事务任务提交服务。
* <p>同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。</p>
*/
public interface AsyncTaskExecutorService {
/**
* 创建并提交一个异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行的业务动作
* @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
*/
AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action);
/**
* 创建并提交一个可在成功终态携带结果文件的异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行并返回结果文件信息的业务动作
* @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
*/
default AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
throw new UnsupportedOperationException("当前异步任务执行器不支持返回任务结果");
}
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 异步任务逐项进度报告器。
* <p>业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。</p>
*/
@FunctionalInterface
public interface AsyncTaskProgressReporter {
/**
* 在后台任务取得真实数据量后更新总项目数。
* <p>适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。</p>
*
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明;为空时使用通用说明
*/
default void updateTotal(int total, String message) {
throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数");
}
/**
* 报告一个项目已处理完成。
*
* @param message 当前项目完成后的进度说明;为空时使用通用说明
*/
void advance(String message);
}
@@ -0,0 +1,91 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步任务 Redis 进度服务。
* <p>业务 Key 按当前用户隔离,租户 ID 显式写入服务端任务键;调用方只提供业务命名空间和任务 ID。</p>
*/
public interface AsyncTaskProgressService {
/**
* 创建等待执行的异步任务并写入 Redis。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数,必须大于零
* @return 已写入 Redis 的初始任务进度
*/
AsyncTaskProgressVO create(String redisKey, String taskName, int total);
/**
* 将任务标记为执行中。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
*/
void start(String redisKey, String taskId);
/**
* 在任务开始后更新真实总项目数并重新计算完成百分比。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
default void updateTotal(String redisKey, String taskId, int total, String message) {
throw new UnsupportedOperationException("当前异步任务进度服务不支持动态更新总数");
}
/**
* 更新任务执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param completed 当前已处理项目数
* @param message 当前进度说明
*/
void update(String redisKey, String taskId, int completed, String message);
/**
* 将任务标记为成功并把进度置为 100%。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
*/
void success(String redisKey, String taskId, String message);
/**
* 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。
* <p>默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改;
* 需要向调用端返回附件的实现应覆写本方法。</p>
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
* @param result 业务动作返回的可选结果文件信息
*/
default void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
success(redisKey, taskId, message);
}
/**
* 将任务标记为失败并保留失败前执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 对调用端安全的失败说明
*/
void fail(String redisKey, String taskId, String message);
/**
* 查询当前用户创建的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
*/
AsyncTaskProgressVO get(String redisKey, String taskId);
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务成功结果。
* <p>导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。</p>
*
* @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
* @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
*/
public record AsyncTaskResult(String fileUrl, String fileName) {
/**
* 创建通用异步任务成功结果。
*
* @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
* @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
*/
public AsyncTaskResult {
}
/**
* 创建不携带结果文件的成功结果。
*
* @return 无文件地址和名称的任务结果
*/
public static AsyncTaskResult empty() {
return new AsyncTaskResult(null, null);
}
/**
* 创建携带下载文件的成功结果。
*
* @param fileUrl 项目现有文件中心返回的文件下载地址
* @param fileName 下载时使用的业务文件名称
* @return 包含结果文件信息的任务结果
*/
public static AsyncTaskResult file(String fileUrl, String fileName) {
return new AsyncTaskResult(fileUrl, fileName);
}
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 携带成功结果的通用异步任务业务动作。
* <p>用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskResultAction {
/**
* 执行具体异步业务并返回成功结果。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
* @return 任务成功终态需要返回调用端的可选结果文件信息
*/
AsyncTaskResult execute(AsyncTaskProgressReporter reporter) throws Exception;
}
@@ -0,0 +1,99 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.excel.stream.TemplateBatchWorkbook;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.core.io.ClassPathResource;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
/** 导出业务共用的分页、进度和完整文件响应适配,不改变业务字段转换。 */
public final class BatchExportSupport {
/** 一个查询批次的最大明细数,与模板流式行窗口一致。 */
public static final int BATCH_SIZE = 200;
/** XLSX 的附件响应类型。 */
public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private BatchExportSupport() { }
/** 按已有模板分页生成;仅在文件关闭后交给消费者上传或返回同步响应。 */
public static <T, R> R template(String template, Map<String, Object> values,
Function<Long, Page<T>> fetcher, Function<T, Map<String, Object>> converter,
AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer<R> consumer,
Consumer<XSSFWorkbook> customizer) throws Exception {
Page<T> first = fetcher.apply(1L);
long total = first.getTotal();
if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的数据");
if (reporter != null) {
reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BATCH_SIZE, 2),
"共 " + total + " 条,正在分批生成 Excel");
}
try {
return TemplateBatchWorkbook.write(() -> new ClassPathResource(template).getInputStream(), total, BATCH_SIZE,
values, (current, size) -> current == 1 ? first.getRecords() : fetcher.apply(current).getRecords(),
converter, (completed, expected) -> {
if (reporter != null) reporter.advance("已写入 " + completed + " / " + expected + " 条");
}, file -> {
if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
return consumer.consume(file);
}, customizer);
} catch (IllegalArgumentException exception) {
// 模板行数校验的文案为受控提示,其他异常仍交给执行器统一处理。
if (exception.getMessage() != null && exception.getMessage().startsWith("导出明细数超过")) {
throw new AsyncTaskException(exception.getMessage() + ",请缩小筛选范围");
}
throw exception;
} catch (IllegalStateException exception) {
if (exception.getMessage() != null && exception.getMessage().startsWith("导出数据在执行期间发生变化")) {
throw new AsyncTaskException("导出期间数据发生变化,请重试");
}
throw exception;
}
}
/** 保持旧同步接口的附件语义;生成失败时尚未写出任何响应文件字节。 */
public static Void respond(Path file, String fileName, HttpServletResponse response) throws Exception {
response.setContentType(CONTENT_TYPE);
response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" +
URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
Files.copy(file, response.getOutputStream());
response.flushBuffer();
return null;
}
/** 延续旧模板的时间范围标题字段;缺失时间条件时占位符留空。 */
public static Map<String, Object> timeValues(LocalDateTime[] range) {
Map<String, Object> values = new HashMap<>();
if (range != null && range.length == 2 && range[0] != null && range[1] != null) {
DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
values.put("startTime", range[0].format(format));
values.put("endTime", range[1].format(format));
values.put("timeSpan", " 到 ");
}
return values;
}
/** 钱包日期区间沿用零点时间标题,不改变实际日期筛选。 */
public static Map<String, Object> timeValues(java.time.LocalDate[] range) {
if (range == null || range.length != 2) return new HashMap<>();
return timeValues(new LocalDateTime[]{range[0] == null ? null : range[0].atStartOfDay(), range[1] == null ? null : range[1].atStartOfDay()});
}
/** 创建受导出批大小约束的分页,后续页不重复执行 COUNT。 */
public static <T> Page<T> page(long current) {
Page<T> page = new Page<>(current, BATCH_SIZE, current == 1);
page.setMaxLimit((long) BATCH_SIZE);
return page;
}
}
@@ -0,0 +1,217 @@
package com.bsd.pjcloud.common.asynctask.service.impl;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.task.TaskExecutor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 通用异步事务任务提交服务默认实现。
* <p>实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。</p>
*/
@Slf4j
public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService {
/** 通用异步任务日志标签,用于统一检索任务提交和执行异常。 */
private static final String LOG_TAG = "通用异步任务";
/** 后台事务失败时返回调用端的固定说明,避免暴露 SQL 或远程调用等内部异常细节。 */
private static final String TASK_FAILED_MESSAGE = "导出任务执行失败,请重试";
/** 线程池拒绝任务时返回调用端的固定说明,提示稍后重新提交。 */
private static final String TASK_REJECTED_MESSAGE = "任务提交失败,请稍后重试";
/** 公共异步任务工作线程池,用于限制后台任务并发并与业务专用线程池隔离。 */
private final TaskExecutor asyncTaskWorkerExecutor;
/** Redis 任务进度服务,用于初始化、更新和查询任务状态。 */
private final AsyncTaskProgressService asyncTaskProgressService;
/** Spring 编程式事务模板,保证整个异步业务动作成功或整体回滚。 */
private final TransactionTemplate transactionTemplate;
/**
* 创建通用异步任务执行器。
*
* @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
* @param asyncTaskProgressService Redis 任务进度服务
* @param transactionTemplate Spring 编程式事务模板
*/
public DefaultAsyncTaskExecutorService(TaskExecutor asyncTaskWorkerExecutor,
AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
this.asyncTaskWorkerExecutor = asyncTaskWorkerExecutor;
this.asyncTaskProgressService = asyncTaskProgressService;
this.transactionTemplate = transactionTemplate;
}
/**
* 创建并提交一个异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行的业务动作
* @return 等待执行的初始任务进度
*/
@Override
public AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action) {
return submitWithResult(redisKey, taskName, total, reporter -> {
action.execute(reporter);
return AsyncTaskResult.empty();
});
}
/**
* 创建并提交一个可在成功终态携带结果文件的异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行并返回结果文件信息的业务动作
* @return 等待执行的初始任务进度
*/
@Override
public AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
AsyncTaskProgressVO progress = asyncTaskProgressService.create(redisKey, taskName, total);
Long tenantId = TenantContextHolder.getTenantId();
Authentication authentication = SecurityUtils.getAuthentication();
String token = SecurityUtils.getToken();
SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
SecurityContext securityContext = strategy.createEmptyContext();
securityContext.setAuthentication(authentication);
try {
asyncTaskWorkerExecutor.execute(() -> runTask(progress, tenantId, token, strategy, securityContext, action));
}
catch (RuntimeException exception) {
log.error("|{}|任务提交线程池失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, TASK_REJECTED_MESSAGE);
throw new AsyncTaskException(TASK_REJECTED_MESSAGE);
}
return progress;
}
/**
* 在恢复租户、安全和 Feign Token 上下文后执行异步事务。
*
* @param progress 初始任务进度
* @param tenantId 请求提交时的租户 ID,恢复查询及任务键的租户隔离上下文
* @param token 请求提交时的 OAuth2 Token,可为空
* @param strategy 当前 Spring Security 上下文策略
* @param securityContext 请求提交时复制出的安全上下文
* @param action 后台事务中执行的业务动作
*/
private void runTask(AsyncTaskProgressVO progress, Long tenantId, String token, SecurityContextHolderStrategy strategy,
SecurityContext securityContext, AsyncTaskResultAction action) {
try {
TenantContextHolder.setTenantId(tenantId);
strategy.setContext(securityContext);
NonWebTokenContextHolder.setToken(token);
asyncTaskProgressService.start(progress.getRedisKey(), progress.getTaskId());
AtomicInteger completed = new AtomicInteger();
AsyncTaskResult result;
try {
AsyncTaskProgressReporter reporter = createProgressReporter(progress, completed);
result = transactionTemplate.execute(status -> {
try { return action.execute(reporter); }
catch (RuntimeException exception) { throw exception; }
catch (Exception exception) { throw new IllegalStateException("导出执行异常", exception); }
});
}
catch (Exception exception) {
log.error("|{}|任务事务执行失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE);
return;
}
markSuccessSafely(progress, result);
}
catch (Exception exception) {
log.error("|{}|任务启动失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE);
}
finally {
NonWebTokenContextHolder.clear();
strategy.clearContext();
TenantContextHolder.clear();
}
}
/**
* 创建同时支持逐项推进和动态总数更新的任务进度报告器。
*
* @param progress 当前任务的业务命名空间和任务 ID
* @param completed 当前任务线程内的已处理计数器
* @return 绑定当前任务 Redis 进度的报告器
*/
private AsyncTaskProgressReporter createProgressReporter(AsyncTaskProgressVO progress, AtomicInteger completed) {
return new AsyncTaskProgressReporter() {
/**
* 将后台查询得到的真实总数写入 Redis,并同步约束线程内已处理计数。
*
* @param total 真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
@Override
public void updateTotal(int total, String message) {
completed.updateAndGet(current -> Math.min(current, total));
asyncTaskProgressService.updateTotal(progress.getRedisKey(), progress.getTaskId(), total, message);
}
/**
* 将已处理项目数递增一项并写入 Redis。
*
* @param message 当前项目完成后的进度说明
*/
@Override
public void advance(String message) {
int current = completed.incrementAndGet();
asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message);
}
};
}
/**
* 尝试把任务写为失败状态;Redis 异常只记录日志,不能覆盖原始业务异常或阻断线程上下文清理。
*
* @param progress 待更新的任务标识和业务命名空间
* @param message 对调用端安全的失败说明
*/
private void markFailedSafely(AsyncTaskProgressVO progress, String message) {
try {
asyncTaskProgressService.fail(progress.getRedisKey(), progress.getTaskId(), message);
}
catch (Exception exception) {
log.error("|{}|写入失败进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
}
}
/**
* 尝试把已提交事务的任务写为成功状态;Redis 写入失败只记录,不能把已经提交的数据误报为已回滚。
*
* @param progress 已完成数据库事务的任务标识和业务命名空间
* @param result 业务动作返回的可选结果文件信息
*/
private void markSuccessSafely(AsyncTaskProgressVO progress, AsyncTaskResult result) {
try {
asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功", result);
}
catch (Exception exception) {
log.error("|{}|写入成功进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
}
}
}
@@ -0,0 +1,262 @@
package com.bsd.pjcloud.common.asynctask.service.impl;
import com.bsd.pjcloud.common.asynctask.constant.AsyncTaskStatusEnum;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 通用异步任务 Redis 进度服务默认实现。
* <p>该实现显式拼接租户、业务命名空间、登录用户和任务 ID,兼容项目当前普通字符串 Redis Key 序列化器。</p>
*/
public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService {
/** 所有通用异步任务 Redis Key 的固定业务命名空间。 */
private static final String REDIS_KEY_PREFIX = "async:task:progress";
/** 任务状态的 Redis 保留时间,供页面刷新或短时重连后继续查询。 */
private static final Duration TASK_TTL = Duration.ofHours(24);
/** 业务 Redis Key 允许的字符和长度,防止调用方构造不可控的 Redis 路径。 */
private static final Pattern REDIS_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9:_-]{1,80}$");
/** 不透明任务 ID 的固定格式,与本服务生成的 32 位 UUID 一致。 */
private static final Pattern TASK_ID_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}$");
/** Redis 操作模板,使用项目统一 JSON 序列化配置保存进度对象。 */
private final RedisTemplate<String, Object> redisTemplate;
/**
* 创建通用 Redis 进度服务。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
*/
public DefaultAsyncTaskProgressService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 创建等待执行的异步任务并写入 Redis。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数,必须大于零
* @return 已写入 Redis 的初始任务进度
*/
@Override
public AsyncTaskProgressVO create(String redisKey, String taskName, int total) {
validateRedisKey(redisKey);
if (total <= 0) {
throw new IllegalArgumentException("异步任务总数必须大于0");
}
AsyncTaskProgressVO progress = new AsyncTaskProgressVO();
progress.setTaskId(UUID.randomUUID().toString().replace("-", ""));
progress.setRedisKey(redisKey);
progress.setTaskName(taskName);
progress.setStatus(AsyncTaskStatusEnum.PENDING.getStatus());
progress.setTotal(total);
progress.setCompleted(0);
progress.setPercentage(0);
progress.setMessage(AsyncTaskStatusEnum.PENDING.getDescription());
progress.setFinished(false);
progress.setSuccess(false);
save(progress);
return progress;
}
/**
* 将任务标记为执行中。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
*/
@Override
public void start(String redisKey, String taskId) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setMessage(AsyncTaskStatusEnum.RUNNING.getDescription());
save(progress);
}
/**
* 在任务开始后更新真实总项目数并重新计算完成百分比。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
@Override
public void updateTotal(String redisKey, String taskId, int total, String message) {
if (total <= 0) {
throw new IllegalArgumentException("异步任务总数必须大于0");
}
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
int safeCompleted = Math.max(0, Math.min(progress.getCompleted(), total));
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setTotal(total);
progress.setCompleted(safeCompleted);
progress.setPercentage(calculatePercentage(safeCompleted, total));
progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
save(progress);
}
/**
* 更新任务执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param completed 当前已处理项目数
* @param message 当前进度说明
*/
@Override
public void update(String redisKey, String taskId, int completed, String message) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
int safeCompleted = Math.max(0, Math.min(completed, progress.getTotal()));
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setCompleted(safeCompleted);
progress.setPercentage(calculatePercentage(safeCompleted, progress.getTotal()));
progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
save(progress);
}
/**
* 将任务标记为成功并把进度置为 100%。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
*/
@Override
public void success(String redisKey, String taskId, String message) {
success(redisKey, taskId, message, null);
}
/**
* 将任务标记为成功、把进度置为 100%,并保存可选的结果文件信息。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
* @param result 业务动作返回的可选结果文件信息
*/
@Override
public void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.SUCCESS.getStatus());
progress.setCompleted(progress.getTotal());
progress.setPercentage(100);
progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.SUCCESS.getDescription() : message);
progress.setFinished(true);
progress.setSuccess(true);
if (result != null) {
progress.setResultFileUrl(result.fileUrl());
progress.setResultFileName(result.fileName());
}
save(progress);
}
/**
* 将任务标记为失败并保留失败前执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 对调用端安全的失败说明
*/
@Override
public void fail(String redisKey, String taskId, String message) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.FAILED.getStatus());
progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.FAILED.getDescription() : message);
progress.setFinished(true);
progress.setSuccess(false);
save(progress);
}
/**
* 查询当前用户创建的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
*/
@Override
public AsyncTaskProgressVO get(String redisKey, String taskId) {
validateRedisKey(redisKey);
Object value = redisTemplate.opsForValue().get(buildRedisKey(redisKey, taskId));
return value instanceof AsyncTaskProgressVO progress ? progress : null;
}
/**
* 获取必须存在的任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 当前任务进度
*/
private AsyncTaskProgressVO requireProgress(String redisKey, String taskId) {
AsyncTaskProgressVO progress = get(redisKey, taskId);
if (progress == null) {
throw new IllegalStateException("异步任务不存在或已过期");
}
return progress;
}
/**
* 保存任务进度并刷新统一过期时间。
*
* @param progress 待保存的完整进度对象
*/
private void save(AsyncTaskProgressVO progress) {
redisTemplate.opsForValue().set(buildRedisKey(progress.getRedisKey(), progress.getTaskId()), progress, TASK_TTL);
}
/**
* 构建按当前用户隔离的业务 Redis Key。
* <p>租户 ID 显式进入任务键,不依赖 Redis 序列化器隐式隔离。</p>
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 仅服务端使用的业务 Redis Key,结构为固定前缀、租户 ID、业务命名空间、用户 ID 和任务 ID
*/
private String buildRedisKey(String redisKey, String taskId) {
PjcloudUser currentUser = SecurityUtils.getUser();
if (currentUser == null || currentUser.getId() == null) {
throw new IllegalStateException("缺少异步任务用户上下文");
}
if (taskId == null || !TASK_ID_PATTERN.matcher(taskId).matches()) {
throw new IllegalArgumentException("异步任务ID格式不正确");
}
return String.join(":", REDIS_KEY_PREFIX, String.valueOf(TenantContextHolder.getTenantId()), redisKey, String.valueOf(currentUser.getId()), taskId);
}
/**
* 校验业务 Redis Key 命名空间,限制长度与字符范围。
*
* @param redisKey 业务指定的 Redis Key 命名空间
*/
private void validateRedisKey(String redisKey) {
if (redisKey == null || !REDIS_KEY_PATTERN.matcher(redisKey).matches()) {
throw new IllegalArgumentException("异步任务Redis Key格式不正确");
}
}
/**
* 计算取整后的完成百分比。
*
* @param completed 已完成项目数
* @param total 总项目数
* @return 0 至 99 的整数百分比;只有 success 终态可写入 100
*/
private int calculatePercentage(int completed, int total) {
return total <= 0 ? 0 : (int) Math.min(99L, (long) completed * 100 / total);
}
}
@@ -0,0 +1,66 @@
package com.bsd.pjcloud.common.asynctask.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 通用异步任务进度展示对象。
* <p>对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。</p>
*/
@Data
@Schema(description = "通用异步任务进度")
public class AsyncTaskProgressVO implements Serializable {
/** 序列化版本号,用于 Redis 中任务状态的兼容反序列化。 */
private static final long serialVersionUID = 1L;
/** 调用端轮询使用的不透明任务 ID,不包含租户或用户信息。 */
@Schema(description = "任务ID")
private String taskId;
/** 业务指定的 Redis Key 命名空间,用于区分不同异步任务场景。 */
@Schema(description = "Redis Key命名空间")
private String redisKey;
/** 任务展示名称,例如后台用户导入。 */
@Schema(description = "任务名称")
private String taskName;
/** 固定任务状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
@Schema(description = "任务状态")
private String status;
/** 任务需要处理的总项目数。 */
@Schema(description = "总项目数")
private Integer total;
/** 当前执行到的项目数;任务整体失败时仅表示回滚或失败前的执行位置,不代表最终成功写入数量。 */
@Schema(description = "已处理项目数")
private Integer completed;
/** 任务完成百分比,取值范围为 0 至 100。 */
@Schema(description = "完成百分比")
private Integer percentage;
/** 当前进度或最终结果说明。 */
@Schema(description = "任务消息")
private String message;
/** 是否已进入成功或失败终态,供调用端决定是否停止轮询。 */
@Schema(description = "是否完成")
private Boolean finished;
/** 终态是否成功;任务未结束时固定为 {@code false}。 */
@Schema(description = "是否成功")
private Boolean success;
/** 任务成功后生成文件的下载地址;导入等无附件任务为空。 */
@Schema(description = "任务结果文件下载地址")
private String resultFileUrl;
/** 任务成功后生成文件的下载名称;导入等无附件任务为空。 */
@Schema(description = "任务结果文件名称")
private String resultFileName;
}
@@ -0,0 +1,2 @@
com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration
com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration
@@ -62,6 +62,11 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
<version>${pjcloud.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.bsd</groupId> <groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId> <artifactId>pjcloud-common-core</artifactId>
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.core.config;
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
import com.bsd.pjcloud.common.core.file.FileAccessSigner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FileAccessProperties.class)
public class FileAccessConfiguration {
@Bean
@ConditionalOnMissingBean
public FileAccessSigner fileAccessSigner(FileAccessProperties properties) { return new FileAccessSigner(properties); }
}
@@ -169,6 +169,8 @@ public interface CacheConstants {
String HARDER_ORDER_NON_PAY = "hard:order:non_pay"; String HARDER_ORDER_NON_PAY = "hard:order:non_pay";
String HARD_XFJ_WHITE_LIST_LOCK = "hard:xfj:whitelist:lock";
/* ************************************* end 订单相关 end ************************************* */ /* ************************************* end 订单相关 end ************************************* */
@@ -13,7 +13,8 @@ import lombok.Getter;
@AllArgsConstructor @AllArgsConstructor
public enum YesNoEnum { public enum YesNoEnum {
YES("1", "是"), NO("0", "否"); YES("1", "是"),
NO("0", "否");
/** /**
* 编码 * 编码
@@ -0,0 +1,111 @@
package com.bsd.pjcloud.common.core.file;
import lombok.Data;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.*;
@Data
@ConfigurationProperties(prefix = "file-access")
public class FileAccessProperties implements InitializingBean {
private String mode = "observe";
private long ttlSeconds = 1800;
private long packageTtlSeconds = 86400;
private long futureSkewSeconds = 300;
private boolean blockBrowserNavigation = true;
private String publicBaseUrl = "";
private List<String> allowedOrigins = new ArrayList<>();
/** An empty or root URI path allows the whole origin; other paths must end with '/'. */
private List<String> publicImageRefererPrefixes = new ArrayList<>();
private Map<String, Client> clients = new LinkedHashMap<>();
private LegacyDart legacyDart = new LegacyDart();
private Upload upload = new Upload();
@Data
public static class Upload {
private long ttlSeconds = 300;
private long maxFileBytes = 200L * 1024 * 1024;
private Set<String> allowedExtensions = new HashSet<>(List.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg",
"pdf", "apk", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "zip",
"mp4", "mov", "webm", "avi", "mp3", "wav", "m4a", "aac", "ogg", "amr", "flac"));
private Set<String> internalExtensions = new HashSet<>(List.of("log", "gz"));
}
@Data
public static class Client {
private String activeKid = "v1";
private Map<String, String> keys = new LinkedHashMap<>();
}
@Data
public static class LegacyDart {
private boolean enabled = true;
private Set<String> extensions = new HashSet<>(List.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico"));
}
public boolean enforcing() { return "enforce".equals(mode); }
public long ttl(String appId) { return "package".equals(appId) ? packageTtlSeconds : ttlSeconds; }
public String key(String appId, String kid) {
Client client = clients.get(appId);
return client == null ? null : client.getKeys().get(kid);
}
@Override
public void afterPropertiesSet() { validate(); }
/** Also called at the gate, so an invalid refreshed configuration cannot open access. */
public void validate() {
if (!Set.of("observe", "enforce").contains(mode) || ttlSeconds <= 300 || packageTtlSeconds <= 300
|| futureSkewSeconds < 0 || futureSkewSeconds > 300) {
throw new IllegalStateException("Invalid file-access policy");
}
if (upload == null || upload.ttlSeconds < 60 || upload.ttlSeconds > 1800 || upload.maxFileBytes <= 0
|| upload.maxFileBytes > 1024L * 1024 * 1024 || upload.allowedExtensions == null || upload.internalExtensions == null) {
throw new IllegalStateException("Invalid file-access upload policy");
}
if (enforcing() || !publicBaseUrl.isBlank()) {
try {
java.net.URI uri = java.net.URI.create(publicBaseUrl);
if (uri.getHost() == null || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()))
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException();
}
} catch (IllegalArgumentException e) { throw new IllegalStateException("Invalid file-access.public-base-url"); }
}
if (publicImageRefererPrefixes == null) throw new IllegalStateException("Invalid public image Referer prefixes");
for (String prefix : publicImageRefererPrefixes) {
try {
java.net.URI uri = java.net.URI.create(prefix);
String path = uri.getRawPath();
if (uri.getHost() == null || !("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null
|| path == null || (!path.isEmpty() && !path.endsWith("/")) || path.contains("%")
|| !path.equals(uri.normalize().getRawPath())) throw new IllegalArgumentException();
} catch (RuntimeException e) { throw new IllegalStateException("Invalid public image Referer prefix"); }
}
if (enforcing()) {
for (String app : List.of("admin", "h5", "mini", "flutter", "service", "package")) {
// Some deployments retain an old H5 and only allow its image Referer prefix.
// An omitted H5 key does not authorize other H5 requests.
if (app.equals("h5") && !clients.containsKey(app)) continue;
Client client = clients.get(app);
if (client == null || !client.getActiveKid().matches("[a-zA-Z0-9_-]{1,32}")
|| !validKey(client.getKeys().get(client.getActiveKid()))) {
throw new IllegalStateException("Missing file-access key for " + app);
}
}
}
clients.forEach((app, client) -> client.getKeys().forEach((kid, key) -> {
if (key != null && !key.isBlank() && (!kid.matches("[a-zA-Z0-9_-]{1,32}") || !validKey(key))) {
throw new IllegalStateException("Invalid file-access key configuration for " + app);
}
}));
}
private static boolean validKey(String key) {
try { return key != null && Base64.getDecoder().decode(key).length >= 32; }
catch (IllegalArgumentException e) { return false; }
}
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.common.core.file;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
/** Versioned wire protocol shared with the browser and Dart clients. */
public final class FileAccessSignature {
private FileAccessSignature() { }
public static String canonical(String fileName, String appId, String kid, long timestamp) {
return String.join("\n", "v1", "oss-file-read", appId, kid, Long.toString(timestamp),
Base64.getUrlEncoder().withoutPadding().encodeToString(fileName.getBytes(StandardCharsets.UTF_8)));
}
public static String sign(String fileName, String appId, String kid, long timestamp, String base64Key) {
return hmac(canonical(fileName, appId, kid, timestamp), base64Key);
}
public static String hmac(String canonical, String base64Key) {
try {
byte[] key = Base64.getDecoder().decode(base64Key);
if (key.length < 32) throw new IllegalArgumentException("File access key must contain at least 32 bytes");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Cannot calculate file access signature", e);
}
}
public static boolean matches(String expected, String actual) {
return actual != null && actual.matches("[0-9a-f]{64}")
&& MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII));
}
}
@@ -0,0 +1,105 @@
package com.bsd.pjcloud.common.core.file;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.*;
/** Signs only this installation's file-center URLs, never arbitrary remote URLs. */
public class FileAccessSigner {
private final FileAccessProperties properties;
private final Clock clock;
public FileAccessSigner(FileAccessProperties properties) { this(properties, Clock.systemUTC()); }
public FileAccessSigner(FileAccessProperties properties, Clock clock) { this.properties = properties; this.clock = clock; }
public Map<String, String> parameters(String fileName, String appId) {
FileAccessProperties.Client client = properties.getClients().get(appId);
if (client == null) throw new IllegalStateException("Missing file-access signer configuration for " + appId);
String kid = client.getActiveKid();
String key = properties.key(appId, kid);
if (key == null || key.isBlank()) throw new IllegalStateException("Missing file-access signer key for " + appId);
long ts = clock.instant().getEpochSecond();
Map<String, String> result = new LinkedHashMap<>();
result.put("appId", appId);
result.put("kid", kid);
result.put("ts", Long.toString(ts));
result.put("sign", FileAccessSignature.sign(fileName, appId, kid, ts, key));
result.put("fileName", fileName); // Legacy APK clients require .apk at the end of the URL.
return result;
}
public Map<String, String> uploadHeaders(String target) {
FileAccessProperties.Client client = properties.getClients().get("service");
if (client == null) throw new IllegalStateException("Missing file-access service signer configuration");
String kid = client.getActiveKid(), key = properties.key("service", kid);
if (key == null || key.isBlank()) throw new IllegalStateException("Missing file-access service signer key");
long ts = clock.instant().getEpochSecond();
String nonce = UUID.randomUUID().toString().replace("-", "");
Map<String, String> headers = new LinkedHashMap<>();
headers.put(FileUploadSignature.APP, "service");
headers.put(FileUploadSignature.KID, kid);
headers.put(FileUploadSignature.TIMESTAMP, Long.toString(ts));
headers.put(FileUploadSignature.NONCE, nonce);
headers.put(FileUploadSignature.SIGNATURE, FileUploadSignature.sign(target, "service", kid, ts, nonce, key));
return headers;
}
public String signUrl(String source, String appId) {
if (source == null || source.isBlank()) return source;
String value = source.trim();
String fileName;
String endpoint;
if (!value.contains("/") && !value.contains("?") && !value.contains(":")) {
fileName = value;
endpoint = base() + "/admin/sys-file/oss/file";
} else {
URI uri;
try { uri = URI.create(value); } catch (IllegalArgumentException e) { return source; }
if (uri.isAbsolute() && !owned(uri)) return source;
if (uri.getRawAuthority() != null && !uri.isAbsolute()) return source;
String path = uri.getPath();
String configuredPath = properties.getPublicBaseUrl().isBlank() ? "" : URI.create(properties.getPublicBaseUrl()).getPath().replaceAll("/+$", "") + "/admin/sys-file/oss/file";
if (!Set.of("/sys-file/oss/file", "/admin/sys-file/oss/file", "/api/admin/sys-file/oss/file").contains(path) && !path.equals(configuredPath)) return source;
List<String> names = new ArrayList<>();
if (uri.getRawQuery() != null) for (String pair : uri.getRawQuery().split("&")) {
String[] parts = pair.split("=", 2);
if (decode(parts[0]).equals("fileName")) names.add(decode(parts.length == 2 ? parts[1] : ""));
}
if (names.size() != 1 || names.get(0).isBlank()) throw new IllegalArgumentException("Invalid file-center URL");
fileName = names.get(0);
if (uri.isAbsolute()) endpoint = value.split("\\?", 2)[0];
else endpoint = base() + "/admin/sys-file/oss/file";
}
StringJoiner query = new StringJoiner("&");
parameters(fileName, appId).forEach((key, valuePart) -> query.add(key + "=" + encode(valuePart)));
return endpoint + "?" + query;
}
private String base() {
String base = properties.getPublicBaseUrl().replaceAll("/+$", "");
if (!base.matches("https?://[^/]+.*")) throw new IllegalStateException("Configure file-access.public-base-url");
return base;
}
private boolean owned(URI uri) {
if (!Set.of("http", "https").contains(Objects.toString(uri.getScheme(), "").toLowerCase(Locale.ROOT)) || uri.getUserInfo() != null) return false;
List<String> origins = new ArrayList<>(properties.getAllowedOrigins());
origins.add(properties.getPublicBaseUrl());
return origins.stream().filter(s -> s != null && !s.isBlank()).anyMatch(s -> {
URI allowed = URI.create(s);
return uri.getScheme().equalsIgnoreCase(allowed.getScheme())
&& uri.getHost() != null && uri.getHost().equalsIgnoreCase(allowed.getHost())
&& port(uri) == port(allowed);
});
}
private static int port(URI uri) {
return uri.getPort() >= 0 ? uri.getPort() : ("https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80);
}
private static String decode(String value) { return URLDecoder.decode(value, StandardCharsets.UTF_8); }
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); }
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.common.core.file;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
/** Resolves stored file references for internal reads through RemoteFileService. */
public final class FileAccessUrl {
private FileAccessUrl() { }
/**
* Returns the original filename, or null for a URL outside the file-center routes.
* The source host is deliberately not used: callers must read the result from
* their own UPMS, never sign or fetch the supplied host with service credentials.
*/
public static String fileName(String source) {
if (source == null || source.isBlank()) throw new IllegalArgumentException("Empty file reference");
String value = source.trim();
if (!value.contains("/") && !value.contains("?") && !value.contains(":")) {
return validateFileName(value);
}
URI uri = URI.create(value);
String path = uri.getRawPath();
if (path == null) return null;
if (!path.startsWith("/")) path = "/" + path;
if (!(path.equals("/sys-file/oss/file") || path.endsWith("/admin/sys-file/oss/file"))) return null;
if ((uri.isAbsolute() && !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())))
|| (!uri.isAbsolute() && uri.getRawAuthority() != null) || uri.getUserInfo() != null
|| uri.getRawFragment() != null) {
throw new IllegalArgumentException("Invalid file-center URL");
}
String name = null;
if (uri.getRawQuery() != null) {
for (String pair : uri.getRawQuery().split("&")) {
String[] parts = pair.split("=", 2);
if (decode(parts[0]).equals("fileName")) {
if (name != null) throw new IllegalArgumentException("Duplicate fileName");
name = decode(parts.length == 2 ? parts[1] : "");
}
}
}
return validateFileName(name);
}
private static String validateFileName(String name) {
if (name == null || name.isBlank() || name.length() > 1024 || name.contains("/") || name.contains("\\")
|| name.equals(".") || name.equals("..") || name.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("Invalid file-center filename");
}
return name;
}
private static String decode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.common.core.file;
import java.util.Set;
/** Upload authorization is purpose-separated from read signatures and is never put in URLs. */
public final class FileUploadSignature {
public static final String APP = "X-File-App-Id";
public static final String KID = "X-File-Key-Id";
public static final String TIMESTAMP = "X-File-Timestamp";
public static final String NONCE = "X-File-Nonce";
public static final String SIGNATURE = "X-File-Signature";
public static final Set<String> TARGETS = Set.of("upload", "inner-upload", "qn-upload");
private FileUploadSignature() { }
public static String canonical(String target, String appId, String kid, long timestamp, String nonce) {
if (!TARGETS.contains(target)) throw new IllegalArgumentException("Invalid file upload target");
return String.join("\n", "v1", "oss-file-upload", "POST", target, appId, kid, Long.toString(timestamp), nonce);
}
public static String sign(String target, String appId, String kid, long timestamp, String nonce, String key) {
return FileAccessSignature.hmac(canonical(target, appId, kid, timestamp, nonce), key);
}
}
@@ -0,0 +1,318 @@
package com.bsd.pjcloud.common.core.util;
import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.StreamWriteConstraints;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import org.springframework.core.io.Resource;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.lang.reflect.Array;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
/** 仅用于审计日志的副本过滤,不修改业务对象、业务 ObjectMapper 或系统配置。 */
public final class AuditLogSanitizer {
public static final String HIDDEN = "参数已隐藏";
private static final int MAX_DEPTH = 32;
private static final int MAX_NODES = 10000;
private static final ObjectMapper MAPPER = new ObjectMapper(JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(MAX_DEPTH).build())
.streamWriteConstraints(StreamWriteConstraints.builder().maxNestingDepth(MAX_DEPTH).build()).build())
.registerModule(new PjcloudJavaTimeModule()).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private static final Set<String> CREDENTIALS = Set.of(
"pass", "password", "passwd", "pwd", "credential", "credentials", "salt",
"captcha", "captchacode", "captchaverification", "captchaid", "verifycode", "verificationcode",
"validatecode", "smscode", "imagecode", "otp", "otpcode", "randomstr", "authcode",
"token", "tokenid", "authtoken", "accesstoken", "refreshtoken", "idtoken", "resettoken", "authorization", "authorizationcode",
"clientsecret", "appsecret", "accesssecret", "accesskeysecret", "secret", "secretkey",
"privatekey", "apikey", "accesskey", "sign", "signature", "cookie", "setcookie", "sessionid", "jsessionid",
"密码", "口令", "验证码", "短信验证码", "图形验证码", "令牌", "访问令牌", "刷新令牌", "密钥", "私钥");
private static final Set<String> AUTH_CONTEXT_FIELDS = Set.of("randomstr", "captcha", "captchaid", "captchaverification",
"captchacode", "verifycode", "verificationcode", "validatecode", "smscode", "otp", "otpcode", "granttype");
private static final Pattern PASSWORD = Pattern.compile(
"(?:old|new|current|confirm|confirmation|repeat|re|login|pay|trade|transaction|user|admin|initial|original|temporary|encoded|encrypted|hashed)*(?:password|passwd|pwd|pass)(?:s|confirm|confirmation|repeat|again|hash|digest)?\\d*");
private static final Pattern CREDENTIAL_FAMILY = Pattern.compile(
"[\\p{L}\\p{N}]*(?:(?:password|passwd|pwd)(?:s|confirm|confirmation|repeat|again|hash|digest)?"
+ "|secret|secretkey|privatekey|token|tokenid|tokenkey|apikey|accesskey|(?:captcha|verify|verification|validate|sms|otp|auth)code)\\d*");
private static final Pattern AUTH_PATH = Pattern.compile(
"(?i)(?:^|/)(?:oauth2?|login|applogin|captcha|verifycode|smscode)(?:/|$)"
+ "|/register/(?:password|default-user)(?:/|$)|/user/(?:personal/password|fix-phone-as-password|check)(?:/|$)"
+ "|/code/(?:image|check|verify)(?:/|$)");
private static final Pattern LABEL = Pattern.compile("(?<![\\p{L}\\p{N}_.\\[\\]-])[\\\"']?([\\p{L}\\p{N}_.\\[\\]-]{1,256})[\\\"']?\\s*[:=]");
private static final Pattern AUTH_VALUE = Pattern.compile("(?i)\\b(?:Bearer|Basic)\\s+\\S+|-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----");
private static final Pattern FORM_KEY = Pattern.compile("[\\p{L}\\p{N}_%.\\[\\]-]+");
private final Set<String> excluded;
public AuditLogSanitizer(Collection<String> additionalFields) {
Set<String> fields = new HashSet<>(CREDENTIALS);
if (additionalFields != null) additionalFields.stream().filter(Objects::nonNull).map(AuditLogSanitizer::normalize)
.forEach(fields::add);
excluded = Set.copyOf(fields);
}
public boolean isAuthenticationContext(String uri) {
return uri != null && AUTH_PATH.matcher(uri.split("[?#]", 2)[0]).find();
}
public boolean isSensitiveField(String name, boolean authentication) {
if (name == null) return false;
if (name.length() > 256) throw new IllegalArgumentException("Unsupported audit field length");
String decoded = decode(name);
for (String part : decoded.split("[.\\[\\]/]+")) {
String key = normalize(part);
if (excluded.contains(key) || PASSWORD.matcher(key).matches() || CREDENTIAL_FAMILY.matcher(key).matches()
|| (authentication && "code".equals(key))) return true;
}
return false;
}
/** 历史 params 只接受可识别的 JSON 或表单,匿名值与损坏报文不回退为原文。 */
public String sanitizeParameters(String value, String uri) {
if (value == null || value.isBlank() || HIDDEN.equals(value)) return value;
try {
String input = structuredInput(value);
if (jsonLike(input)) return MAPPER.writeValueAsString(clean(MAPPER.readTree(input), isAuthenticationContext(uri), false, 0, new Budget()));
if (formLike(input)) return form(input, isAuthenticationContext(uri), 0, new Budget());
} catch (Exception ignored) {
// 异常原文可能包含请求值,不能输出异常消息或回退到原始内容。
}
return HIDDEN;
}
/** 方法参数和 DTO 立即转成过滤后的字符串快照,异步事件不携带原始业务对象。 */
public String sanitizePayload(Object value, String uri) {
if (value instanceof CharSequence sequence) return sanitizeParameters(sequence.toString(), uri);
try {
return MAPPER.writeValueAsString(clean(value, isAuthenticationContext(uri), false, 0, new Budget()));
} catch (Exception ignored) {
return HIDDEN;
}
}
/** 保留 URL 表单及重复参数的语义,操作副本而非 servlet 参数映射。 */
public String sanitizeRequestParameters(Map<String, String[]> parameters, String uri) {
try {
List<String> pairs = new ArrayList<>();
for (var entry : parameters.entrySet()) {
if (entry.getValue() == null) continue;
for (String value : entry.getValue()) pairs.add(encode(entry.getKey()) + "=" + encode(value == null ? "" : value));
}
return form(String.join("&", pairs), isAuthenticationContext(uri), 0, new Budget());
} catch (Exception ignored) {
return HIDDEN;
}
}
/** 普通错误文本保留;含可识别凭据或损坏结构化内容时隐藏,不尝试猜测秘密值边界。 */
public String sanitizeText(String value, String uri) {
if (value == null || value.isBlank() || HIDDEN.equals(value)) return value;
try {
String input = structuredInput(value);
if (jsonLike(input) || formLike(input)) return sanitizeParameters(input, uri);
if (unsafeText(input, isAuthenticationContext(uri))) return HIDDEN;
return value;
} catch (Exception ignored) {
return HIDDEN;
}
}
public String sanitizeUri(String uri) {
return sanitizeUri(uri, Map.of());
}
/** 新请求利用路径参数名称过滤;历史记录覆盖已知的认证路径。 */
public String sanitizeUri(String uri, Map<String, String> pathVariables) {
if (uri == null || uri.isBlank() || HIDDEN.equals(uri)) return uri;
try {
int hash = uri.indexOf('#');
String fragment = hash < 0 ? "" : uri.substring(hash + 1);
String main = hash < 0 ? uri : uri.substring(0, hash);
int question = main.indexOf('?');
String path = question < 0 ? main : main.substring(0, question);
boolean authentication = isAuthenticationContext(path);
String[] segments = path.split("/", -1);
for (int index = 0; index < segments.length; index++) {
String segment = decode(segments[index]);
for (var variable : pathVariables.entrySet()) {
if (isSensitiveField(variable.getKey(), authentication) && Objects.equals(segment, variable.getValue())) segments[index] = HIDDEN;
}
}
path = String.join("/", segments).replaceAll("(?i)(/appLogin/(?:zzdsso|getAuthToken)/)[^/?#]+", "$1" + HIDDEN)
.replaceAll("(?i)(https?://)[^/@\\s]+@", "$1" + HIDDEN + "@");
if (unsafeText(path, authentication)) return HIDDEN;
if (question >= 0) {
String query = safeUriForm(main.substring(question + 1), authentication);
if (!query.isBlank()) path += "?" + query;
}
if (!fragment.isBlank()) {
String safeFragment = formLike(fragment) ? safeUriForm(fragment, authentication)
: authentication || unsafeText(fragment, false) ? HIDDEN : fragment;
path += "#" + safeFragment;
}
return path;
} catch (Exception ignored) {
return HIDDEN;
}
}
private JsonNode clean(Object value, boolean authentication, boolean named, int depth, Budget budget) throws Exception {
budget.visit(depth);
if (value == null || value instanceof JsonNode node && node.isNull()) return MAPPER.nullNode();
if (isUnloggable(value)) throw new UnsafePayload();
if (value instanceof Map<?, ?> map) {
ObjectNode result = MAPPER.createObjectNode();
boolean auth = authentication || authenticationMap(map);
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new UnsafePayload();
if (!isSensitiveField(key, auth) && !isUnloggable(entry.getValue())) result.set(key, clean(entry.getValue(), auth, true, depth + 1, budget));
}
return result;
}
if (value instanceof JsonNode node && node.isObject()) {
Map<String, JsonNode> entries = new LinkedHashMap<>();
node.fields().forEachRemaining(entry -> entries.put(entry.getKey(), entry.getValue()));
return clean(entries, authentication, named, depth + 1, budget);
}
if (value instanceof JsonNode node && node.isArray()
|| !(value instanceof JsonNode) && value instanceof Iterable<?> || value.getClass().isArray()) {
ArrayNode result = MAPPER.createArrayNode();
Iterable<?> items = value instanceof Iterable<?> iterable ? iterable : arrayItems(value);
for (Object item : items) if (!isUnloggable(item)) result.add(clean(item, authentication, named, depth + 1, budget));
return result;
}
if (value instanceof CharSequence || value instanceof JsonNode node && node.isTextual()) {
String text = value instanceof JsonNode node ? node.textValue() : value.toString();
if (!named) throw new UnsafePayload();
String input = structuredInput(text);
if (jsonLike(input)) return MAPPER.getNodeFactory().textNode(MAPPER.writeValueAsString(clean(MAPPER.readTree(input), authentication, false, depth + 1, budget)));
if (formLike(input)) return MAPPER.getNodeFactory().textNode(form(input, authentication, depth + 1, budget));
return MAPPER.getNodeFactory().textNode(unsafeText(input, authentication) ? HIDDEN : text);
}
if (value instanceof JsonNode node && node.isValueNode() || value instanceof Number || value instanceof Boolean || value instanceof Character || value instanceof Enum<?>) {
if (!named) throw new UnsafePayload();
return value instanceof JsonNode node ? node.deepCopy() : MAPPER.valueToTree(value);
}
return clean(MAPPER.valueToTree(value), authentication, named, depth + 1, budget);
}
public static boolean isUnloggable(Object value) {
return value instanceof ServletRequest || value instanceof ServletResponse || value instanceof BindingResult
|| value instanceof MultipartFile || value instanceof Resource || value instanceof InputStream
|| value instanceof OutputStream || value instanceof Reader || value instanceof Writer
|| value instanceof File || value instanceof Path;
}
private boolean authenticationMap(Map<?, ?> map) {
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String field)) continue;
String key = normalize(decode(field));
if (AUTH_CONTEXT_FIELDS.contains(key)) return true;
Object value = entry.getValue();
String text = value instanceof JsonNode node && node.isTextual() ? node.textValue()
: value instanceof CharSequence sequence ? sequence.toString()
: value instanceof Enum<?> enumeration ? enumeration.name() : null;
if ("bizcode".equals(key) && "SMS_NORMAL_CODE".equalsIgnoreCase(text)) return true;
}
return false;
}
private String form(String value, boolean authentication, int depth, Budget budget) throws Exception {
budget.visit(depth);
if (value.isBlank()) return "";
List<String[]> pairs = new ArrayList<>();
Map<String, String> context = new LinkedHashMap<>();
for (String part : value.split("&", -1)) {
if (part.isBlank()) continue;
int equals = part.indexOf('=');
String key = decode(equals < 0 ? part : part.substring(0, equals));
if (key.isBlank() || !FORM_KEY.matcher(key).matches()) throw new UnsafePayload();
String text = equals < 0 ? "" : decode(part.substring(equals + 1));
pairs.add(new String[]{key, text});
context.put(key, text);
}
boolean auth = authentication || authenticationMap(context);
List<String> safe = new ArrayList<>();
for (String[] pair : pairs) if (!isSensitiveField(pair[0], auth)) {
JsonNode cleaned = clean(pair[1], auth, true, depth + 1, budget);
safe.add(encode(pair[0]) + "=" + encode(cleaned.textValue()));
}
return String.join("&", safe);
}
private boolean unsafeText(String value, boolean authentication) {
if (AUTH_VALUE.matcher(value).find()) return true;
var matcher = LABEL.matcher(value);
while (matcher.find()) if (isSensitiveField(matcher.group(1), authentication)) return true;
return false;
}
private String safeUriForm(String value, boolean authentication) {
try {
return form(value, authentication, 0, new Budget());
} catch (Exception ignored) {
return HIDDEN;
}
}
private static String structuredInput(String value) {
String input = value.trim();
if (!jsonLike(input) && !formLike(input) && input.matches("(?s).*%[0-9a-fA-F]{2}.*")) input = decode(input);
return input;
}
private static boolean jsonLike(String value) {
return value.startsWith("{") || value.startsWith("[") || value.startsWith("\"");
}
private static boolean formLike(String value) {
int equals = value.indexOf('=');
return equals > 0 && FORM_KEY.matcher(value.substring(0, equals)).matches();
}
private static String normalize(String value) {
return value.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]", "");
}
private static String decode(String value) {
String decoded = value;
for (int attempt = 0; attempt < 3; attempt++) {
String next = URLDecoder.decode(decoded, StandardCharsets.UTF_8);
if (next.equals(decoded) || !next.matches("(?s).*%[0-9a-fA-F]{2}.*")) return next;
decoded = next;
}
throw new IllegalArgumentException("Unsupported parameter encoding");
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static Iterable<?> arrayItems(Object array) {
List<Object> items = new ArrayList<>();
for (int index = 0; index < Array.getLength(array); index++) items.add(Array.get(array, index));
return items;
}
private static final class Budget {
private int nodes;
private void visit(int depth) throws UnsafePayload {
if (depth > MAX_DEPTH || ++nodes > MAX_NODES) throw new UnsafePayload();
}
}
private static final class UnsafePayload extends Exception {}
}
@@ -0,0 +1,57 @@
package com.bsd.pjcloud.common.core.util;
import cn.hutool.core.util.StrUtil;
/**
* 弱口令校验:禁止密码与手机号/用户名相同等低强度规则。
*/
public final class PasswordWeakCheckUtil {
private PasswordWeakCheckUtil() {
}
/**
* @param rawPassword 明文密码
* @param phone 手机号(可空)
* @param username 用户名(可空)
* @return 不通过时的中文原因;通过返回 null
*/
public static String validate(String rawPassword, String phone, String username) {
if (StrUtil.isBlank(rawPassword)) {
return "密码不能为空";
}
String pwd = StrUtil.trim(rawPassword);
String phoneTrim = StrUtil.trim(phone);
String userTrim = StrUtil.trim(username);
if (StrUtil.isNotBlank(phoneTrim) && pwd.equals(phoneTrim)) {
return "密码不能与手机号相同";
}
if (StrUtil.isNotBlank(userTrim) && pwd.equalsIgnoreCase(userTrim)) {
return "密码不能与用户名相同";
}
if (pwd.length() < 8) {
return "密码长度不能少于8位";
}
boolean hasLetter = false;
boolean hasDigit = false;
for (int i = 0; i < pwd.length(); i++) {
char c = pwd.charAt(i);
if (Character.isLetter(c)) {
hasLetter = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
}
}
if (!hasLetter || !hasDigit) {
return "密码必须同时包含字母和数字";
}
return null;
}
public static void assertStrong(String rawPassword, String phone, String username) {
String err = validate(rawPassword, phone, username);
if (err != null) {
throw new IllegalArgumentException(err);
}
}
}
@@ -4,4 +4,4 @@ com.bsd.pjcloud.common.core.config.MessageSourceConfiguration
com.bsd.pjcloud.common.core.config.RestTemplateConfiguration com.bsd.pjcloud.common.core.config.RestTemplateConfiguration
com.bsd.pjcloud.common.core.config.ImmutableConfiguration com.bsd.pjcloud.common.core.config.ImmutableConfiguration
com.bsd.pjcloud.common.core.util.SpringContextHolder com.bsd.pjcloud.common.core.util.SpringContextHolder
com.bsd.pjcloud.common.core.config.FileAccessConfiguration
@@ -1,6 +1,6 @@
sys.user.update.passwordError=\u539F\u5BC6\u7801\u9519\u8BEF\uFF0C\u4FEE\u6539\u5931\u8D25 sys.user.update.passwordError=\u539F\u5BC6\u7801\u9519\u8BEF\uFF0C\u4FEE\u6539\u5931\u8D25
sys.user.query.error=\u83B7\u53D6\u5F53\u524D\u7528\u6237\u4FE1\u606F\u5931\u8D25 sys.user.query.error=\u83B7\u53D6\u5F53\u524D\u7528\u6237\u4FE1\u606F\u5931\u8D25
sys.user.import.succeed=\u7528\u6237\u5BFC\u5165\u6210\u529F\uFF0C\u9ED8\u8BA4\u5BC6\u7801\u4E3A\u624B\u673A\u53F7 sys.user.import.succeed=\u7528\u6237\u5BFC\u5165\u6210\u529F\uFF0C\u9ED8\u8BA4\u5BC6\u7801\u89C1\u7CFB\u7EDF\u53C2\u6570USER_DEFAULT_PASSWORD
sys.user.username.existing={0} \u7528\u6237\u540D\u5DF2\u5B58\u5728 sys.user.username.existing={0} \u7528\u6237\u540D\u5DF2\u5B58\u5728
sys.user.phone.existing={0} \u624b\u673a\u53f7\u5df2\u5b58\u5728 sys.user.phone.existing={0} \u624b\u673a\u53f7\u5df2\u5b58\u5728
sys.user.userInfo.empty={0} \u7528\u6237\u4FE1\u606F\u4E3A\u7A7A sys.user.userInfo.empty={0} \u7528\u6237\u4FE1\u606F\u4E3A\u7A7A
@@ -0,0 +1,176 @@
package com.bsd.pjcloud.common.core.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.time.Duration;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class AuditLogSanitizerTest {
private final AuditLogSanitizer sanitizer = new AuditLogSanitizer(List.of());
private final ObjectMapper json = new ObjectMapper();
@ParameterizedTest
@ValueSource(strings = {"password", "old_password", "New-Password", "newpassword1", "newpassword2",
"ConfirmNewPassword", "password_confirm", "PWD", "passwd", "pass", "captchaVerification", "sms_code",
"验证码", "Authorization", "client_secret", "appSecret", "private-key", "access_token", "refresh_token",
"accessKeySecret", "COOKIE", "data.password", "users[0].password", "p%61ssword", "passwordHash", "encryptedPassword",
"rsaPrivateKey", "selfAppSecret", "selfTokenKey", "selfTokenSecret", "smtpPassword", "X-Auth-Token"})
void dropsCredentialKeysButPreservesBusinessValues(String field) throws Exception {
var original = new LinkedHashMap<String, Object>();
original.put(field, "credential-value");
original.put("username", "operator");
original.put("roleCode", "ADMIN");
JsonNode result = json.readTree(sanitizer.sanitizePayload(original, "/admin/user"));
assertEquals(json.readTree("{\"username\":\"operator\",\"roleCode\":\"ADMIN\"}"), result);
assertEquals("credential-value", original.get(field));
}
@Test
void recursivelyFiltersMapsBeansArraysAndEmbeddedJsonWithoutChangingInputs() throws Exception {
Map<String, Object> child = new LinkedHashMap<>(Map.of("password", "child-secret", "id", 7));
Map<String, Object> original = new LinkedHashMap<>();
original.put("items", List.of(child));
original.put("ids", new long[]{1, 2});
original.put("payload", "{\"newpassword1\":\"embedded-secret\",\"code\":\"BUS-42\",\"rows\":[{\"token\":\"embedded-token\",\"id\":9}]}");
original.put("bean", new LoginBean());
original.put("created", LocalDateTime.of(2026, 10, 29, 12, 30));
original.put("stream", new ByteArrayInputStream(new byte[]{1, 2}));
String output = sanitizer.sanitizePayload(original, "/orders/update");
JsonNode result = json.readTree(output);
assertEquals(7, result.at("/items/0/id").intValue());
assertFalse(result.at("/items/0").has("password"));
assertEquals(json.readTree("[1,2]"), result.get("ids"));
assertEquals("2026-10-29 12:30:00", result.get("created").textValue());
assertEquals("BUS-42", json.readTree(result.get("payload").textValue()).get("code").textValue());
assertEquals("operator", result.at("/bean/username").textValue());
assertFalse(result.has("stream"));
for (String secret : List.of("child-secret", "embedded-secret", "embedded-token", "bean-secret")) assertFalse(output.contains(secret));
assertEquals("child-secret", child.get("password"));
assertTrue(original.get("payload").toString().contains("embedded-secret"));
}
@Test
void formFilteringHandlesEncodedKeysRepeatedValuesAndNestedPayloads() {
String original = "username=operator&password=one&code=BUS-42&roleCode=ADMIN&id=1&id=2&p%61ssword=two"
+ "&payload=%7B%22new_password%22%3A%22three%22%2C%22id%22%3A9%7D";
assertEquals("username=operator&code=BUS-42&roleCode=ADMIN&id=1&id=2&payload=%7B%22id%22%3A9%7D",
sanitizer.sanitizeParameters(original, "/orders/update"));
assertTrue(original.contains("password=one"));
String[] ids = {"1", "2"};
Map<String, String[]> request = new LinkedHashMap<>();
request.put("password", new String[]{"raw-secret"});
request.put("ids", ids);
request.put("note", new String[]{"a+b 50% 中文"});
assertEquals("ids=1&ids=2&note=a%2Bb+50%25+%E4%B8%AD%E6%96%87", sanitizer.sanitizeRequestParameters(request, "/orders"));
assertSame(ids, request.get("ids"));
assertEquals("raw-secret", request.get("password")[0]);
}
@Test
void genericCodeIsRemovedOnlyInAuthenticationAndVerificationContexts() throws Exception {
String parameters = "code=123456&bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0";
assertEquals(parameters, sanitizer.sanitizeParameters(parameters, "/role/update"));
for (String uri : List.of("/oauth2/token", "/auth/oauth2/token", "/api/admin/oauth2/token", "/login", "/register/password")) {
assertEquals("bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0", sanitizer.sanitizeParameters(parameters, uri));
}
JsonNode sms = json.readTree(sanitizer.sanitizeParameters(
"{\"bizCode\":\"SMS_NORMAL_CODE\",\"params\":{\"code\":\"123456\",\"orderId\":42}}", "/message/send/sms"));
assertFalse(sms.get("params").has("code"));
assertEquals(42, sms.at("/params/orderId").intValue());
assertEquals("SMS_NORMAL_CODE", sms.get("bizCode").textValue());
assertEquals("username=operator", sanitizer.sanitizeParameters("code=123456&randomStr=proof&username=operator", "/custom"));
}
@ParameterizedTest
@ValueSource(strings = {"opaque-secret", "\"opaque-secret\"", "[\"opaque-secret\"]", "[1,2]",
"{\"password\":\"truncated-secret", "{\"id\":1} {\"password\":\"tail-secret\"}",
"password=%ZZ", "p%ZZassword=broken", "Bearer opaque-token"})
void damagedOrAnonymousParametersFailClosed(String original) {
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(original, "/orders"));
}
@Test
void emptyValuesAreStableAndSanitizationIsIdempotent() {
assertNull(sanitizer.sanitizeParameters(null, "/orders"));
assertEquals("", sanitizer.sanitizeParameters("", "/orders"));
String result = sanitizer.sanitizeParameters("[{\"password\":\"secret\",\"id\":1},null]", "/orders");
assertEquals("[{\"id\":1},null]", result);
assertEquals(result, sanitizer.sanitizeParameters(result, "/orders"));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(AuditLogSanitizer.HIDDEN, "/orders"));
}
@Test
void urlsAndExceptionsDoNotEchoRecognizableCredentials() {
assertEquals("https://example.test/auth/oauth2/token?username=operator&grant_type=password",
sanitizer.sanitizeUri("https://example.test/auth/oauth2/token?username=operator&code=123456&password=secret&grant_type=password"));
assertEquals("/orders?code=BUS-42", sanitizer.sanitizeUri("/orders?code=BUS-42&client_secret=secret"));
assertEquals("/orders?" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/orders?password=%ZZ"));
assertEquals("/custom/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/custom/path-secret", Map.of("token", "path-secret")));
assertEquals("/appLogin/getAuthToken/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/appLogin/getAuthToken/path-secret"));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("failed password=plain-secret", "/orders"));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("upstream returned Bearer abc123", "/orders"));
assertEquals("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)",
sanitizer.sanitizeText("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)", "/orders"));
}
@Test
void malformedObjectsCyclesAndSerializationFailuresDoNotEscapeOrFallBackToRawValues() {
Map<String, Object> cycle = new HashMap<>();
cycle.put("next", cycle);
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders"));
Map<String, Object> indirect = new HashMap<>();
indirect.put("parent", cycle);
cycle.put("bizCode", indirect);
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders"));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(new ExplodingBean(), "/orders"));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters("{\"nested\":\"{broken-password=secret\"}", "/orders"));
}
@Test
void doubleEncodedNestedJsonIsDecodedAndFilteredWithoutChangingBusinessJsonStrings() throws Exception {
String input = "{\"payload\":\"%257B%2522password%2522%253A%2522encoded-secret%2522%252C%2522id%2522%253A42%257D\",\"code\":\"BUS-42\"}";
JsonNode result = json.readTree(sanitizer.sanitizeParameters(input, "/orders"));
assertEquals(json.readTree("{\"id\":42}"), json.readTree(result.get("payload").textValue()));
assertEquals("BUS-42", result.get("code").textValue());
JsonNode form = json.readTree(sanitizer.sanitizeParameters("{\"payload\":\"password%253Dform-secret%2526id%253D42\"}", "/orders"));
assertEquals("id=42", form.get("payload").textValue());
assertEquals("/orders?note=a%26id%3D3", sanitizer.sanitizeUri("/orders?note=a%26id%3D3&token=secret"));
assertEquals("", sanitizer.sanitizeText("password%3Dencoded-error-secret", "/orders"));
}
@Test
void longBusinessValuesArePreservedWithoutUnboundedCredentialLabelMatching() {
String description = "a".repeat(100000);
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
String result = sanitizer.sanitizePayload(Map.of("description", description), "/orders");
assertTrue(result.contains(description));
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(Map.of("new".repeat(200) + "password", "secret"), "/orders"));
});
}
@Test
void configuredAdditionalFieldsCannotDisableMandatoryCredentialFiltering() throws Exception {
var customized = new AuditLogSanitizer(List.of("phone", "custom-field"));
JsonNode result = json.readTree(customized.sanitizeParameters(
"{\"phone\":\"123\",\"customField\":\"private\",\"PASSWORD\":\"secret\",\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}", "/orders"));
assertEquals(json.readTree("{\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}"), result);
}
public static class LoginBean {
public String getUsername() { return "operator"; }
public String getNewpassword1() { return "bean-secret"; }
}
public static class ExplodingBean {
public String getValue() { throw new IllegalStateException("getter-error-containing-private-secret"); }
}
}
@@ -35,76 +35,107 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* @author pjcloud * @author pjcloud
* @date 2019-06-24 * @date 2019-06-24
* <p> * <p>
* 解决Mybatis Plus Order By SQL注入问题 * 解决Mybatis Plus Order By SQL注入问题:ascs/descs 仅允许简单列名白名单,禁止表达式/函数注入。
*/ */
@Slf4j @Slf4j
public class SqlFilterArgumentResolver implements HandlerMethodArgumentResolver { public class SqlFilterArgumentResolver implements HandlerMethodArgumentResolver {
/**
* 排序字段白名单:仅允许标识符(可选表别名.列名),禁止空格、括号、逗号、注释等。
* 例:create_time、user_id、u.createTime
*/
private static final Pattern SAFE_ORDER_COLUMN = Pattern.compile(
"^[a-zA-Z_][a-zA-Z0-9_]{0,63}(\\.[a-zA-Z_][a-zA-Z0-9_]{0,63})?$");
/** /** 单次请求最多允许的排序列数量,防止恶意堆叠。 */
* 判断Controller是否包含page 参数 private static final int MAX_ORDER_COLUMNS = 5;
*
* @param parameter 参数
* @return 是否过滤
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(Page.class);
}
/** /**
* @param parameter 入参集合 * 判断Controller是否包含page 参数
* @param mavContainer model 和 view *
* @param webRequest web相关 * @param parameter 参数
* @param binderFactory 入参解析 * @return 是否过滤
* @return 检查后新的page对象 */
* <p> @Override
* page 只支持查询 GET .如需解析POST获取请求报文体处理 public boolean supportsParameter(MethodParameter parameter) {
*/ return parameter.getParameterType().equals(Page.class);
@Override }
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); /**
* @param parameter 入参集合
* @param mavContainer model 和 view
* @param webRequest web相关
* @param binderFactory 入参解析
* @return 检查后新的page对象
* <p>
* page 只支持查询 GET .如需解析POST获取请求报文体处理
*/
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
String ascs = request.getParameter("ascs"); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
String descs = request.getParameter("descs");
String current = request.getParameter("current"); String ascs = request.getParameter("ascs");
String size = request.getParameter("size"); String descs = request.getParameter("descs");
Page page = new Page(); String current = request.getParameter("current");
if (StrUtil.isNotBlank(current)) { String size = request.getParameter("size");
// 如果current page 小于零 视为不合法数据
if (CompareUtil.compare(Long.parseLong(current), 0L) < 0) {
throw new CheckedException("current page error");
}
page.setCurrent(Long.parseLong(current));
}
if (StrUtil.isNotBlank(size)) { Page page = new Page();
page.setSize(Long.parseLong(size)); if (StrUtil.isNotBlank(current)) {
} // 如果current page 小于零 视为不合法数据
if (CompareUtil.compare(Long.parseLong(current), 0L) < 0) {
throw new CheckedException("current page error");
}
page.setCurrent(Long.parseLong(current));
}
List<OrderItem> orderItemList = new ArrayList<>(); if (StrUtil.isNotBlank(size)) {
Optional.ofNullable(ascs) page.setSize(Long.parseLong(size));
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA)) }
.filter(asc -> !SqlInjectionUtils.check(asc))
.map(OrderItem::asc) List<OrderItem> orderItemList = new ArrayList<>();
.collect(Collectors.toList()))); Optional.ofNullable(ascs)
Optional.ofNullable(descs) .ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA))
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA)) .map(String::trim)
.filter(desc -> !SqlInjectionUtils.check(desc)) .filter(StrUtil::isNotBlank)
.map(OrderItem::desc) .map(col -> toSafeOrderColumn(col, true))
.collect(Collectors.toList()))); .collect(Collectors.toList())));
page.addOrder(orderItemList); Optional.ofNullable(descs)
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA))
.map(String::trim)
.filter(StrUtil::isNotBlank)
.map(col -> toSafeOrderColumn(col, false))
.collect(Collectors.toList())));
if (orderItemList.size() > MAX_ORDER_COLUMNS) {
throw new CheckedException("排序字段数量超限");
}
page.addOrder(orderItemList);
return page;
}
/**
* 将前端排序字段转为安全的 OrderItem:必须通过标识符白名单,且通过 SqlInjectionUtils 黑名单兜底。
*/
private static OrderItem toSafeOrderColumn(String column, boolean asc) {
if (!SAFE_ORDER_COLUMN.matcher(column).matches()) {
log.warn("拒绝非法排序字段: {}", column);
throw new CheckedException("排序字段不合法");
}
if (SqlInjectionUtils.check(column)) {
log.warn("排序字段命中注入检测: {}", column);
throw new CheckedException("排序字段不合法");
}
return asc ? OrderItem.asc(column) : OrderItem.desc(column);
}
return page;
}
} }
@@ -48,6 +48,11 @@ public class TenantContextHolder {
THREAD_LOCAL_TENANT_SKIP_FLAG.set(Boolean.TRUE); THREAD_LOCAL_TENANT_SKIP_FLAG.set(Boolean.TRUE);
} }
/** Restore only the skip flag without clearing the caller's tenant. */
public void setTenantSkip(boolean skip) {
THREAD_LOCAL_TENANT_SKIP_FLAG.set(skip);
}
/** /**
* 获取TTL中的租户ID * 获取TTL中的租户ID
* @return * @return
@@ -0,0 +1,62 @@
# 导出默认样式 token
`ExcelStyleTokens` 提供全局默认样式,配置前缀为 `excel.export-style`,可放在服务配置或 Nacos 公共配置中。未配置的值使用代码默认值,表头与正文可以分别设置字体、字号、加粗、文字颜色、背景颜色、行高、对齐、换行和缩进。
`/admin/user/export-async` 和日志导出(`/admin/log/export`、`/admin/log/export-async`)已接入。原同步用户导出及未显式接入处理器的业务导出不受影响,已有模板无需修改。
```yaml
excel:
export-style:
header:
font-name: Microsoft YaHei
font-size: 11
bold: true
font-color: "#FFFFFF"
background-color: "#1F4E78"
row-height: 36
alignment: CENTER
wrap-text: true
indent: 0
body:
font-name: Microsoft YaHei
font-size: 11
bold: false
font-color: "#243247"
background-color: "#FFFFFF"
row-height: 26
alignment: LEFT
wrap-text: false
indent: 1
striped: true
alternate-row-color: "#F3F6FA"
border-style: THIN
border-color: "#DCE3EC"
column-width: 22
# 可选:按 Java 字段名覆盖业务默认列宽。[] 保留字段名大小写。
column-widths:
"[userId]": 24
"[username]": 20
"[deptName]": 28
"[roleNameList]": 30
freeze-header: true
auto-filter: true
show-grid-lines: false
```
颜色支持带或不带 `#` 的六位 RGB;YAML 中带 `#` 的颜色必须加引号。字号与行高单位为磅,列宽单位为 Excel 字符宽度(1~255)。配置绑定时校验颜色及尺寸。字体由打开 Excel 的电脑解析,可改为该电脑已安装的中文字体。边框样式与颜色应用于表头列分隔和正文水平分隔,正文不绘制完整网格。
列宽优先级:配置中的 `column-widths` > 业务默认列宽 > `column-width`。用户导出已按编号、手机、邮箱、部门、角色和日期等字段分别设置列宽,不扫描全部明细来计算列宽。长文本保持原值,默认正文不换行;需要多行显示时可同时增加 `body.row-height` 并开启 `body.wrap-text`。开启交替行底纹时,偶数数据行使用 `alternate-row-color`,其余数据行使用 `body.background-color`。
其他导出可显式注册处理器来复用样式:
```java
builder.registerWriteHandler(new ExcelExportStyleHandler(excelConfig.getExportStyle()));
```
每次导出创建独立处理器,在提交任务时固定当前 token。处理器只支持 XLSX,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。
日志导出使用独立的 `SysLogExcelVO`,保留原有信息并按创建时间、类型、标题、操作人、应用、请求方式、请求地址、耗时、IP、提交数据、异常、用户代理、编号的顺序展示。类型显示“正常/错误”,请求地址和用户代理使用明确的中文表头;创建时间保存为可排序的日期单元格,耗时为数字,编号为保留完整精度的文本。报告顶部展示创建时间范围,表头固定为一行,长文本自动换行并适度增加行高,原文保留供展开查看。横向滚动时冻结时间和类型,纵向滚动时冻结报告上下文及表头。
日志导出必须选择完整的创建时间范围,结束时间不得早于开始时间,也不得超过开始时间加一个自然月;前端和同步、异步服务端接口均校验,未通过时不查询日志或创建任务。自然月按月底截断规则计算,例如 1 月 31 日 08:30 到 2 月 28 日 08:30 允许,超过一秒即拒绝。列表查询本身不受导出时间限制。
每批按编号倒序读取 200 条,仅首批统计总数,后续使用编号游标;新增的更大编号不会挤偏后续批次。旧 GET `/admin/log/export` 仍直接返回附件,新 POST `/admin/log/export-async` 返回任务信息,使用 `/admin/async-task/progress` 和命名空间 `admin:export:log` 查询进度。两个接口均要求 `sys_log_export` 权限,超出单工作表行数限制时提示缩小筛选范围。
@@ -1,13 +1,19 @@
package com.bsd.pjcloud.common.excel.config; package com.bsd.pjcloud.common.excel.config;
import com.bsd.pjcloud.common.excel.style.ExcelStyleTokens;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import org.springframework.validation.annotation.Validated;
/** /**
* @author pjcloud * @author pjcloud
* @date 2020/3/29 * @date 2020/3/29
*/ */
@Data @Data
@Validated
@ConfigurationProperties(prefix = ExcelConfigProperties.PREFIX) @ConfigurationProperties(prefix = ExcelConfigProperties.PREFIX)
public class ExcelConfigProperties { public class ExcelConfigProperties {
@@ -18,4 +24,12 @@ public class ExcelConfigProperties {
*/ */
private String templatePath = "excel"; private String templatePath = "excel";
/**
* 全局导出样式 token。只有显式注册 ExcelExportStyleHandler 的导出才会应用。
*/
@Valid
@NotNull
@NestedConfigurationProperty
private ExcelStyleTokens exportStyle = new ExcelStyleTokens();
} }
@@ -0,0 +1,617 @@
package com.bsd.pjcloud.common.excel.stream;
import org.apache.poi.ss.SpreadsheetVersion;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
/**
* 基于 XLSX 模板的通用分批流式导出模板。
*
* <p>该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供
* 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心,
* 可由不同业务按需组合。</p>
*/
public final class BatchExcelExportTemplate {
/** Excel 2007 单个工作表支持的最大行数。 */
private static final int XLSX_MAX_ROW_COUNT = SpreadsheetVersion.EXCEL2007.getMaxRows();
/** 默认分页大小和 SXSSF 内存行窗口大小。 */
private static final int DEFAULT_BATCH_SIZE = 1000;
/** 默认临时文件名前缀,必须满足 JDK 至少三个字符的约束。 */
private static final String DEFAULT_TEMPORARY_FILE_PREFIX = "batch-excel-export-";
/** 公共导出模板日志记录器,仅记录临时文件清理失败等基础设施异常。 */
private static final Logger LOGGER = LoggerFactory.getLogger(BatchExcelExportTemplate.class);
/**
* 工具类不允许实例化。
*/
private BatchExcelExportTemplate() {
}
/**
* 创建一次分批导出的链式配置器。
*
* @param expectedTotal 导出前按相同筛选条件统计的明细总数,必须大于零
* @param <T> 分页查询返回的业务行类型
* @param <C> 模板初始化后供逐行及尾部写入共享的上下文类型
* @param <R> 最终文件消费回调的返回类型
* @return 尚未执行的导出配置器
*/
public static <T, C, R> Builder<T, C, R> builder(long expectedTotal) {
return new Builder<>(expectedTotal);
}
/**
* 根据总记录数和分页大小计算实际查询批次数。
*
* @param expectedTotal 导出前预统计的明细总数,必须大于零
* @param batchSize 单次分页查询大小,必须大于零
* @return 向上取整后的批次数
*/
public static int calculateBatchCount(long expectedTotal, int batchSize) {
validatePositive(expectedTotal, "导出总数必须大于零");
validatePositive(batchSize, "导出批大小必须大于零");
long batchCount = (expectedTotal - 1) / batchSize + 1;
if (batchCount > Integer.MAX_VALUE) {
throw new IllegalArgumentException("导出批次数超过整数上限");
}
return (int) batchCount;
}
/**
* 计算当前 XLSX 工作表在指定布局下最多可写入的明细行数。
*
* @param dataStartRowIndex 明细开始写入的 Excel 零基行号
* @param reservedTailRowCount 明细之后为合计、备注等内容预留的行数
* @return 不超过 Excel 2007 单工作表上限的最大明细行数
*/
public static int calculateMaxDetailRowCount(int dataStartRowIndex, int reservedTailRowCount) {
if (dataStartRowIndex < 0) {
throw new IllegalArgumentException("导出明细起始行不得小于零");
}
if (reservedTailRowCount < 0) {
throw new IllegalArgumentException("导出尾部预留行数不得小于零");
}
long maxDetailRowCount = (long) XLSX_MAX_ROW_COUNT - dataStartRowIndex - reservedTailRowCount;
if (maxDetailRowCount < 0) {
throw new IllegalArgumentException("导出固定行和尾部预留行超过单个工作表上限");
}
return (int) maxDetailRowCount;
}
/**
* 计算异步任务可使用的总进度步数。
*
* <p>每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。</p>
*
* @param expectedTotal 导出前预统计的明细总数,必须大于零
* @param batchSize 单次分页查询大小,必须大于零
* @param extraSteps 文件上传等额外步骤数量,不得小于零
* @return 批次数与额外步骤之和
*/
public static int calculateProgressTotal(long expectedTotal, int batchSize, int extraSteps) {
if (extraSteps < 0) {
throw new IllegalArgumentException("导出额外进度步数不得小于零");
}
long progressTotal = (long) calculateBatchCount(expectedTotal, batchSize) + extraSteps;
if (progressTotal > Integer.MAX_VALUE) {
throw new IllegalArgumentException("导出进度总步数超过整数上限");
}
return (int) progressTotal;
}
/**
* 校验正整数参数。
*
* @param value 待校验数值
* @param message 参数非法时使用的异常信息
*/
private static void validatePositive(long value, String message) {
if (value <= 0) {
throw new IllegalArgumentException(message);
}
}
/**
* 打开模板输入流的回调,返回的流由公共导出模板负责关闭。
*/
@FunctionalInterface
public interface TemplateSupplier {
/**
* 打开本次导出使用的 XLSX 模板。
*
* @return 非空的模板输入流
* @throws Exception 模板不存在或无法读取时抛出
*/
InputStream open() throws Exception;
}
/**
* 模板初始化回调,用于读取样式、替换标题和移除占位行。
*
* @param <C> 后续逐行和尾部写入共享的上下文类型
*/
@FunctionalInterface
public interface TemplateInitializer<C> {
/**
* 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。
*
* @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理
* @return 后续写入共享的业务上下文,允许为空
* @throws Exception 模板结构不符合业务约束时抛出
*/
C initialize(XSSFWorkbook workbook) throws Exception;
}
/**
* 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。
*
* @param <T> 业务行类型
*/
@FunctionalInterface
public interface PageFetcher<T> {
/**
* 查询指定页的数据。
*
* @param pageNumber 从一开始的页码
* @param batchSize 单页最大记录数
* @return 当前页记录;无记录时可返回空列表或空值
* @throws Exception 数据查询失败时抛出
*/
List<T> fetch(long pageNumber, int batchSize) throws Exception;
}
/**
* 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。
*
* @param <T> 业务行类型
* @param <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface RowWriter<T, C> {
/**
* 将一条业务记录写入指定工作表行。
*
* @param sheet SXSSF 流式工作表
* @param rowIndex Excel 零基行号
* @param dataIndex 当前记录在全部明细中的零基序号
* @param item 当前业务记录
* @param context 模板初始化产生的共享上下文,允许为空
* @throws Exception 数据转换或行写入失败时抛出
*/
void write(SXSSFSheet sheet, int rowIndex, long dataIndex, T item, C context) throws Exception;
}
/**
* 明细写完后的尾部写入回调,可用于合计、签章或备注行。
*
* @param <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface FooterWriter<C> {
/**
* 从预留尾部区域的第一行开始写入业务内容。
*
* @param sheet SXSSF 流式工作表
* @param firstFooterRowIndex 尾部区域第一行的 Excel 零基行号
* @param processedTotal 已成功写入的明细总数
* @param context 模板初始化产生的共享上下文,允许为空
* @throws Exception 尾部内容写入失败时抛出
*/
void write(SXSSFSheet sheet, int firstFooterRowIndex, long processedTotal, C context) throws Exception;
}
/**
* 每批数据完整写入后的进度通知回调。
*/
@FunctionalInterface
public interface ProgressListener {
/**
* 报告已完成写入的明细数量。
*
* @param processedTotal 已完成写入的明细总数
* @param expectedTotal 导出前预统计的明细总数
* @throws Exception 进度上报失败且需要终止导出时抛出
*/
void onBatchCompleted(long processedTotal, long expectedTotal) throws Exception;
}
/**
* 完整 XLSX 临时文件的消费回调,可用于上传文件中心或移动到业务目录。
*
* @param <R> 文件消费结果类型
*/
@FunctionalInterface
public interface FileConsumer<R> {
/**
* 在临时文件仍然存在时消费完整 XLSX 文件。
*
* @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有
* @return 文件上传地址等业务结果
* @throws Exception 文件读取、上传或移动失败时抛出
*/
R consume(Path file) throws Exception;
}
/**
* 单次分批流式导出的链式配置器。
*
* <p>配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。</p>
*
* @param <T> 分页查询返回的业务行类型
* @param <C> 模板共享上下文类型
* @param <R> 完整文件消费结果类型
*/
public static final class Builder<T, C, R> {
/** 导出前按相同筛选条件统计的明细总数。 */
private final long expectedTotal;
/** 单次分页查询大小,同时作为 SXSSF 内存行窗口大小。 */
private int batchSize = DEFAULT_BATCH_SIZE;
/** 明细开始写入的 Excel 零基行号。 */
private int dataStartRowIndex;
/** 明细之后为合计、备注等内容预留的行数。 */
private int reservedTailRowCount;
/** 目标工作表的零基下标。 */
private int sheetIndex;
/** 最终 XLSX 临时文件的文件名前缀。 */
private String temporaryFilePrefix = DEFAULT_TEMPORARY_FILE_PREFIX;
/** 是否压缩 SXSSF 生成的工作表临时 XML 文件,以磁盘计算换取较低临时空间占用。 */
private boolean compressTemporaryFiles = true;
/** 是否使用共享字符串表;默认关闭以避免大量唯一文本占用堆内存。 */
private boolean useSharedStringsTable;
/** 业务提供的模板输入流工厂。 */
private TemplateSupplier templateSupplier;
/** 业务提供的模板初始化回调。 */
private TemplateInitializer<C> templateInitializer;
/** 业务提供的分页查询回调。 */
private PageFetcher<T> pageFetcher;
/** 业务提供的逐行写入回调。 */
private RowWriter<T, C> rowWriter;
/** 可选的业务尾部写入回调。 */
private FooterWriter<C> footerWriter;
/** 可选的批次进度通知回调。 */
private ProgressListener progressListener;
/** 业务提供的完整文件消费回调。 */
private FileConsumer<R> fileConsumer;
/**
* 创建单次导出配置器。
*
* @param expectedTotal 导出前预统计的明细总数
*/
private Builder(long expectedTotal) {
this.expectedTotal = expectedTotal;
}
/**
* 设置分页大小和 SXSSF 内存行窗口大小。
*
* @param batchSize 正整数批大小
* @return 当前配置器
*/
public Builder<T, C, R> batchSize(int batchSize) {
this.batchSize = batchSize;
return this;
}
/**
* 设置明细开始写入的 Excel 零基行号。
*
* @param dataStartRowIndex 非负的零基行号
* @return 当前配置器
*/
public Builder<T, C, R> dataStartRowIndex(int dataStartRowIndex) {
this.dataStartRowIndex = dataStartRowIndex;
return this;
}
/**
* 设置明细之后必须预留的尾部行数。
*
* @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数
* @return 当前配置器
*/
public Builder<T, C, R> reservedTailRowCount(int reservedTailRowCount) {
this.reservedTailRowCount = reservedTailRowCount;
return this;
}
/**
* 设置目标工作表下标。
*
* @param sheetIndex 非负的工作表零基下标
* @return 当前配置器
*/
public Builder<T, C, R> sheetIndex(int sheetIndex) {
this.sheetIndex = sheetIndex;
return this;
}
/**
* 设置最终 XLSX 临时文件的文件名前缀。
*
* @param temporaryFilePrefix 至少三个字符的文件名前缀
* @return 当前配置器
*/
public Builder<T, C, R> temporaryFilePrefix(String temporaryFilePrefix) {
this.temporaryFilePrefix = temporaryFilePrefix;
return this;
}
/**
* 设置是否压缩 SXSSF 的临时 XML 文件。
*
* @param compressTemporaryFiles 是否启用临时 XML 压缩
* @return 当前配置器
*/
public Builder<T, C, R> compressTemporaryFiles(boolean compressTemporaryFiles) {
this.compressTemporaryFiles = compressTemporaryFiles;
return this;
}
/**
* 设置是否启用共享字符串表。
*
* @param useSharedStringsTable 是否把字符串集中写入共享表
* @return 当前配置器
*/
public Builder<T, C, R> useSharedStringsTable(boolean useSharedStringsTable) {
this.useSharedStringsTable = useSharedStringsTable;
return this;
}
/**
* 设置模板输入流工厂。
*
* @param templateSupplier 每次执行时打开一个新模板流的回调
* @return 当前配置器
*/
public Builder<T, C, R> templateSupplier(TemplateSupplier templateSupplier) {
this.templateSupplier = templateSupplier;
return this;
}
/**
* 设置模板初始化回调。
*
* @param templateInitializer 模板样式读取和占位内容清理回调
* @return 当前配置器
*/
public Builder<T, C, R> templateInitializer(TemplateInitializer<C> templateInitializer) {
this.templateInitializer = templateInitializer;
return this;
}
/**
* 设置分页查询回调。
*
* @param pageFetcher 按页码和批大小查询业务数据的回调
* @return 当前配置器
*/
public Builder<T, C, R> pageFetcher(PageFetcher<T> pageFetcher) {
this.pageFetcher = pageFetcher;
return this;
}
/**
* 设置逐行写入回调。
*
* @param rowWriter 将单条业务数据写入工作表的回调
* @return 当前配置器
*/
public Builder<T, C, R> rowWriter(RowWriter<T, C> rowWriter) {
this.rowWriter = rowWriter;
return this;
}
/**
* 设置可选的尾部写入回调。
*
* @param footerWriter 明细写完后写入合计、备注等内容的回调
* @return 当前配置器
*/
public Builder<T, C, R> footerWriter(FooterWriter<C> footerWriter) {
this.footerWriter = footerWriter;
return this;
}
/**
* 设置可选的批次进度通知回调。
*
* @param progressListener 每批数据完整写入后的通知回调
* @return 当前配置器
*/
public Builder<T, C, R> progressListener(ProgressListener progressListener) {
this.progressListener = progressListener;
return this;
}
/**
* 设置最终 XLSX 文件消费回调。
*
* @param fileConsumer 上传、移动或读取最终临时文件的回调
* @return 当前配置器
*/
public Builder<T, C, R> fileConsumer(FileConsumer<R> fileConsumer) {
this.fileConsumer = fileConsumer;
return this;
}
/**
* 执行分批查询、流式写入和最终文件消费。
*
* @return 文件消费回调返回的业务结果
* @throws Exception 模板读取、查询、写入、进度上报或文件消费失败时抛出
*/
public R execute() throws Exception {
validate();
Path outputPath = null;
try {
outputPath = Files.createTempFile(temporaryFilePrefix, ".xlsx");
writeWorkbook(outputPath);
return fileConsumer.consume(outputPath);
}
finally {
deleteTemporaryFile(outputPath);
}
}
/**
* 校验所有基础参数和必需回调,避免进入写入流程后才暴露配置错误。
*/
private void validate() {
validatePositive(expectedTotal, "导出总数必须大于零");
validatePositive(batchSize, "导出批大小必须大于零");
if (dataStartRowIndex < 0) {
throw new IllegalArgumentException("导出明细起始行不得小于零");
}
if (reservedTailRowCount < 0) {
throw new IllegalArgumentException("导出尾部预留行数不得小于零");
}
if (sheetIndex < 0) {
throw new IllegalArgumentException("导出工作表下标不得小于零");
}
if (temporaryFilePrefix == null || temporaryFilePrefix.length() < 3) {
throw new IllegalArgumentException("导出临时文件名前缀不得少于三个字符");
}
int maxDetailRowCount = calculateMaxDetailRowCount(dataStartRowIndex, reservedTailRowCount);
if (expectedTotal > maxDetailRowCount) {
throw new IllegalArgumentException("导出明细数超过当前工作表可容纳的 " + maxDetailRowCount + " 条");
}
Objects.requireNonNull(templateSupplier, "导出模板输入流工厂不能为空");
Objects.requireNonNull(templateInitializer, "导出模板初始化回调不能为空");
Objects.requireNonNull(pageFetcher, "导出分页查询回调不能为空");
Objects.requireNonNull(rowWriter, "导出逐行写入回调不能为空");
Objects.requireNonNull(fileConsumer, "导出文件消费回调不能为空");
}
/**
* 创建并写出 SXSSF 工作簿,同时保证其工作表临时 XML 被释放。
*
* @param outputPath 最终 XLSX 临时文件路径
* @throws Exception 模板、查询或写入任一环节失败时抛出
*/
private void writeWorkbook(Path outputPath) throws Exception {
try (InputStream template = Objects.requireNonNull(templateSupplier.open(), "导出模板输入流不能为空");
XSSFWorkbook templateWorkbook = new XSSFWorkbook(template)) {
C context = templateInitializer.initialize(templateWorkbook);
SXSSFWorkbook workbook = new SXSSFWorkbook(templateWorkbook, batchSize, compressTemporaryFiles,
useSharedStringsTable);
workbook.setShouldCalculateSheetDimensions(true);
try (workbook; OutputStream output = Files.newOutputStream(outputPath)) {
writeRows(workbook.getSheetAt(sheetIndex), context);
workbook.write(output);
}
finally {
// close 关闭压缩包句柄,dispose 负责删除 SXSSF 为各工作表创建的临时 XML。
workbook.dispose();
}
}
}
/**
* 按页读取并逐行写入数据,最后校验实际数量与预统计数量一致。
*
* @param sheet 目标 SXSSF 工作表
* @param context 模板初始化产生的共享上下文
* @throws Exception 查询、行写入或尾部写入失败时抛出
*/
private void writeRows(SXSSFSheet sheet, C context) throws Exception {
long processedTotal = 0;
long pageNumber = 1;
while (true) {
List<T> rows = pageFetcher.fetch(pageNumber++, batchSize);
if (rows == null || rows.isEmpty()) {
break;
}
if (rows.size() > batchSize) {
throw new IllegalStateException("导出分页查询返回数量超过配置的批大小");
}
if (processedTotal + rows.size() > expectedTotal) {
throw dataChangedException(processedTotal + rows.size());
}
for (T item : rows) {
int rowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal);
rowWriter.write(sheet, rowIndex, processedTotal, item, context);
processedTotal++;
}
if (progressListener != null) {
progressListener.onBatchCompleted(processedTotal, expectedTotal);
}
if (rows.size() < batchSize && processedTotal < expectedTotal) {
break;
}
}
if (processedTotal != expectedTotal) {
throw dataChangedException(processedTotal);
}
if (footerWriter != null) {
int footerRowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal);
footerWriter.write(sheet, footerRowIndex, processedTotal, context);
}
}
/**
* 创建导出期间数据变化异常,统一向业务方暴露预期数量和实际数量。
*
* @param actualTotal 当前已读取或即将写入的实际数量
* @return 可直接抛出的状态异常
*/
private IllegalStateException dataChangedException(long actualTotal) {
return new IllegalStateException("导出数据在执行期间发生变化,预期 " + expectedTotal + " 条,实际 "
+ actualTotal + " 条");
}
/**
* 删除公共模板创建的最终 XLSX 临时文件,清理失败仅记录告警,避免覆盖原始业务结果。
*
* @param outputPath 待删除的临时文件路径,允许为空
*/
private void deleteTemporaryFile(Path outputPath) {
if (outputPath == null) {
return;
}
try {
Files.deleteIfExists(outputPath);
}
catch (Exception exception) {
LOGGER.warn("|批量Excel导出|清理最终临时文件失败,path:{}", outputPath, exception);
}
}
}
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.common.excel.stream;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.*;
/** 文件中心上传适配;本地上传直接读取临时文件流,不使用测试库的 MockMultipartFile。 */
public final class ExportMultipartFile implements MultipartFile {
/** 完整且已关闭的导出文件。 */
private final Path path;
/** 文件中心保存的业务文件名。 */
private final String fileName;
/** 绑定本次导出文件;调用方负责在上传完成后清理文件。 */
public ExportMultipartFile(Path path, String fileName) { this.path = path; this.fileName = fileName; }
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return fileName; }
@Override public String getContentType() { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; }
@Override public boolean isEmpty() { return getSize() == 0; }
@Override public long getSize() { try { return Files.size(path); } catch (IOException exception) { throw new UncheckedIOException(exception); } }
@Override public byte[] getBytes() throws IOException { return Files.readAllBytes(path); }
@Override public InputStream getInputStream() throws IOException { return Files.newInputStream(path); }
@Override public void transferTo(File destination) throws IOException { Files.copy(path, destination.toPath(), StandardCopyOption.REPLACE_EXISTING); }
}
@@ -0,0 +1,182 @@
package com.bsd.pjcloud.common.excel.stream;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 保留既有占位符模板布局的分批工作簿;仅缓存模板行及一个数据批次。 */
public final class TemplateBatchWorkbook {
/** 同时识别列表占位符与标题、合计占位符。 */
private static final Pattern TOKEN = Pattern.compile("\\{(\\.?[^{}]+)}");
private TemplateBatchWorkbook() { }
/**
* 按原模板流式生成完整文件,文件消费者返回后自动清理临时文件。
* values 可由逐行转换器累计合计;只有写入尾部时才读取最终合计。
*/
public static <T, R> R write(BatchExcelExportTemplate.TemplateSupplier template, long total, int batchSize,
Map<String, Object> values, BatchExcelExportTemplate.PageFetcher<T> fetcher,
Function<T, Map<String, Object>> converter,
BatchExcelExportTemplate.ProgressListener progress,
BatchExcelExportTemplate.FileConsumer<R> consumer,
Consumer<XSSFWorkbook> customizer) throws Exception {
int start;
int tail;
// 先读取很小的模板确定物理行数上限,不能把大工作簿加载进 XSSF。
try (InputStream input = template.open(); XSSFWorkbook workbook = new XSSFWorkbook(input)) {
customizer.accept(workbook);
start = findDetailRow(workbook.getSheetAt(0));
tail = workbook.getSheetAt(0).getLastRowNum() - start;
}
return BatchExcelExportTemplate.<T, Layout, R>builder(total)
.batchSize(batchSize).dataStartRowIndex(start).reservedTailRowCount(tail)
.templateSupplier(template)
.templateInitializer(workbook -> {
customizer.accept(workbook);
return prepare(workbook, values);
})
.pageFetcher(fetcher)
.rowWriter((sheet, row, index, item, layout) -> {
Map<String, Object> data = converter.apply(item);
data.putIfAbsent("lineNumber", index + 1);
copyRow(sheet.createRow(row), layout.detail, data, layout);
})
.footerWriter((sheet, row, count, layout) -> {
for (int i = 0; i < layout.footer.size(); i++) {
copyRow(sheet.createRow(row + i), layout.footer.get(i), values, layout);
}
for (CellRangeAddress merge : layout.tailMerges) {
int delta = row - layout.start - 1;
sheet.addMergedRegion(new CellRangeAddress(merge.getFirstRow() + delta,
merge.getLastRow() + delta, merge.getFirstColumn(), merge.getLastColumn()));
}
}).progressListener(progress).fileConsumer(consumer).execute();
}
/** 从列表占位符定位明细行,拒绝误用无明细模板。 */
private static int findDetailRow(Sheet sheet) {
for (Row row : sheet) {
for (Cell cell : row) {
if (cell.getCellType() == CellType.STRING && cell.getStringCellValue().contains("{.")) {
return row.getRowNum();
}
}
}
throw new IllegalArgumentException("导出模板缺少明细占位行");
}
/** 缓存原始样式和合计布局,移除占位区域后交给 SXSSF 顺序写入。 */
private static Layout prepare(XSSFWorkbook workbook, Map<String, Object> values) {
Sheet sheet = workbook.getSheetAt(0);
int start = findDetailRow(sheet);
Layout layout = new Layout(start, snapshot(sheet.getRow(start)), new ArrayList<>(), new ArrayList<>(), new HashMap<>());
for (int i = start + 1; i <= sheet.getLastRowNum(); i++) layout.footer.add(snapshot(sheet.getRow(i)));
for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
CellRangeAddress merge = sheet.getMergedRegion(i);
if (merge.getFirstRow() > start) {
layout.tailMerges.add(merge);
sheet.removeMergedRegion(i);
} else if (merge.getLastRow() >= start) {
throw new IllegalArgumentException("导出模板明细行不支持跨行合并");
}
}
for (int i = sheet.getLastRowNum(); i >= start; i--) {
Row row = sheet.getRow(i);
if (row != null) sheet.removeRow(row);
}
for (Row row : sheet) {
for (Cell cell : row) {
if (cell.getCellType() == CellType.STRING) {
putValue(cell, resolve(cell.getStringCellValue(), values), layout);
}
}
}
return layout;
}
/** 只保存模板的单元格值、样式及行高,不复制任何业务数据。 */
private static RowPattern snapshot(Row row) {
List<CellPattern> cells = new ArrayList<>();
if (row != null) {
for (Cell cell : row) {
Object value = switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue();
case NUMERIC -> cell.getNumericCellValue();
case BOOLEAN -> cell.getBooleanCellValue();
case FORMULA -> cell.getCellFormula();
default -> null;
};
cells.add(new CellPattern(cell.getColumnIndex(), cell.getCellStyle(), cell.getCellType(), value));
}
}
return new RowPattern(row == null ? (short) -1 : row.getHeight(), cells);
}
/** 逐行复用模板样式,避免创建与数据量成正比的 CellStyle。 */
private static void copyRow(Row row, RowPattern pattern, Map<String, Object> values, Layout layout) {
row.setHeight(pattern.height);
for (CellPattern source : pattern.cells) {
Cell cell = row.createCell(source.column);
cell.setCellStyle(source.style);
if (source.type == CellType.FORMULA) cell.setCellFormula((String) source.value);
else putValue(cell, source.value instanceof String text ? resolve(text, values) : source.value, layout);
}
}
/** 完整占位符保留数值类型;混合文本占位符沿用字符串结果。 */
private static Object resolve(String text, Map<String, Object> values) {
Matcher matcher = TOKEN.matcher(text);
if (matcher.matches()) return values.get(matcher.group(1).replaceFirst("^\\.", ""));
matcher.reset();
StringBuilder result = new StringBuilder();
while (matcher.find()) {
Object value = values.get(matcher.group(1).replaceFirst("^\\.", ""));
matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString()));
}
return matcher.appendTail(result).toString();
}
/** 按 EasyExcel 模板填充的默认类型写入,日期使用相同的日期格式。 */
private static void putValue(Cell cell, Object value, Layout layout) {
if (value == null) cell.setBlank();
else if (value instanceof Number number) cell.setCellValue(number.doubleValue());
else if (value instanceof Boolean bool) cell.setCellValue(bool);
else if (value instanceof LocalDateTime date) {
cell.setCellValue(date);
applyDateStyle(cell, layout);
} else if (value instanceof LocalDate date) {
cell.setCellValue(date);
applyDateStyle(cell, layout);
} else cell.setCellValue(value.toString());
}
/** 日期样式按模板样式索引缓存,与原 EasyExcel 的默认显示格式一致。 */
private static void applyDateStyle(Cell cell, Layout layout) {
short original = cell.getCellStyle().getIndex();
CellStyle style = layout.dateStyles.computeIfAbsent(original, key -> {
CellStyle created = cell.getSheet().getWorkbook().createCellStyle();
created.cloneStyleFrom(cell.getCellStyle());
created.setDataFormat(cell.getSheet().getWorkbook().createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
return created;
});
cell.setCellStyle(style);
}
/** 模板单元格快照;样式直接属于当前工作簿。 */
private record CellPattern(int column, CellStyle style, CellType type, Object value) { }
/** 模板行快照,包括原行高。 */
private record RowPattern(short height, List<CellPattern> cells) { }
/** 单个工作簿的模板上下文,生命周期仅覆盖一次导出。 */
private record Layout(int start, RowPattern detail, List<RowPattern> footer,
List<CellRangeAddress> tailMerges, Map<Short, CellStyle> dateStyles) { }
}
@@ -0,0 +1,193 @@
package com.bsd.pjcloud.common.excel.style;
import com.alibaba.excel.constant.OrderConstant;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.handler.SheetWriteHandler;
import com.alibaba.excel.write.handler.WorkbookWriteHandler;
import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFFont;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* 可选的 XLSX 样式处理器,每次导出创建一个实例并注册在 ExcelWriterBuilder 上。
* <p>构造时固定 token;按原单元格样式和行类型缓存样式,字体仅创建两份。
* 不扫描明细、不按行创建 POI 样式,保留转换器产生的数据格式与单元格类型。</p>
*/
public class ExcelExportStyleHandler implements CellWriteHandler, SheetWriteHandler, WorkbookWriteHandler {
private final Appearance header;
private final Appearance body;
private final boolean striped;
private final byte[] alternateRowColor;
private final BorderStyle borderStyle;
private final byte[] borderColor;
private final int columnWidth;
private final Map<String, Double> columnWidths;
private final boolean freezeHeader;
private final boolean autoFilter;
private final boolean showGridLines;
private final Map<StyleKey, XSSFCellStyle> styles = new HashMap<>();
private final Map<Sheet, SheetLayout> layouts = new IdentityHashMap<>();
private XSSFFont headerFont;
private XSSFFont bodyFont;
public ExcelExportStyleHandler(ExcelStyleTokens tokens) {
this(tokens, Map.of());
}
public ExcelExportStyleHandler(ExcelStyleTokens tokens, Map<String, Double> defaultColumnWidths) {
header = Appearance.of(tokens.getHeader());
body = Appearance.of(tokens.getBody());
striped = tokens.isStriped();
alternateRowColor = rgb(tokens.getAlternateRowColor());
borderStyle = tokens.getBorderStyle();
borderColor = rgb(tokens.getBorderColor());
columnWidth = tokens.getColumnWidth();
Map<String, Double> widths = new HashMap<>(defaultColumnWidths);
widths.putAll(tokens.getColumnWidths());
columnWidths = Map.copyOf(widths);
freezeHeader = tokens.isFreezeHeader();
autoFilter = tokens.isAutoFilter();
showGridLines = tokens.isShowGridLines();
}
@Override
public int order() {
// 在 EasyExcel 样式及 @ColumnWidth 应用之后调整,保留原数据格式。
return OrderConstant.FILL_STYLE + 1;
}
@Override
public void afterWorkbookCreate(WriteWorkbookHolder holder) {
headerFont = createFont(holder.getWorkbook(), header);
bodyFont = createFont(holder.getWorkbook(), body);
}
@Override
public void afterSheetCreate(WriteWorkbookHolder workbook, WriteSheetHolder holder) {
Sheet sheet = holder.getSheet();
sheet.setDisplayGridlines(showGridLines);
sheet.setDefaultColumnWidth(columnWidth);
sheet.setDefaultRowHeightInPoints(body.rowHeight());
layouts.put(sheet, new SheetLayout());
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
if (context.getHead() == null) return;
Cell cell = context.getCell();
Sheet sheet = cell.getSheet();
SheetLayout layout = layouts.get(sheet);
boolean isHeader = Boolean.TRUE.equals(context.getHead());
if (isHeader) {
layout.lastHeaderRow = Math.max(layout.lastHeaderRow, cell.getRowIndex());
String field = context.getHeadData() == null ? null : context.getHeadData().getFieldName();
double width = field == null ? columnWidth : columnWidths.getOrDefault(field, (double) columnWidth);
layout.columnWidths.put(cell.getColumnIndex(), (int) Math.round(width * 256));
}
if (layout.lastStyledRow != cell.getRowIndex()) {
cell.getRow().setHeightInPoints(isHeader ? header.rowHeight() : body.rowHeight());
layout.lastStyledRow = cell.getRowIndex();
}
layout.lastRow = Math.max(layout.lastRow, cell.getRowIndex());
layout.lastColumn = Math.max(layout.lastColumn, cell.getColumnIndex());
// relativeRowIndex 会在下一批重置,条纹必须使用工作表绝对行号。
int kind = 1;
if (isHeader) kind = 0;
else if (striped && (cell.getRowIndex() - layout.lastHeaderRow - 1) % 2 == 1) kind = 2;
CellStyle base = cell.getCellStyle();
StyleKey key = new StyleKey(base.getIndex(), kind);
int styleKind = kind;
cell.setCellStyle(styles.computeIfAbsent(key, ignored -> createStyle(sheet.getWorkbook(), base, styleKind)));
}
@Override
public void afterWorkbookDispose(WriteWorkbookHolder holder) {
// EasyExcel 在序列化文件前回调;使用记录的边界,兼容已经刷盘的 SXSSF 行。
try {
layouts.forEach((sheet, layout) -> {
// @ColumnWidth 在每个正文单元格回调中也会执行,因此最后统一设置列宽。
layout.columnWidths.forEach(sheet::setColumnWidth);
if (layout.lastHeaderRow < 0) return;
if (freezeHeader) sheet.createFreezePane(0, layout.lastHeaderRow + 1);
if (autoFilter) sheet.setAutoFilter(new CellRangeAddress(
layout.lastHeaderRow, layout.lastRow, 0, layout.lastColumn));
});
} finally {
styles.clear();
layouts.clear();
headerFont = null;
bodyFont = null;
}
}
private XSSFCellStyle createStyle(Workbook workbook, CellStyle base, int kind) {
XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
style.cloneStyleFrom(base);
Appearance appearance = kind == 0 ? header : body;
style.setFont(kind == 0 ? headerFont : bodyFont);
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(new XSSFColor(kind == 2 ? alternateRowColor : appearance.backgroundColor(), null));
style.setAlignment(appearance.alignment());
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(appearance.wrapText());
style.setIndention((short) appearance.indent());
// 正文只保留水平分隔线,表头增加列分隔,避免密集网格干扰阅读。
style.setBorderTop(BorderStyle.NONE);
style.setBorderBottom(borderStyle);
style.setBorderLeft(kind == 0 ? borderStyle : BorderStyle.NONE);
style.setBorderRight(kind == 0 ? borderStyle : BorderStyle.NONE);
XSSFColor border = new XSSFColor(borderColor, null);
style.setTopBorderColor(border);
style.setBottomBorderColor(border);
style.setLeftBorderColor(border);
style.setRightBorderColor(border);
return style;
}
private static XSSFFont createFont(Workbook workbook, Appearance appearance) {
if (!(workbook.createFont() instanceof XSSFFont font)) {
throw new IllegalArgumentException("ExcelExportStyleHandler 仅支持 XLSX 导出");
}
font.setFontName(appearance.fontName());
font.setFontHeightInPoints((short) appearance.fontSize());
font.setBold(appearance.bold());
font.setColor(new XSSFColor(appearance.fontColor(), null));
return font;
}
private static byte[] rgb(String color) {
String value = color.startsWith("#") ? color.substring(1) : color;
int rgb = Integer.parseInt(value, 16);
return new byte[] { (byte) (rgb >> 16), (byte) (rgb >> 8), (byte) rgb };
}
private record StyleKey(int baseStyle, int kind) {}
private record Appearance(String fontName, int fontSize, boolean bold, byte[] fontColor,
byte[] backgroundColor, int rowHeight, HorizontalAlignment alignment,
boolean wrapText, int indent) {
private static Appearance of(ExcelStyleTokens.CellTokens tokens) {
return new Appearance(tokens.getFontName(), tokens.getFontSize(), tokens.isBold(),
rgb(tokens.getFontColor()), rgb(tokens.getBackgroundColor()), tokens.getRowHeight(),
tokens.getAlignment(), tokens.isWrapText(), tokens.getIndent());
}
}
private static class SheetLayout {
private final Map<Integer, Integer> columnWidths = new HashMap<>();
private int lastHeaderRow = -1;
private int lastStyledRow = -1;
private int lastRow;
private int lastColumn;
}
}
@@ -0,0 +1,102 @@
package com.bsd.pjcloud.common.excel.style;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.Data;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* XLSX 导出的全局默认样式,通过 excel.export-style 配置覆盖。
* <p>仅供显式接入的导出使用;表头与正文独立配置,颜色为六位 RGB。</p>
*/
@Data
public class ExcelStyleTokens {
@Valid
@NotNull
private CellTokens header = headerDefaults();
@Valid
@NotNull
private CellTokens body = new CellTokens();
/** 启用正文交替行底纹,按工作表实际行号计算,跨批次连续。 */
private boolean striped = true;
@NotNull
@Pattern(regexp = "#?[0-9a-fA-F]{6}")
private String alternateRowColor = "#F3F6FA";
@NotNull
private BorderStyle borderStyle = BorderStyle.THIN;
@NotNull
@Pattern(regexp = "#?[0-9a-fA-F]{6}")
private String borderColor = "#DCE3EC";
/** 列宽单位为 Excel 字符宽度,避免对海量明细执行自动列宽扫描。 */
@Min(1)
@Max(255)
private int columnWidth = 22;
/** 按 Java 字段名覆盖列宽,优先于业务导出的默认列宽。 */
@NotNull
private Map<String, @NotNull @DecimalMin("1") @DecimalMax("255") Double> columnWidths = new LinkedHashMap<>();
private boolean freezeHeader = true;
private boolean autoFilter = true;
private boolean showGridLines = false;
private static CellTokens headerDefaults() {
CellTokens header = new CellTokens();
header.setBold(true);
header.setFontColor("#FFFFFF");
header.setBackgroundColor("#1F4E78");
header.setRowHeight(36);
header.setAlignment(HorizontalAlignment.CENTER);
header.setWrapText(true);
header.setIndent(0);
return header;
}
/** 表头或正文的语义样式 token;行高与字号的单位均为磅。 */
@Data
public static class CellTokens {
@NotBlank
private String fontName = "Microsoft YaHei";
@Min(1)
@Max(409)
private int fontSize = 11;
private boolean bold = false;
@NotNull
@Pattern(regexp = "#?[0-9a-fA-F]{6}")
private String fontColor = "#243247";
@NotNull
@Pattern(regexp = "#?[0-9a-fA-F]{6}")
private String backgroundColor = "#FFFFFF";
@Min(1)
@Max(409)
private int rowHeight = 26;
@NotNull
private HorizontalAlignment alignment = HorizontalAlignment.LEFT;
private boolean wrapText = false;
@Min(0)
@Max(15)
private int indent = 1;
}
}
@@ -0,0 +1,36 @@
{
"properties": [
{
"name": "excel.export-style.header.alignment",
"defaultValue": "CENTER"
},
{
"name": "excel.export-style.header.background-color",
"defaultValue": "#1F4E78"
},
{
"name": "excel.export-style.header.bold",
"defaultValue": true
},
{
"name": "excel.export-style.header.font-color",
"defaultValue": "#FFFFFF"
},
{
"name": "excel.export-style.header.indent",
"defaultValue": 0
},
{
"name": "excel.export-style.header.row-height",
"defaultValue": 36
},
{
"name": "excel.export-style.header.wrap-text",
"defaultValue": true
},
{
"name": "excel.export-style.body.alignment",
"defaultValue": "LEFT"
}
]
}
@@ -0,0 +1,14 @@
# SysLog 认证参数过滤
`AuditLogSanitizer` 是独立的日志过滤器,不修改业务请求、全局 ObjectMapper 或系统配置使用的 `JacksonSensitiveFieldUtil`。
- 密码、新旧及确认密码、验证码、OTP、`randomStr`、令牌、Authorization、客户端密钥、应用密钥、私钥等字段从结构化日志中删除。字段名匹配忽略大小写、下划线及连字符,支持嵌套字段、JSON 字符串和 URL 编码。
- 通用 `code` 只在登录认证请求、带验证码上下文的参数或 `SMS_NORMAL_CODE` 短信模板中去除。普通业务 `code`、`bizCode`、`roleCode` 和密码过期标记等字段保留。
- `pjcloud.log.exclude-fields` 继续作为附加排除规则。即使配置为空,也不会关闭认证凭据的必需过滤。原有日志开关和请求体采集开关继续生效。
- 新请求采集方法参数名并立即生成过滤后的副本,跳过 Cookie 参数、请求、响应、文件、流和 BindingResult;异步事件不携带原始请求体。监听器兼容旧事件,并在远程写入前再次过滤。
- 解析失败、截断、匿名参数值、循环引用、序列化失败、超过结构遍历限制或过滤后仍超过 `max-length` 的报文显示“参数已隐藏”,不回退为原文,也不打印可能带有秘密的解析异常消息。JSON 遍历深度上限为 32,节点上限为 10000。
- URL 查询参数和已知认证路径、异常文本中的可识别凭据也会过滤。正常错误说明和堆栈保留;无法安全判断边界时隐藏对应内容。
UPMS 的单条写入、前端错误日志批量写入、分页查询以及同步和异步 Excel 导出共用这组规则。查询与导出操作副本,不更新历史数据库;已有导出的文件不会被改写。数据库结构、API 结构及导出布局不变。
发布时先更新 UPMS,保护入库与历史日志读取,再更新认证及依赖 common-log 的日志生产服务。本实现只针对 SysLog,不修改其他日志表或应用运行日志。
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff. Show more