Compare commits
90
Commits
e7ecc2fd2c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af755b390e | ||
|
|
886d23e3db | ||
|
|
94a0cd5f92 | ||
|
|
be93d4ae50 | ||
|
|
9347410909 | ||
|
|
8b5b564292 | ||
|
|
e3ede5f5f2 | ||
|
|
a663c30028 | ||
|
|
b31a77334a | ||
|
|
18ebd245a5 | ||
|
|
26d968ec55 | ||
|
|
2c1bf9b967 | ||
|
|
bbde6cc87f | ||
|
|
bb0b8bd7ef | ||
|
|
710e4cfb90 | ||
|
|
42e0323080 | ||
|
|
0259445866 | ||
|
|
9f9e3c6d04 | ||
|
|
c0285edd4c | ||
|
|
3ae6fdbfb4 | ||
|
|
4a3c8c1938 | ||
|
|
f4c4390352 | ||
|
|
c34b249d7f | ||
|
|
b0b16ae58f | ||
|
|
8e5c8ea24c | ||
|
|
aa3897a651 | ||
|
|
da167a46ef | ||
|
|
7ae8d3e14b | ||
|
|
731a4f8b8d | ||
|
|
da549b19aa | ||
|
|
dd0c661223 | ||
|
|
cb8f5fc2e6 | ||
|
|
ddfc970928 | ||
|
|
9dbd345fc9 | ||
|
|
535ede3c6f | ||
|
|
c714612ace | ||
|
|
037ce89cca | ||
|
|
bea1783c7c | ||
|
|
10eec976c2 | ||
|
|
241e9b0c46 | ||
|
|
3effd6f9be | ||
|
|
b254ef0783 | ||
|
|
2ca14aa1e0 | ||
|
|
1c86c227ee | ||
|
|
1ecbcf540c | ||
|
|
8995bf89d6 | ||
|
|
e587cbc7cd | ||
|
|
7a2b991aa0 | ||
|
|
4e804a17cd | ||
|
|
287e8134db | ||
|
|
e260c015ab | ||
|
|
17015621e1 | ||
|
|
60446146f9 | ||
|
|
83fb0dd2a6 | ||
|
|
2b2969dc92 | ||
|
|
2560fb1ace | ||
|
|
be3be6179f | ||
|
|
f8f938b04c | ||
|
|
d1884d813f | ||
|
|
295077a1e8 | ||
|
|
880d19ec94 | ||
|
|
dc80a20b71 | ||
|
|
afd56a0586 | ||
|
|
0925f01b54 | ||
|
|
dae713eb0a | ||
|
|
33a8d5dd6a | ||
|
|
febc01042e | ||
|
|
1c11e78be0 | ||
|
|
66bedfc7f5 | ||
|
|
218ca53dd2 | ||
|
|
a857b1213c | ||
|
|
a1c466b18b | ||
|
|
1147ea1e48 | ||
|
|
17736d12e2 | ||
|
|
986ee20a9e | ||
|
|
dbf20d66d4 | ||
|
|
77d7700709 | ||
|
|
67823d9644 | ||
|
|
19e7b42c4f | ||
|
|
640a075742 | ||
|
|
f2f719d608 | ||
|
|
d29393692c | ||
|
|
41819feaa1 | ||
|
|
4faa7bb759 | ||
|
|
db02c79d9b | ||
|
|
df841a80f7 | ||
|
|
78aeb77cd6 | ||
|
|
cda3fff6f4 | ||
|
|
42e3e273fe | ||
|
|
fc820ba3b1 |
No files matched your search
@@ -61,3 +61,6 @@ gen_code
|
||||
|
||||
### node ###
|
||||
node_modules
|
||||
|
||||
# Local file-access credentials
|
||||
.file-access/
|
||||
@@ -86,6 +86,11 @@ CREATE TABLE `third_pay_log`
|
||||
COLLATE = utf8mb4_general_ci
|
||||
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
|
||||
-- ----------------------------
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ='终端日志文件';
|
||||
@@ -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='食堂超市终端心跳状态';
|
||||
@@ -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)。
|
||||
@@ -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秒扫描待生成/待补偿下发的打印任务)'
|
||||
);
|
||||
@@ -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秒。
|
||||
@@ -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 环境开关显式开启。
|
||||
@@ -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,通过业务编号定位少量流水。
|
||||
@@ -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='批量钱包入账幂等键,与余额和流水同事务提交';
|
||||
+151
-15
@@ -177,22 +177,83 @@ CREATE TABLE hard_group
|
||||
DROP TABLE IF EXISTS `hard_invoice`;
|
||||
CREATE TABLE hard_invoice
|
||||
(
|
||||
id bigint NOT NULL COMMENT '编号',
|
||||
dept_id bigint NOT NULL COMMENT '部门id',
|
||||
hard_id bigint NOT NULL COMMENT '设备id',
|
||||
order_id bigint NOT NULL COMMENT '硬件订单id',
|
||||
status char(10) NOT NULL COMMENT '状态',
|
||||
pic varchar(256) NOT 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)
|
||||
id bigint NOT NULL COMMENT '编号',
|
||||
dept_id bigint NOT NULL COMMENT '部门id',
|
||||
hard_id bigint DEFAULT NULL COMMENT '来源业务或核销终端id',
|
||||
printer_hard_id bigint DEFAULT NULL COMMENT '目标尔印打印终端id',
|
||||
order_id bigint NOT NULL COMMENT '硬件订单id',
|
||||
print_type char(2) NOT NULL DEFAULT '1' COMMENT '打印类型,1发票、2兑换券',
|
||||
print_no int NOT NULL DEFAULT 1 COMMENT '同订单同类型打印序号',
|
||||
retry_version int NOT NULL DEFAULT 0 COMMENT '人工失败重试版本',
|
||||
attempt_count int NOT NULL DEFAULT 0 COMMENT '终端USB累计尝试次数',
|
||||
status char(10) NOT NULL DEFAULT '0' COMMENT '状态,0待生成、1待打印、2已打印、3打印失败、4已取消',
|
||||
failure_stage varchar(32) DEFAULT NULL COMMENT '失败阶段',
|
||||
last_error varchar(2000) DEFAULT NULL COMMENT '最近失败摘要',
|
||||
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
|
||||
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
|
||||
@@ -311,6 +372,50 @@ CREATE TABLE hard_order
|
||||
PRIMARY KEY (id)
|
||||
) 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
|
||||
-- ----------------------------
|
||||
@@ -599,4 +704,35 @@ CREATE TABLE hard_third_mi_order
|
||||
del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记',
|
||||
tenant_id bigint DEFAULT NULL COMMENT '租户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 ='消费机日志';
|
||||
@@ -107,6 +107,7 @@ CREATE TABLE mch_manage
|
||||
address_des varchar(256) DEFAULT NULL COMMENT '商户地址',
|
||||
sort int 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 '合作状态',
|
||||
mch_count int NOT 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 '流水标题',
|
||||
price 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_time datetime 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 ='会员钱包流水表';
|
||||
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
|
||||
-- ----------------------------
|
||||
@@ -538,7 +553,9 @@ CREATE TABLE wallet_code_use
|
||||
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)
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_wcu_user_biz (user_id, biz_id),
|
||||
INDEX idx_wcu_biz (biz_id)
|
||||
) ENGINE = InnoDB
|
||||
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`,不再全表扫描。
|
||||
@@ -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 程序卡顿。
|
||||
@@ -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 推断视为旧安装包验收。
|
||||
@@ -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:}'}
|
||||
@@ -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` 密钥一致,再在实际环境重新生成发票验收。
|
||||
@@ -1,4 +1,7 @@
|
||||
MYSQL_HOST=123.157.250.162
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=33905
|
||||
MYSQL_USER=root@sys
|
||||
MYSQL_PWD=bsd.1799
|
||||
MYSQL_PWD=bsd.1799
|
||||
|
||||
NACOS_HOST=127.0.0.1
|
||||
NACOS_PORT=8848
|
||||
+1
@@ -15,6 +15,7 @@ public enum AppMenuPlatformEnum {
|
||||
|
||||
WECHAT("10", "微信小程序"),
|
||||
ZZD("30", "浙政钉"),
|
||||
SHPJ("40", "诗画浦江"),
|
||||
;
|
||||
|
||||
private final String type;
|
||||
|
||||
+30
-15
@@ -3,12 +3,10 @@ package com.bsd.pjcloud.app.controller;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
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.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.app.api.constant.menu.AppMenuPlatformEnum;
|
||||
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.common.core.constant.enums.YesNoEnum;
|
||||
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.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -30,6 +29,7 @@ import java.util.List;
|
||||
/**
|
||||
* 菜单管理
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/app-menu")
|
||||
@@ -42,12 +42,14 @@ public class AppMenuController {
|
||||
/* ************************************* start app start ************************************* */
|
||||
|
||||
/**
|
||||
* 列表差
|
||||
* App 端菜单列表(显式接收 enum,与 Feign 一致,避免 ParameterObject 继承 Entity 绑参歧义)
|
||||
*/
|
||||
@Operation(summary = "分页查询", description = "分页查询")
|
||||
@Operation(summary = "App菜单列表", description = "按平台/类型查询可见菜单")
|
||||
@GetMapping("/app/list")
|
||||
public R<List<AppMenuEntity>> appList(@ParameterObject AppMenuDto dto) {
|
||||
return listVisibleAppMenus(dto);
|
||||
public R<List<AppMenuEntity>> appList(
|
||||
@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)
|
||||
@Operation(summary = "内部-App菜单列表", description = "供 integrated 等经 Feign 拉取,与 /app-menu/app/list 一致")
|
||||
@GetMapping("/inner/app/list")
|
||||
public R<List<AppMenuEntity>> innerAppList(@ParameterObject AppMenuDto dto) {
|
||||
return listVisibleAppMenus(dto);
|
||||
public R<List<AppMenuEntity>> innerAppList(
|
||||
@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());
|
||||
if (ObjectUtil.isNotEmpty(dto.getPlatformEnum())) {
|
||||
dto.setPlatform(dto.getPlatformEnum().getType());
|
||||
/**
|
||||
* 仅按 platform/type/visible 查询,使用干净 Entity,避免 DTO 脏字段污染条件。
|
||||
*/
|
||||
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())) {
|
||||
dto.setType(dto.getTypeEnum().getType());
|
||||
if (ObjectUtil.isNotEmpty(typeEnum)) {
|
||||
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 ************************************* */
|
||||
|
||||
+1
@@ -115,6 +115,7 @@ public class AppUserController {
|
||||
*/
|
||||
@Operation(summary = "分页查询", description = "分页查询")
|
||||
@GetMapping("/page")
|
||||
@HasPermission("app_appuser_page")
|
||||
public R getAppUserPage(Page page, AppUserDTO appUserDTO) {
|
||||
return R.ok(appUserService.getUsersWithRolePage(page, appUserDTO));
|
||||
}
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ public interface AppMenuMapper extends PjcloudBaseMapper<AppMenuEntity> {
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getPlatform()), AppMenuEntity::getPlatform, dto.getPlatform())
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getType()), AppMenuEntity::getType, dto.getType())
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getVisible()), AppMenuEntity::getVisible, dto.getVisible())
|
||||
.orderByAsc(AppMenuEntity::getSort)
|
||||
// 后台文案:排序越大越靠前
|
||||
.orderByDesc(AppMenuEntity::getSort)
|
||||
.orderByDesc(AppMenuEntity::getCreateTime);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ spring:
|
||||
name: @artifactId@
|
||||
cloud:
|
||||
nacos:
|
||||
username: @nacos.username@
|
||||
password: @nacos.password@
|
||||
username: nacos
|
||||
password: Aa@bsd.321
|
||||
discovery:
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848}
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
|
||||
config:
|
||||
server-addr: ${spring.cloud.nacos.discovery.server-addr}
|
||||
config:
|
||||
@@ -23,7 +23,7 @@ mini-ali-pay:
|
||||
public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk2B2NAFtnI2x6Dopw/H+AijCmrA5m0cOAhDGrBuSM3JJnrk6rrdxpscUUZbOts5V0VTgg2IJxocxIQ/5zGMdEzAV9pmKPnx/V1DLWTCmPqdqz0105T5xn20OHnVGKH0dLatboPsBzg+f48bsDtcz3we7fHV94DWuDON30zytpjw3qP1IkQeOQz41Fr2zF+fZbWGDIJgFtEwG15kQBihca5l9x3XSStI0sBKU3B2kVdwuha2FePL1hxJYr4lQVLC0SKcL909xWGZtGtfS3BkLcm3k4V2mgmK4+y8KH35PL/Ay9oNvCAHohbY45FmzxSijArEnvy1ZL3UE7oZ3Lg2skwIDAQAB
|
||||
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://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:
|
||||
app-id: wx95481f43b14565e5
|
||||
|
||||
@@ -6,10 +6,10 @@ spring:
|
||||
name: @artifactId@
|
||||
cloud:
|
||||
nacos:
|
||||
username: @nacos.username@
|
||||
password: @nacos.password@
|
||||
username: nacos
|
||||
password: Aa@bsd.321
|
||||
discovery:
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848}
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
|
||||
config:
|
||||
server-addr: ${spring.cloud.nacos.discovery.server-addr}
|
||||
config:
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
+7
@@ -3,6 +3,8 @@ package com.bsd.pjcloud.canteen.vo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class ADCanteenDailyMenuListVO {
|
||||
/**
|
||||
@@ -10,6 +12,11 @@ public class ADCanteenDailyMenuListVO {
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 菜谱供应日期,yyyy-MM-dd;收银终端多日同步时以此归属到具体日期
|
||||
*/
|
||||
private LocalDate saleDate;
|
||||
|
||||
/**
|
||||
* spuid
|
||||
*/
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+15
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
|
||||
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.entity.CanteenSpuEntity;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
|
||||
@@ -198,6 +199,20 @@ public class CanteenDailyMenuController {
|
||||
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---------------
|
||||
|
||||
|
||||
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
package com.bsd.pjcloud.canteen.mapper;
|
||||
|
||||
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.vo.ADCanteenDailyMenuListVO;
|
||||
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,
|
||||
@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);
|
||||
|
||||
+10
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
|
||||
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.entity.CanteenDailyMenuEntity;
|
||||
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);
|
||||
|
||||
/**
|
||||
* 按闭区间查询收银终端多日菜谱,返回字段与单日查询保持一致,
|
||||
* 每条记录额外携带供应日期,供终端按日期分桶落库。
|
||||
*
|
||||
* @param request 日期范围、可选筛选条件和收银终端所属商户
|
||||
* @return 按日期、餐段和商品排序的菜谱列表
|
||||
*/
|
||||
List<ADCanteenDailyMenuListVO> getCanteenMenuListByDateRange(DailyMenuDateRangeQueryDTO request);
|
||||
|
||||
Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO);
|
||||
|
||||
|
||||
|
||||
+21
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
|
||||
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.entity.CanteenSpuEntity;
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按闭区间查询收银终端多日菜谱。
|
||||
*
|
||||
* @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
|
||||
public Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO) {
|
||||
Page<DailyMenuAppVO> pageData = baseMapper.selectTimeFoodPage(page, dailyMenuDTO);
|
||||
|
||||
@@ -6,10 +6,10 @@ spring:
|
||||
name: @artifactId@
|
||||
cloud:
|
||||
nacos:
|
||||
username: @nacos.username@
|
||||
password: @nacos.password@
|
||||
username: nacos
|
||||
password: Aa@bsd.321
|
||||
discovery:
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848}
|
||||
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:18849}
|
||||
config:
|
||||
server-addr: ${spring.cloud.nacos.discovery.server-addr}
|
||||
config:
|
||||
|
||||
+46
-8
@@ -19,6 +19,7 @@
|
||||
|
||||
<resultMap id="adCanteenDailyMenuListVO" type="com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO">
|
||||
<id property="id" column="id"/>
|
||||
<result property="saleDate" column="start_time"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="spuId" column="spu_id"/>
|
||||
<result property="hourType" column="hour_type"/>
|
||||
@@ -30,33 +31,38 @@
|
||||
<result property="hourType" column="hour_type"/>
|
||||
<result property="isPriceIncreased" column="is_price_increased"/>
|
||||
</resultMap>
|
||||
<select id="selectTimeFood" resultType="com.bsd.pjcloud.canteen.vo.DailyMenuVO">
|
||||
SELECT
|
||||
DISTINCT(sp.id),
|
||||
t.id as tid,
|
||||
<select id="selectTimeFood"
|
||||
resultType="com.bsd.pjcloud.canteen.vo.DailyMenuVO">
|
||||
SELECT DISTINCT
|
||||
sp.id,
|
||||
t.id AS tid,
|
||||
sp.name,
|
||||
sp.pic_url,
|
||||
t.hour_type,
|
||||
sp.price,
|
||||
sp.category_id
|
||||
sp.category_id,
|
||||
sp.sort
|
||||
FROM canteen_daily_menu t
|
||||
LEFT JOIN canteen_spu sp
|
||||
on t.spu_id=sp.id
|
||||
ON t.spu_id = sp.id
|
||||
<where>
|
||||
AND t.merchant_id = #{merchantId}
|
||||
|
||||
<if test="categoryId != null">
|
||||
AND sp.category_id = #{categoryId}
|
||||
</if>
|
||||
|
||||
AND sp.del_flag = 0
|
||||
AND t.start_time = #{time}
|
||||
And t.del_flag = 0
|
||||
AND t.del_flag = 0
|
||||
</where>
|
||||
order by sp.sort asc
|
||||
ORDER BY sp.sort ASC
|
||||
</select>
|
||||
<select id="getCanteenMenuList" resultMap="adCanteenDailyMenuListVO">
|
||||
SELECT
|
||||
sp.id as spu_id,
|
||||
t.id,
|
||||
t.start_time,
|
||||
sp.name,
|
||||
sp.pic_url,
|
||||
t.hour_type,
|
||||
@@ -86,6 +92,38 @@
|
||||
</if>
|
||||
</where>
|
||||
</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"
|
||||
parameterType="com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO">
|
||||
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>
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.bsd.pjcloud.common.asynctask.service;
|
||||
|
||||
/**
|
||||
* 通用异步任务业务动作。
|
||||
* <p>动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AsyncTaskAction {
|
||||
|
||||
/**
|
||||
* 执行具体异步业务。
|
||||
*
|
||||
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
|
||||
*/
|
||||
void execute(AsyncTaskProgressReporter reporter);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.bsd.pjcloud.common.asynctask.service;
|
||||
|
||||
/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */
|
||||
public class AsyncTaskException extends RuntimeException {
|
||||
/** 使用业务可读提示终止任务,不暴露底层异常。 */
|
||||
public AsyncTaskException(String message) { super(message); }
|
||||
}
|
||||
+34
@@ -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("当前异步任务执行器不支持返回任务结果");
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration
|
||||
com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration
|
||||
@@ -62,6 +62,11 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-common-async-task</artifactId>
|
||||
<version>${pjcloud.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-common-core</artifactId>
|
||||
|
||||
+16
@@ -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); }
|
||||
}
|
||||
+2
@@ -169,6 +169,8 @@ public interface CacheConstants {
|
||||
|
||||
String HARDER_ORDER_NON_PAY = "hard:order:non_pay";
|
||||
|
||||
String HARD_XFJ_WHITE_LIST_LOCK = "hard:xfj:whitelist:lock";
|
||||
|
||||
/* ************************************* end 订单相关 end ************************************* */
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ import lombok.Getter;
|
||||
@AllArgsConstructor
|
||||
public enum YesNoEnum {
|
||||
|
||||
YES("1", "是"), NO("0", "否");
|
||||
YES("1", "是"),
|
||||
NO("0", "否");
|
||||
|
||||
/**
|
||||
* 编码
|
||||
|
||||
+111
@@ -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; }
|
||||
}
|
||||
}
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+105
@@ -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"); }
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+318
@@ -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 {}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,4 +4,4 @@ com.bsd.pjcloud.common.core.config.MessageSourceConfiguration
|
||||
com.bsd.pjcloud.common.core.config.RestTemplateConfiguration
|
||||
com.bsd.pjcloud.common.core.config.ImmutableConfiguration
|
||||
com.bsd.pjcloud.common.core.util.SpringContextHolder
|
||||
|
||||
com.bsd.pjcloud.common.core.config.FileAccessConfiguration
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
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.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.phone.existing={0} \u624b\u673a\u53f7\u5df2\u5b58\u5728
|
||||
sys.user.userInfo.empty={0} \u7528\u6237\u4FE1\u606F\u4E3A\u7A7A
|
||||
|
||||
+176
@@ -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¬e=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"); }
|
||||
}
|
||||
}
|
||||
+84
-53
@@ -35,76 +35,107 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author pjcloud
|
||||
* @date 2019-06-24
|
||||
* <p>
|
||||
* 解决Mybatis Plus Order By SQL注入问题
|
||||
* 解决Mybatis Plus Order By SQL注入问题:ascs/descs 仅允许简单列名白名单,禁止表达式/函数注入。
|
||||
*/
|
||||
@Slf4j
|
||||
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 参数
|
||||
*
|
||||
* @param parameter 参数
|
||||
* @return 是否过滤
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.getParameterType().equals(Page.class);
|
||||
}
|
||||
/** 单次请求最多允许的排序列数量,防止恶意堆叠。 */
|
||||
private static final int MAX_ORDER_COLUMNS = 5;
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
/**
|
||||
* 判断Controller是否包含page 参数
|
||||
*
|
||||
* @param parameter 参数
|
||||
* @return 是否过滤
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.getParameterType().equals(Page.class);
|
||||
}
|
||||
|
||||
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");
|
||||
String descs = request.getParameter("descs");
|
||||
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
|
||||
|
||||
String current = request.getParameter("current");
|
||||
String size = request.getParameter("size");
|
||||
String ascs = request.getParameter("ascs");
|
||||
String descs = request.getParameter("descs");
|
||||
|
||||
Page page = new Page();
|
||||
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));
|
||||
}
|
||||
String current = request.getParameter("current");
|
||||
String size = request.getParameter("size");
|
||||
|
||||
if (StrUtil.isNotBlank(size)) {
|
||||
page.setSize(Long.parseLong(size));
|
||||
}
|
||||
Page page = new Page();
|
||||
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<>();
|
||||
Optional.ofNullable(ascs)
|
||||
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA))
|
||||
.filter(asc -> !SqlInjectionUtils.check(asc))
|
||||
.map(OrderItem::asc)
|
||||
.collect(Collectors.toList())));
|
||||
Optional.ofNullable(descs)
|
||||
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA))
|
||||
.filter(desc -> !SqlInjectionUtils.check(desc))
|
||||
.map(OrderItem::desc)
|
||||
.collect(Collectors.toList())));
|
||||
page.addOrder(orderItemList);
|
||||
if (StrUtil.isNotBlank(size)) {
|
||||
page.setSize(Long.parseLong(size));
|
||||
}
|
||||
|
||||
List<OrderItem> orderItemList = new ArrayList<>();
|
||||
Optional.ofNullable(ascs)
|
||||
.ifPresent(s -> orderItemList.addAll(Arrays.stream(s.split(StrUtil.COMMA))
|
||||
.map(String::trim)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.map(col -> toSafeOrderColumn(col, true))
|
||||
.collect(Collectors.toList())));
|
||||
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;
|
||||
}
|
||||
}
|
||||
+5
@@ -48,6 +48,11 @@ public class TenantContextHolder {
|
||||
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
|
||||
* @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` 权限,超出单工作表行数限制时提示缩小筛选范围。
|
||||
+14
@@ -1,13 +1,19 @@
|
||||
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 org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.NestedConfigurationProperty;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* @author pjcloud
|
||||
* @date 2020/3/29
|
||||
*/
|
||||
@Data
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = ExcelConfigProperties.PREFIX)
|
||||
public class ExcelConfigProperties {
|
||||
|
||||
@@ -18,4 +24,12 @@ public class ExcelConfigProperties {
|
||||
*/
|
||||
private String templatePath = "excel";
|
||||
|
||||
/**
|
||||
* 全局导出样式 token。只有显式注册 ExcelExportStyleHandler 的导出才会应用。
|
||||
*/
|
||||
@Valid
|
||||
@NotNull
|
||||
@NestedConfigurationProperty
|
||||
private ExcelStyleTokens exportStyle = new ExcelStyleTokens();
|
||||
|
||||
}
|
||||
+617
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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); }
|
||||
}
|
||||
+182
@@ -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) { }
|
||||
}
|
||||
+193
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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
Reference in new issue
Block a user