diff --git a/.gitignore b/.gitignore index 9e2781708..765142c86 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,10 @@ gen_code ### node ### node_modules + +### AI 辅助工具目录 ### +.v2c/ +.video_agent/ +.mimosa/ +.cloudbase/ +.zcode/ diff --git a/db/20260908_add_hard_order_evidence_image.sql b/db/20260908_add_hard_order_evidence_image.sql new file mode 100644 index 000000000..8817ca8fb --- /dev/null +++ b/db/20260908_add_hard_order_evidence_image.sql @@ -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 = '硬件订单识别证据图片'; diff --git a/db/20260908_add_hard_order_verification_time.sql b/db/20260908_add_hard_order_verification_time.sql new file mode 100644 index 000000000..363d63e1c --- /dev/null +++ b/db/20260908_add_hard_order_verification_time.sql @@ -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; diff --git a/db/20260908_add_hard_printer.sql b/db/20260908_add_hard_printer.sql new file mode 100644 index 000000000..28d950ad1 --- /dev/null +++ b/db/20260908_add_hard_printer.sql @@ -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; diff --git a/db/20260908_add_hard_terminal_log_file.sql b/db/20260908_add_hard_terminal_log_file.sql new file mode 100644 index 000000000..046527ec5 --- /dev/null +++ b/db/20260908_add_hard_terminal_log_file.sql @@ -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 ='终端日志文件'; diff --git a/db/20260908_add_hard_terminal_status.sql b/db/20260908_add_hard_terminal_status.sql new file mode 100644 index 000000000..71d932f34 --- /dev/null +++ b/db/20260908_add_hard_terminal_status.sql @@ -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='食堂超市终端心跳状态'; diff --git a/db/20260908_add_hard_xfj_log.sql b/db/20260908_add_hard_xfj_log.sql new file mode 100644 index 000000000..6dcff40e7 --- /dev/null +++ b/db/20260908_add_hard_xfj_log.sql @@ -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 ='消费机日志'; diff --git a/db/20260908_add_wallet_transaction_tags_remark.sql b/db/20260908_add_wallet_transaction_tags_remark.sql new file mode 100644 index 000000000..b35c43a26 --- /dev/null +++ b/db/20260908_add_wallet_transaction_tags_remark.sql @@ -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; diff --git a/db/20260908_智能柜手动结算权限待添加.txt b/db/20260908_智能柜手动结算权限待添加.txt new file mode 100644 index 000000000..49c60dd4c --- /dev/null +++ b/db/20260908_智能柜手动结算权限待添加.txt @@ -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)。 diff --git a/db/20260908_权限菜单字典待添加.txt b/db/20260908_权限菜单字典待添加.txt new file mode 100644 index 000000000..31752705d --- /dev/null +++ b/db/20260908_权限菜单字典待添加.txt @@ -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 diff --git a/db/20260909_add_hard_after_sale_order_index.sql b/db/20260909_add_hard_after_sale_order_index.sql new file mode 100644 index 000000000..6a09734ef --- /dev/null +++ b/db/20260909_add_hard_after_sale_order_index.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 依赖,可自行评估删除,本文件不强制处理。 diff --git a/db/20260909_add_hard_door_flow_manual_settle_vo.sql b/db/20260909_add_hard_door_flow_manual_settle_vo.sql new file mode 100644 index 000000000..0d9a0e03e --- /dev/null +++ b/db/20260909_add_hard_door_flow_manual_settle_vo.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 '手动结算/取消操作快照(操作人+购物车明细+金额+备注)'; diff --git a/db/20260909_add_sys_user_face_quality.sql b/db/20260909_add_sys_user_face_quality.sql new file mode 100644 index 000000000..e96186239 --- /dev/null +++ b/db/20260909_add_sys_user_face_quality.sql @@ -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; diff --git a/db/20260909_智能柜手动结算测试数据.sql b/db/20260909_智能柜手动结算测试数据.sql new file mode 100644 index 000000000..ffd383b94 --- /dev/null +++ b/db/20260909_智能柜手动结算测试数据.sql @@ -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),按需处理 diff --git a/db/20260910_add_hard_print_task_quartz_job.sql b/db/20260910_add_hard_print_task_quartz_job.sql new file mode 100644 index 000000000..7c29e5ebb --- /dev/null +++ b/db/20260910_add_hard_print_task_quartz_job.sql @@ -0,0 +1,42 @@ +-- ===================================================================== +-- 打印任务补偿扫描:由 hard 模块内 @Scheduled 改为 quartz 调度中心统一触发 +-- 接口:GET /hard-invoice/task/process-due(HardInvoiceController#processDueTasks) +-- +-- 上线步骤: +-- 1. 按部署环境修改 execute_path(默认为本机直连示例): +-- - 直连硬件服务(推荐,调度中心与服务同内网): +-- http://: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秒扫描待生成/待补偿下发的打印任务)' +); diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql index d93d5c60c..877edbf2a 100644 --- a/db/800pjcloudx_hardware.sql +++ b/db/800pjcloudx_hardware.sql @@ -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 ='硬件商品免密订单'; \ No newline at end of file +) 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 ='消费机日志'; \ No newline at end of file diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index 7fafbd4e2..f5e6b8a6a 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -374,6 +374,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 '修改人', diff --git a/db/slowlog_optimization_20260826/01_add_indexes.sql b/db/slowlog_optimization_20260826/01_add_indexes.sql new file mode 100644 index 000000000..13f857db3 --- /dev/null +++ b/db/slowlog_optimization_20260826/01_add_indexes.sql @@ -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); diff --git a/db/slowlog_optimization_20260826/README.md b/db/slowlog_optimization_20260826/README.md new file mode 100644 index 000000000..72323b122 --- /dev/null +++ b/db/slowlog_optimization_20260826/README.md @@ -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`,不再全表扫描。 diff --git a/local.env b/local.env index a5024b3c0..e8d08751e 100644 --- a/local.env +++ b/local.env @@ -1,4 +1,4 @@ -MYSQL_HOST=123.157.250.162 +MYSQL_HOST=127.0.0.1 MYSQL_PORT=33905 MYSQL_USER=root@sys MYSQL_PWD=bsd.1799 diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/DailyMenuDateRangeQueryDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/DailyMenuDateRangeQueryDTO.java new file mode 100644 index 000000000..d6d7b5246 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/DailyMenuDateRangeQueryDTO.java @@ -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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenDailyMenuListVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenDailyMenuListVO.java index ecb1f1636..f708ef8fd 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenDailyMenuListVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenDailyMenuListVO.java @@ -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 */ diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OnlineSpuComboItemVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OnlineSpuComboItemVO.java new file mode 100644 index 000000000..4517706df --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/OnlineSpuComboItemVO.java @@ -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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index 715d8f8d2..e3475f1bc 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -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> getCanteenListByDateRange( + @Validated @RequestBody DailyMenuDateRangeQueryDTO request) { + return R.ok(canteenDailyMenuService.getCanteenMenuListByDateRange(request)); + } + // --------------APP--------------- diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java index dcc9b3c59..1219f37b9 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java @@ -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 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 getCanteenMenuListByDateRange(@Param("request") DailyMenuDateRangeQueryDTO request, + DataScope dataScope); + Page selectTimeFoodPage(@Param("page")Page page, DailyMenuDTO dailyMenuDTO); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java index 90b7be021..d3c6d9509 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java @@ -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 getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId); + /** + * 按闭区间查询收银终端多日菜谱,返回字段与单日查询保持一致, + * 每条记录额外携带供应日期,供终端按日期分桶落库。 + * + * @param request 日期范围、可选筛选条件和收银终端所属商户 + * @return 按日期、餐段和商品排序的菜谱列表 + */ + List getCanteenMenuListByDateRange(DailyMenuDateRangeQueryDTO request); + Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java index b631fb63a..5f0d521b1 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java @@ -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 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 pageData = baseMapper.selectTimeFoodPage(page, dailyMenuDTO); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index 7da7b1a8f..38029a9b2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -19,6 +19,7 @@ + @@ -61,6 +62,7 @@ SELECT sp.id as spu_id, t.id, + t.start_time, sp.name, sp.pic_url, t.hour_type, @@ -90,6 +92,38 @@ + m.name as hardName, m.type as hardType, + printer.name as printerHardName, o.hard_id as orderHardId, o.user_id as orderUserId, o.user_id as userId, @@ -57,6 +85,7 @@ o.pay_time as orderPayTime from hard_invoice a left join hard_manager m on m.id = a.hard_id + left join hard_manager printer on printer.id = a.printer_hard_id left join hard_order o on o.id = a.order_id @@ -84,4 +113,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalLogFileMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalLogFileMapper.xml new file mode 100644 index 000000000..179d93d9b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalLogFileMapper.xml @@ -0,0 +1,51 @@ + + + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalStatusMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalStatusMapper.xml new file mode 100644 index 000000000..294fd49fe --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/manager/HardTerminalStatusMapper.xml @@ -0,0 +1,60 @@ + + + + + + INSERT INTO hard_terminal_status ( + hard_id, + last_online_time, + heartbeat_scene, + disconnect_reason, + apk_version_name, + apk_version_code, + system_uptime_seconds, + app_uptime_seconds, + network_type, + available_storage_bytes, + root_status, + device_owner, + kiosk_active, + device_status_json, + create_time, + update_time, + tenant_id + ) VALUES ( + #{status.hardId}, + #{status.lastOnlineTime}, + #{status.heartbeatScene}, + #{status.disconnectReason}, + #{status.apkVersionName}, + #{status.apkVersionCode}, + #{status.systemUptimeSeconds}, + #{status.appUptimeSeconds}, + #{status.networkType}, + #{status.availableStorageBytes}, + #{status.rootStatus}, + #{status.deviceOwner}, + #{status.kioskActive}, + #{status.deviceStatusJson}, + #{status.createTime}, + #{status.updateTime}, + #{status.tenantId} + ) + ON DUPLICATE KEY UPDATE + last_online_time = VALUES(last_online_time), + heartbeat_scene = VALUES(heartbeat_scene), + disconnect_reason = VALUES(disconnect_reason), + apk_version_name = VALUES(apk_version_name), + apk_version_code = VALUES(apk_version_code), + system_uptime_seconds = VALUES(system_uptime_seconds), + app_uptime_seconds = VALUES(app_uptime_seconds), + network_type = VALUES(network_type), + available_storage_bytes = VALUES(available_storage_bytes), + root_status = VALUES(root_status), + device_owner = VALUES(device_owner), + kiosk_active = VALUES(kiosk_active), + device_status_json = VALUES(device_status_json), + update_time = VALUES(update_time), + tenant_id = VALUES(tenant_id) + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 1e9895222..153dcfaec 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -2,6 +2,16 @@ + + + + @@ -82,6 +92,9 @@ a.del_flag = '0' + + and a.hard_id = #{dto.hardId} + and a.order_no = #{dto.orderNo} @@ -151,7 +164,14 @@ - and a.merchant_dept_id in + + + and a.merchant_dept_id NOT IN + + + and a.merchant_dept_id IN + + #{v} @@ -262,13 +282,13 @@ on w_m.id = a.wallet_id and w_m.tenant_id = a.tenant_id and w_m.del_flag = '0' left join pjcloudx_wallet.wallet_type w_t on w_t.id = w_m.wallet_type_id and w_t.tenant_id = a.tenant_id and w_t.del_flag = '0' - left join (select tenant_id, order_id, max(id) as id - from hard_after_sale - where del_flag = '0' - group by tenant_id, order_id) sx - on sx.order_id = a.id and sx.tenant_id = a.tenant_id - left join hard_after_sale s - on s.id = sx.id and s.tenant_id = a.tenant_id and s.del_flag = '0' + + left join hard_after_sale s on s.id = ( + select max(latest_sale.id) + from hard_after_sale latest_sale + where latest_sale.order_id = a.id + and latest_sale.del_flag = '0' + ) @@ -293,7 +313,8 @@ - order by a.create_time desc, a.user_id desc + + order by a.create_time desc, a.user_id desc, a.id desc @@ -380,7 +401,13 @@ select count(0) from hard_order a left join hard_manager m on m.id = a.hard_id - left join hard_after_sale s on s.order_id = a.id + + left join hard_after_sale s on s.id = ( + select max(latest_sale.id) + from hard_after_sale latest_sale + where latest_sale.order_id = a.id + and latest_sale.del_flag = '0' + ) @@ -421,6 +448,28 @@ group by a.hard_id + + + + + , + m.name as hardName + from hard_xfj_log a + left join hard_manager m on a.unique_code = m.unique_code and m.del_flag = '0' + + + + and m.name LIKE CONCAT('%', #{dto.hardName}, '%') + + + order by a.create_time desc, a.id desc + + + \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml index 35a41449b..f10b639c8 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml @@ -112,6 +112,12 @@ com.bsd pjcloud-common-log + + + com.jcraft + jsch + 0.1.55 + com.baomidou diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java index 341d86c82..6358300ab 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java @@ -8,7 +8,9 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.integrated.dto.appmodule.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; import com.bsd.pjcloud.integrated.service.AppVersionService; +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; import com.bsd.pjcloud.integrated.vo.appmodule.AppVersionVo; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -18,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; /** * app版本控制表 @@ -30,12 +33,13 @@ import org.springframework.web.bind.annotation.*; public class AppVersionController { private final AppVersionService appVersionService; + private final AppVersionPackageUploadService appVersionPackageUploadService; /* ************************************* start 设备端 start ************************************* */ @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/ad/page") public R> hardPage(@ParameterObject Page page, @ParameterObject AppVersionDto dto) { - return R.ok(appVersionService.pageList(page, dto)); + return R.ok(appVersionService.terminalPageList(page, dto)); } /* ************************************* end 设备端 end ************************************* */ @@ -45,6 +49,16 @@ public class AppVersionController { public R lastVersion(@ParameterObject AppVersionDto dto) { return R.ok(appVersionService.lastVersion(dto)); } + + /** + * 部门邀请等场景:取安卓最新已发布 APK 整包地址(排除 wgt 热更) + */ + @Operation(summary = "最新安卓APK整包", description = "平台含安卓且 downloadUrl 以 .apk 结尾的最新已发布版本") + @GetMapping("/latest-android-apk") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_view') || @pms.hasPermission('sys_dept_edit') || @pms.hasPermission('sys_dept_view')") + public R latestAndroidApk() { + return R.ok(appVersionService.latestAndroidApk()); + } /* ************************************* end app end ************************************* */ /** @@ -89,6 +103,29 @@ public class AppVersionController { return R.ok(appVersionService.updateById(appVersion)); } + /** + * 上传安装包到指定服务器目录(SFTP),返回对外下载地址 + */ + @Operation(summary = "上传安装包到远程服务器", description = "SFTP 上传 apk/wgt 等到宝塔等服务器指定目录") + @SysLog("上传App安装包到远程服务器") + @PostMapping("/upload-package") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_add')") + public R uploadPackage(@RequestPart("file") MultipartFile file, + @RequestParam("host") String host, + @RequestParam(value = "port", required = false) Integer port, + @RequestParam("username") String username, + @RequestParam("password") String password, + @RequestParam("remoteDir") String remoteDir, + @RequestParam("publicBaseUrl") String publicBaseUrl) { + try { + return R.ok(appVersionPackageUploadService.uploadBySftp(file, host, port, username, password, remoteDir, + publicBaseUrl)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + /** * 通过id删除app版本控制表 */ diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java index d1fc5e67a..f9245a67a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java @@ -13,4 +13,13 @@ public interface AppVersionMapper extends PjcloudBaseMapper { Page pagList(Page page, @Param("dto") AppVersionDto dto); + /** + * 精确查询指定终端和平台的已发布版本,排序由 SQL 固定为版本码倒序。 + * + * @param page 分页参数 + * @param dto 终端 App 编码与平台参数 + * @return 终端可见的已发布版本分页 + */ + Page terminalPageList(Page page, @Param("dto") AppVersionDto dto); + } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java new file mode 100644 index 000000000..a023be6ab --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.integrated.service; + +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; +import org.springframework.web.multipart.MultipartFile; + +/** + * App 安装包上传到远程服务器(宝塔等) + */ +public interface AppVersionPackageUploadService { + + /** + * 通过 SFTP 上传安装包,并返回对外可访问的下载地址。 + * + * @param file 安装包文件 + * @param host 服务器 IP/域名 + * @param port SFTP 端口,默认 22 + * @param username 登录用户名 + * @param password 登录密码 + * @param remoteDir 远程目录,如 /www/wwwroot/h5.xxx.com/download + * @param publicBaseUrl 对外访问前缀,如 https://h5.xxx.com/download + */ + AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, String password, + String remoteDir, String publicBaseUrl); +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java index d2d248c15..bd61e309a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java @@ -17,4 +17,20 @@ public interface AppVersionService extends IService { * 获取最新版本 */ AppVersionEntity lastVersion(AppVersionDto dto); + + /** + * 按终端 App 编码和平台精确查询已发布版本。 + * + * @param page 分页参数 + * @param dto 终端 App 编码与平台参数 + * @return 版本码倒序的终端版本分页 + */ + Page terminalPageList(Page page, AppVersionDto dto); + + /** + * 取安卓平台最新已发布的整包安装地址(downloadUrl 以 .apk 结尾)。 + * + * @return 安卓最新已发布整包版本;没有记录时为 {@code null} + */ + AppVersionEntity latestAndroidApk(); } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java new file mode 100644 index 000000000..061c351e3 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java @@ -0,0 +1,128 @@ +package com.bsd.pjcloud.integrated.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.ssh.Sftp; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; + +/** + * 将 APK/WGT 等安装包经 SFTP 上传到宝塔等目标机指定目录。 + */ +@Slf4j +@Service +public class AppVersionPackageUploadServiceImpl implements AppVersionPackageUploadService { + + private static final String TAG = "AppPackageUpload"; + + private static final Set ALLOWED_EXT = Set.of("apk", "wgt", "ipa", "zip"); + + @Override + public AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, + String password, String remoteDir, String publicBaseUrl) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请选择要上传的安装包文件"); + } + if (StrUtil.hasBlank(host, username, password, remoteDir, publicBaseUrl)) { + throw new IllegalArgumentException("服务器地址、账号、密码、远程目录、对外访问前缀不能为空"); + } + String safeDir = normalizeRemoteDir(remoteDir.trim()); + String originalName = file.getOriginalFilename(); + if (StrUtil.isBlank(originalName)) { + throw new IllegalArgumentException("无法识别文件名"); + } + String fileName = FileUtil.getName(originalName); + String ext = FileUtil.extName(fileName); + if (StrUtil.isBlank(ext) || !ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("仅支持上传 apk/wgt/ipa/zip 文件"); + } + int sftpPort = (port == null || port <= 0) ? 22 : port; + String remoteFilePath = safeDir + "/" + fileName; + String publicUrl = joinUrl(publicBaseUrl.trim(), fileName); + + Session session = null; + Sftp sftp = null; + try { + JSch jsch = new JSch(); + session = jsch.getSession(username.trim(), host.trim(), sftpPort); + session.setPassword(password); + session.setConfig("StrictHostKeyChecking", "no"); + session.connect(15000); + sftp = new Sftp(session); + try { + if (!sftp.exist(safeDir)) { + sftp.mkDirs(safeDir); + } + } + catch (Exception ex) { + // 部分环境 exist 不稳定,直接尝试建目录 + try { + sftp.mkDirs(safeDir); + } + catch (Exception mkdirEx) { + log.warn("|{}|创建目录失败(可能已存在):|{}|", TAG, mkdirEx.getMessage()); + } + } + try (InputStream in = file.getInputStream()) { + boolean ok = sftp.upload(safeDir, fileName, in); + if (!ok) { + throw new IllegalArgumentException("SFTP 上传返回失败"); + } + } + log.info("|{}|上传成功 host:|{}| remote:|{}| size:|{}|", TAG, host, remoteFilePath, file.getSize()); + return AppPackageUploadVo.builder() + .url(publicUrl) + .fileName(fileName) + .remotePath(remoteFilePath) + .build(); + } + catch (IllegalArgumentException ex) { + throw ex; + } + catch (Exception ex) { + log.error("|{}|SFTP上传失败 host:|{}| dir:|{}| error:|{}|", TAG, host, safeDir, ex.getMessage(), ex); + throw new IllegalArgumentException("上传到服务器失败:" + StrUtil.blankToDefault(ex.getMessage(), "请检查账号、目录与网络")); + } + finally { + if (sftp != null) { + try { + sftp.close(); + } + catch (Exception ignore) { + // ignore + } + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + } + + private static String normalizeRemoteDir(String remoteDir) { + String dir = remoteDir.replace('\\', '/'); + if (dir.contains("..")) { + throw new IllegalArgumentException("远程目录非法"); + } + if (!dir.startsWith("/")) { + dir = "/" + dir; + } + while (dir.endsWith("/") && dir.length() > 1) { + dir = dir.substring(0, dir.length() - 1); + } + return dir; + } + + private static String joinUrl(String base, String fileName) { + String b = StrUtil.removeSuffix(base, "/"); + return b + "/" + fileName; + } +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java index c6c5c2426..c45b7c2b6 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.integrated.constant.appmodule.AppPlatformEnum; import com.bsd.pjcloud.integrated.constant.appmodule.AppVersionStatusEnum; import com.bsd.pjcloud.integrated.dto.appmodule.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; @@ -39,4 +40,30 @@ public class AppVersionServiceImpl extends ServiceImpl terminalPageList(Page page, AppVersionDto dto) { + return baseMapper.terminalPageList(page, dto); + } + + /** + * 取安卓平台最新已发布的整包安装地址(downloadUrl 以 .apk 结尾)。 + *

排除 .wgt 热更补丁,供部门邀请下载二维码使用。

+ */ + @Override + public AppVersionEntity latestAndroidApk() { + return baseMapper.selectOne(new LambdaQueryWrapper() + .eq(AppVersionEntity::getStatus, AppVersionStatusEnum.PUBLISHED.getCode()) + .apply("FIND_IN_SET({0}, platform) > 0", AppPlatformEnum.ANDROID.getCode()) + .apply("LOWER(download_url) LIKE '%.apk'") + .orderByDesc(AppVersionEntity::getVersionCode) + .last("LIMIT 1")); + } } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java new file mode 100644 index 000000000..38194112b --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.integrated.vo.appmodule; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * App 安装包远程上传结果 + */ +@Data +@Builder +@Schema(description = "App安装包上传结果") +public class AppPackageUploadVo { + + @Schema(description = "对外下载地址") + private String url; + + @Schema(description = "服务器上的文件名") + private String fileName; + + @Schema(description = "服务器完整路径") + private String remotePath; +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml index 1444d1f11..3c701c58a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml @@ -42,4 +42,20 @@ + + \ No newline at end of file diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java index 52ec87606..8be880c85 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java @@ -18,7 +18,7 @@ import java.util.List; public interface RemoteMqttEventService { @NoToken - @GetMapping("/mqtt/send-default-msg") + @PostMapping("/mqtt/send-default-msg") R sendDefaultMsg(@RequestBody MqttBaseDto dto); /** @@ -43,11 +43,12 @@ public interface RemoteMqttEventService { R sendCanteenClassifyChangeMsg(@RequestParam Long deptId); /** - * 超市商品变化 + * 超市商品变化;spuIds 非空时终端只定向同步这些商品 */ @NoToken @GetMapping("/mqtt/send-supermarket-product-change-msg") - R sendSupermarketProductChangeMsg(@RequestParam Long deptId); + R sendSupermarketProductChangeMsg(@RequestParam Long deptId, + @RequestParam(value = "spuIds", required = false) List spuIds); /** * 超市分类变化 diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/constant/MqttActionEnum.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/constant/MqttActionEnum.java index 3c9af1d98..f54526b66 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/constant/MqttActionEnum.java +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/constant/MqttActionEnum.java @@ -18,6 +18,7 @@ public enum MqttActionEnum { PRINT("打印变化"), FACE("人脸变化"), TAKE_FOOD_BROADCAST("定餐广播"), + PULL_TERMINAL_LOG("拉取终端当前日志"), ; private final String description; } \ No newline at end of file diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java index 7b0266917..c6afa09cd 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.mqtt.dto.hard; import com.bsd.pjcloud.mqtt.dto.MqttHardBaseMsg; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data @@ -8,9 +9,21 @@ public class HardPrintMsg extends MqttHardBaseMsg { // 用户id private Long userId; - // 设备id + // 实际接收 MQTT 并代理 USB 打印的食堂或超市终端 ID private Long hardId; + /** + * 产生订单或执行核销的来源业务终端 ID。 + */ + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** + * 后台绑定的 EY_PRINT 逻辑打印机资产 ID,用于客户端校验绑定关系。 + */ + @Schema(description = "逻辑打印机资产ID") + private Long targetHardId; + // 设备部门id private Long mchDeptId; @@ -20,6 +33,24 @@ public class HardPrintMsg extends MqttHardBaseMsg { // 订单id private Long orderId; + /** + * 打印凭证类型,1 发票、2 兑换券。 + */ + @Schema(description = "打印凭证类型") + private String printType; + + /** + * 同订单同凭证类型的打印序号。 + */ + @Schema(description = "打印序号") + private Integer printNo; + + /** + * 人工失败重试版本,同一版本的重复 MQTT 消息必须幂等去重。 + */ + @Schema(description = "重试版本") + private Integer retryVersion; + // 安卓订单id private String androidOrderCode; diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java new file mode 100644 index 000000000..85a7ae1df --- /dev/null +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.mqtt.dto.hard; + +import com.bsd.pjcloud.mqtt.dto.MqttHardBaseMsg; +import lombok.Data; + +import java.util.List; + +/** + * 超市商品变更消息,携带发生变化的商品 SPU 编号供终端定向同步; + * 未携带编号时终端回退为常规增量同步。 + */ +@Data +public class HardSupermarketProductMsg extends MqttHardBaseMsg { + + private List spuIds; + +} diff --git a/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java b/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java index 53ee11dd2..2698dc067 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java +++ b/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.mqtt.constant.MqttActionEnum; import com.bsd.pjcloud.mqtt.dto.MqttBaseDto; import com.bsd.pjcloud.mqtt.dto.hard.HardCustomerMsg; import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg; +import com.bsd.pjcloud.mqtt.dto.hard.HardSupermarketProductMsg; import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg; import com.bsd.pjcloud.mqtt.service.MqttEventService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -77,13 +78,20 @@ public class MqttEventController { */ @Inner @GetMapping("/send-supermarket-product-change-msg") - public R sendSupermarketProductChangeMsg(@RequestParam Long deptId) { + public R sendSupermarketProductChangeMsg(@RequestParam Long deptId, + @RequestParam(required = false) List spuIds) { MqttBaseDto dto = new MqttBaseDto<>(); mqttEventService.getTopicParamsByUserDeptId(dto, deptId); if (ObjectUtil.isNotEmpty(deptId)) { dto.setMerchantDeptId(deptId); } dto.setAction(MqttActionEnum.SUPERMARKET_PRODUCT); + // 携带变更商品编号时终端只定向同步这些商品,否则回退常规增量同步 + if (ObjectUtil.isNotEmpty(spuIds)) { + HardSupermarketProductMsg msg = new HardSupermarketProductMsg(); + msg.setSpuIds(spuIds); + dto.setData(msg); + } return R.ok(mqttEventService.defaultAction(dto)); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java index 10ef8edc2..7988d5970 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java @@ -3,9 +3,13 @@ package com.bsd.pjcloud.supermarket.dto; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + @Data @Schema(description = "AD商品 SPU 查询参数") public class SupermarketSpuQueryDTO { @@ -15,6 +19,12 @@ public class SupermarketSpuQueryDTO { @Schema(description="商品 SPU 编号,自增") private Long id; + /** + * 商品 SPU 编号集合,定向同步指定商品 + */ + @Schema(description="商品 SPU 编号集合,定向同步指定商品") + private List ids; + /** * 货号 */ @@ -45,4 +55,12 @@ public class SupermarketSpuQueryDTO { @Schema(description="商品性质(字典)") private String productNature; + /** + * 增量同步起点:只返回该时间之后有变化的商品(含已删除的墓碑行)。 + * 传入后查询不再过滤上架/删除状态,靠返回的 delFlag 区分。 + */ + @Schema(description="增量同步起点,只返回该时间之后有变化的商品(含删除墓碑)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java index 16030bc88..88527fc6d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java @@ -87,6 +87,12 @@ public class SpuVo { @Schema(description="创建时间") private LocalDateTime createTime; + /** + * 是否删除;增量同步时为 "1" 的行是服务端删除墓碑,终端应清理本地对应商品 + */ + @Schema(description="是否删除,增量同步墓碑标记") + private String delFlag; + } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml index 6dded74c2..8e8f416f0 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml @@ -123,6 +123,13 @@ pjcloud-common-sequence 5.7.0
+ + + com.bsd + pjcloud-mqtt-api + 5.7.0 + compile + diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java index b1263473a..777c5a2be 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java @@ -43,7 +43,9 @@ import com.bsd.pjcloud.supermarket.vo.erpspu.SmErpSpuStockExcel; import com.bsd.pjcloud.supermarket.vo.sm.smspu.SmErpPageScopeVo; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.annotations.ParameterObject; @@ -71,6 +73,7 @@ import java.util.stream.Collectors; * @author zjf * @date 2024-12-23 10:42:43 */ +@Slf4j @Service @AllArgsConstructor public class SmErpSpuServiceImpl extends ServiceImpl implements SmErpSpuService { @@ -79,6 +82,8 @@ public class SmErpSpuServiceImpl extends ServiceImpl list) { + // 删除前留档部门归属,删除后按部门通知终端 + List spuEntities = baseMapper.selectByIds(list); + // 逻辑删除不会更新 update_time,先补一次更新时间保证终端增量同步能拉到删除墓碑 + baseMapper.update(null, new LambdaUpdateWrapper() + .set(SmErpSpuEntity::getUpdateTime, LocalDateTime.now()) + .in(SmErpSpuEntity::getId, list)); baseMapper.deleteByIds(list); //删除 @@ -504,9 +520,44 @@ public class SmErpSpuServiceImpl extends ServiceImpl> deptSpuIds = spuEntities.stream() + .filter(v -> v.getDeptId() != null) + .collect(Collectors.groupingBy(SmErpSpuEntity::getDeptId, + Collectors.mapping(SmErpSpuEntity::getId, Collectors.toList()))); + deptSpuIds.forEach(this::notifySupermarketProductChange); + return R.ok(); } + /** + * 入参实体可能不携带部门信息,缺失时回查数据库。 + */ + private Long resolveSpuDeptId(SmErpSpuEntity supermarketSpu) { + if (supermarketSpu.getDeptId() != null) { + return supermarketSpu.getDeptId(); + } + if (supermarketSpu.getId() == null) { + return null; + } + SmErpSpuEntity dbSpu = baseMapper.selectById(supermarketSpu.getId()); + return dbSpu != null ? dbSpu.getDeptId() : null; + } + + /** + * 超市商品变更后通知终端定向同步;MQTT 发送失败只记日志,不影响业务事务。 + */ + private void notifySupermarketProductChange(Long deptId, List spuIds) { + if (deptId == null || CollectionUtils.isEmpty(spuIds)) { + return; + } + try { + remoteMqttEventService.sendSupermarketProductChangeMsg(deptId, spuIds); + } catch (Exception e) { + log.error("|SmErpSpuServiceImpl|通知终端同步超市商品变更失败deptId:|{}|spuIds:|{}|", deptId, spuIds, e); + } + } + @Override @Transactional public R updateSpuStock(SupermarketSpuUpdateStockReqDTO updateStockReqDTO) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml index e2a78fd99..8018725c3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml @@ -38,6 +38,7 @@ es.sales_count, es.dept_id, es.create_by, + es.del_flag, u.unit, u.spec, u.retail_price, @@ -46,13 +47,27 @@ FROM sm_erp_spu es left join sm_erp_unit u on es.id = u.spu_id and u.is_base=1 - left join sm_erp_stock st - on es.id = st.erp_spu_id + left join sm_erp_stock st + on es.id = st.erp_spu_id - u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0 + + + + es.update_time >= #{supermarketSpuQueryDTO.updateTime} + + + u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0 + + AND es.id = #{supermarketSpuQueryDTO.id} + + AND es.id IN + + #{spuIdItem} + + AND es.product_code = #{supermarketSpuQueryDTO.productCode} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/miniplatform/AppPlusPlatformTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/miniplatform/AppPlusPlatformTypeEnum.java new file mode 100644 index 000000000..0b89335b0 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/miniplatform/AppPlusPlatformTypeEnum.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.thirdparty.constant.miniplatform; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 小程序平台 + */ +@AllArgsConstructor +@Getter +public enum AppPlusPlatformTypeEnum { + + WECHAT("10", "微信"), + ALI_PAY("20", "支付宝"), + ZAX_APP_WALLET("30", "资安信-钱包"), + ; + + private final String type; + private final String description; + + public static AppPlusPlatformTypeEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) return null; + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2025-06/debug.2025-06-26.0.log.gz b/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2025-06/debug.2025-06-26.0.log.gz new file mode 100644 index 000000000..b86cb8744 Binary files /dev/null and b/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2025-06/debug.2025-06-26.0.log.gz differ diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceDto.java index 96b976f53..dd82f28fb 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceDto.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceDto.java @@ -36,4 +36,12 @@ public class SysUserFaceDto extends SysUserFaceEntity implements BaseIsIncludeTr private String username; private String phone; + + /** + * 人脸质量查询分类。 + * + *

分页查询支持 {@code qualified}(质量分大于等于固定阈值)、{@code low}(质量分低于固定阈值)、 + * {@code failed}(检测失败)和 {@code pending}(未检测);为空或未知值时不按质量过滤。

+ */ + private String qualityCategory; } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceQualityReportItemDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceQualityReportItemDto.java new file mode 100644 index 000000000..9e703ecf0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceQualityReportItemDto.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.admin.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 终端上报的一条人脸静态图片质量结果。 + */ +@Data +public class SysUserFaceQualityReportItemDto { + + /** + * 虹软 32 位搜索样本编号,对应 {@code sys_user_face.auto_code}。 + */ + @NotBlank(message = "人脸样本编号不能为空") + private String sampleId; + + /** + * 检测状态,跨端协议值仅允许 detected 或 failed。 + */ + @NotBlank(message = "人脸质量检测状态不能为空") + private String status; + + /** + * 虹软静态图片质量分,detected 时必填且范围为 0 到 1,failed 时必须为空。 + */ + private BigDecimal score; + + /** + * 终端检测所依据的人脸图片版本,必须与服务端当前人脸更新时间一致。 + */ + @NotNull(message = "人脸图片版本不能为空") + private LocalDateTime sourceUpdatedAt; + + /** + * 终端完成本次检测的本地时间,用于保证重复和延迟上报不会覆盖更新结果。 + */ + @NotNull(message = "人脸质量检测时间不能为空") + private LocalDateTime checkedAt; + + /** + * 虹软 SDK 或终端插件错误码,detected 时必须为空,failed 时允许为空。 + */ + private Integer errorCode; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java new file mode 100644 index 000000000..9e7a891e0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 终端部门人脸固定上界游标快照查询参数。 + * + *

首次请求不传 {@code snapshotMaxId},后续分页必须原样携带首次响应中的上界, + * 避免同步期间新增记录改变当前对账集合。

+ */ +@Data +@Schema(description = "终端部门人脸同步快照查询") +public class SysUserFaceSyncSnapshotDto { + + /** + * 终端绑定的商户部门主键;查询范围包含该部门及所有未删除子部门。 + */ + @NotNull(message = "商户部门不能为空") + @Schema(description = "商户部门主键", requiredMode = Schema.RequiredMode.REQUIRED) + private Long merchantDeptId; + + /** + * 可选用户主键;MQ 增量通知回查时传入,部门完整快照时为空。 + */ + @Schema(description = "可选用户主键") + private Long userId; + + /** + * 排他起始游标;首次请求为空或 0,后续使用上一页的 nextAfterId。 + */ + @Min(value = 0, message = "人脸同步游标不能小于0") + @Schema(description = "排他起始游标") + private Long afterId; + + /** + * 单页记录数;空值使用服务端默认值,允许范围为 1 至 500。 + */ + @Min(value = 1, message = "人脸同步分页大小不能小于1") + @Max(value = 500, message = "人脸同步分页大小不能超过500") + @Schema(description = "单页记录数") + private Integer limit; + + /** + * 首次响应固定的人脸数据库主键上界;首次请求为空,后续分页必须原样携带。 + */ + @Min(value = 0, message = "人脸同步快照上界不能小于0") + @Schema(description = "固定快照主键上界") + private Long snapshotMaxId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalDeleteDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalDeleteDto.java new file mode 100644 index 000000000..ede797071 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalDeleteDto.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.admin.api.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 终端按可信部门批量删除人脸的内部请求。 + */ +@Data +public class SysUserFaceTerminalDeleteDto { + + /** + * Hardware 根据 DEVICE-ID 解析出的商户部门主键。 + */ + @NotNull(message = "终端绑定部门不能为空") + private Long merchantDeptId; + + /** + * 待删除的虹软样本编号,单批最多 500 条。 + */ + @NotNull(message = "人脸样本编号不能为空") + @Size(min = 1, max = 500, message = "单次最多删除500条人脸") + private List sampleIds; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalQualityReportDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalQualityReportDto.java new file mode 100644 index 000000000..1a3577380 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalQualityReportDto.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.admin.api.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * Hardware 向 UPMS 上报终端人脸质量的内部可信请求。 + */ +@Data +public class SysUserFaceTerminalQualityReportDto { + + /** + * Hardware 根据 DEVICE-ID 解析出的商户部门主键,终端请求体不得指定。 + */ + @NotNull(message = "终端绑定部门不能为空") + private Long merchantDeptId; + + /** + * Hardware 验证存在且已绑定商户的终端设备主键,用于质量结果来源追踪。 + */ + @NotNull(message = "质量上报终端不能为空") + private Long qualityDeviceId; + + /** + * 本批人脸质量结果,单次最多 500 条且不允许重复样本编号。 + */ + @Valid + @NotEmpty(message = "人脸质量结果不能为空") + @Size(max = 500, message = "单次最多上报500条人脸质量结果") + private List items; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java index b7a6efc3b..86a7f3b71 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java @@ -12,6 +12,7 @@ import lombok.NoArgsConstructor; import com.bsd.pjcloud.common.core.util.TenantTable; +import java.math.BigDecimal; import java.time.LocalDateTime; /** @@ -73,6 +74,36 @@ public class SysUserFaceEntity extends Model { @Schema(description = "人脸图片") private String faceImg; + /** + * 虹软静态图片质量分,范围为 0 到 1;未检测或检测失败时为空。 + */ + @Schema(description = "虹软静态图片质量分,范围0-1") + private BigDecimal qualityScore; + + /** + * 终端质量检测状态,持久化协议值为 detected 或 failed;历史未检测记录为空。 + */ + @Schema(description = "终端质量检测状态:detected/failed") + private String qualityStatus; + + /** + * 终端完成本次静态图片质量检测的本地时间,用于阻止旧结果覆盖新结果。 + */ + @Schema(description = "终端完成质量检测的时间") + private LocalDateTime qualityCheckedAt; + + /** + * 虹软 SDK 或终端插件质量检测错误码;检测成功时为空。 + */ + @Schema(description = "虹软或终端质量检测错误码") + private Integer qualityErrorCode; + + /** + * 最后一次成功写入质量结果的终端设备主键,来源于 Hardware 对 DEVICE-ID 的可信解析。 + */ + @Schema(description = "最后一次上报质量的终端ID") + private Long qualityDeviceId; + /** * 创建人 */ diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java new file mode 100644 index 000000000..020a7e85a --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.admin.api.feign; + +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +/** + * 用户人脸远程服务。 + */ +@FeignClient(contextId = "remoteUserFaceService", value = ServiceNameConstants.UPMS_SERVICE) +public interface RemoteUserFaceService { + + /** + * 批量查询用户的人脸可用与审核中数量。 + * + * @param userIds 用户ID列表 + * @return 各用户的人脸状态数量 + */ + @NoToken + @PostMapping("/user-face/inner/status-count") + R> listStatusCounts(@RequestBody List userIds); + + /** + * 按用户和虹软样本编号读取审核通过的人脸原图地址,供订单证据服务复制订单专属快照。 + * + * @param userId 识别用户主键 + * @param sampleId 虹软样本编号 + * @return 受信人脸图片来源;不存在或未审核通过时为空 + */ + @NoToken + @GetMapping("/user-face/inner/evidence-source") + R getEvidenceSource(@RequestParam Long userId, @RequestParam String sampleId); + + /** + * 按 Hardware 解析出的可信部门批量删除人脸。 + * + * @param dto 商户部门与样本编号 + * @return 实际删除和已不存在的样本编号 + */ + @NoToken + @PostMapping("/user-face/inner/terminal-delete") + R terminalDelete(@RequestBody SysUserFaceTerminalDeleteDto dto); + + /** + * 按 Hardware 解析出的可信设备和部门批量保存终端人脸质量结果。 + * + * @param dto 商户部门、来源设备及质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @NoToken + @PostMapping("/user-face/inner/terminal-quality-report") + R terminalReportQuality(@RequestBody SysUserFaceTerminalQualityReportDto dto); +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 651949d25..65d29b438 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -146,6 +146,23 @@ public interface RemoteUserService { @NoToken R> getUserIdListByDeptIdList(@RequestParam("deptIdList") List deptIdList); + /** + * 离线人员同步的固定快照上界;不传部门时为全租户,仅非生产调试同步使用。 + */ + @GetMapping("/user/inner/offline-user-max-id") + @NoToken + R getOfflineUserMaxId(@RequestParam(value = "deptIds", required = false) List deptIds); + + /** + * 离线人员同步的游标用户页;按主键升序返回大于 afterId 的一页用户。 + */ + @GetMapping("/user/inner/offline-user-cursor") + @NoToken + R> getOfflineUserCursor( + @RequestParam(value = "deptIds", required = false) List deptIds, + @RequestParam("afterId") Long afterId, + @RequestParam("limit") Integer limit); + /** * 通过用户名查询用户列表 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java new file mode 100644 index 000000000..c70843df3 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 供 Hardware 订单证据服务使用的受信人脸原图来源,不包含特征数据和审核流程内部字段。 + */ +@Data +@Schema(description = "人脸证据原图来源") +public class SysUserFaceEvidenceSourceVo { + + /** + * 人脸所属用户主键。 + */ + @Schema(description = "用户主键") + private Long userId; + + /** + * 虹软样本编号。 + */ + @Schema(description = "样本编号") + private String sampleId; + + /** + * 审核通过的人脸图片地址。 + */ + @Schema(description = "人脸图片地址") + private String faceImg; + + /** + * 人脸来源最后更新时间,用于证据审计和图片版本判断。 + */ + @Schema(description = "来源更新时间") + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceStatusCountVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceStatusCountVo.java new file mode 100644 index 000000000..2e01d65e6 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceStatusCountVo.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.admin.api.vo; + +import lombok.Data; + +/** + * 用户人脸状态数量,用于跨服务批量汇总人脸业务状态。 + */ +@Data +public class SysUserFaceStatusCountVo { + + /** + * 用户ID。 + */ + private Long userId; + + /** + * 审核通过且当前可用的人脸数量。 + */ + private Long availableCount; + + /** + * 当前仍在审核中的人脸数量。 + */ + private Long pendingCount; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java new file mode 100644 index 000000000..8bb574972 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java @@ -0,0 +1,65 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 终端人脸同步快照中的单条权威记录。 + * + *

记录只包含审核通过且位于目标部门树内的人脸;终端使用 {@code sampleId} 作为虹软搜索标识, + * 使用 {@code sourceUpdatedAt} 与图片地址共同判断是否需要重新提取特征。

+ */ +@Data +@Schema(description = "终端人脸同步快照记录") +public class SysUserFaceSyncRecordVo { + + /** + * 人脸记录数据库主键,用于游标排序和快照范围约束。 + */ + @Schema(description = "人脸记录数据库主键") + private Long id; + + /** + * 虹软人脸库搜索标识,来源于人脸记录 {@code auto_code},必须是 32 位正整数且在当前租户内唯一。 + */ + @Schema(description = "虹软人脸库搜索标识") + private String sampleId; + + /** + * 人脸所属用户主键,MQ 增量通知按该字段回查同一用户的最多三张人脸。 + */ + @Schema(description = "用户主键") + private Long userId; + + /** + * 用户姓名,仅用于终端本地展示和识别结果元数据。 + */ + @Schema(description = "用户姓名") + private String username; + + /** + * 用户当前所属部门主键,数据来源为 {@code sys_user.dept_id}。 + */ + @Schema(description = "当前所属部门主键") + private Long deptId; + + /** + * 用户当前所属部门名称,仅用于终端本地展示。 + */ + @Schema(description = "当前所属部门名称") + private String deptName; + + /** + * 服务端人脸图片地址;地址变化会触发终端重新下载和提取特征。 + */ + @Schema(description = "人脸图片地址") + private String faceImg; + + /** + * 服务端人脸图片版本时间;未修改的历史记录回退为创建时间,终端将其持久化作为上报版本依据。 + */ + @Schema(description = "服务端人脸图片版本时间") + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java new file mode 100644 index 000000000..e3d966a03 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java @@ -0,0 +1,41 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 终端部门人脸同步的游标快照响应。 + * + *

首次请求生成固定的 {@code snapshotMaxId},后续分页必须原样携带;终端只有在全部页面成功返回并校验后, + * 才能据此删除不在目标集合中的本地孤儿记录。

+ */ +@Data +@Schema(description = "终端部门人脸同步游标快照") +public class SysUserFaceSyncSnapshotVo { + + /** + * 当前页面的人脸记录,按数据库主键升序排列。 + */ + @Schema(description = "当前页面记录") + private List records; + + /** + * 下一页起始游标;当前页为空时沿用请求游标,末页无需继续请求。 + */ + @Schema(description = "下一页起始游标") + private Long nextAfterId; + + /** + * 是否仍有位于固定快照上界内的后续记录。 + */ + @Schema(description = "是否还有下一页") + private Boolean hasMore; + + /** + * 首次请求时固定的人脸数据库主键上界,后续分页必须原样携带。 + */ + @Schema(description = "固定快照主键上界") + private Long snapshotMaxId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalDeleteVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalDeleteVo.java new file mode 100644 index 000000000..84d2f2ae0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalDeleteVo.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.admin.api.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 终端批量删除人脸的幂等确认结果。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SysUserFaceTerminalDeleteVo { + + /** + * 本次实际逻辑删除的样本编号。 + */ + private List deletedSampleIds; + + /** + * 请求前已经不存在的样本编号,终端可继续清理本地副本。 + */ + private List alreadyAbsentSampleIds; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalQualityReportVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalQualityReportVo.java new file mode 100644 index 000000000..62fcc5654 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalQualityReportVo.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.admin.api.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 终端批量上报人脸质量后的幂等处理结果。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SysUserFaceTerminalQualityReportVo { + + /** + * 本次成功写入服务端的人脸样本编号。 + */ + private List updatedSampleIds; + + /** + * 因图片版本不一致、服务端已有成功结果或已有更新失败结果而忽略的样本编号。 + */ + private List ignoredStaleSampleIds; + + /** + * 请求时服务端已经不存在的样本编号,终端无需将其视为上报失败。 + */ + private List alreadyAbsentSampleIds; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java new file mode 100644 index 000000000..35f50e503 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +import java.util.List; + +/** + * 用户部门+商户部门树查询条件接口 + * 在 {@link BaseIsIncludeTreeInterface} 用户部门包含/不包含的基础上,增加商户部门的包含/不包含 + */ +public interface BaseIsIncludeDeptMchTreeInterface extends BaseIsIncludeTreeInterface { + + /** + * 商户部门ids是否包含 + */ + Boolean getIsIncludeMerchantDept(); + + void setIsIncludeMerchantDept(Boolean isIncludeMerchantDept); + + /** + * 商户部门ids + */ + List getMerchantDeptIds(); + + void setMerchantDeptIds(List merchantDeptIds); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java new file mode 100644 index 000000000..a825860a4 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.admin.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 定时任务配置 + * 启用定时任务功能,支持调岗钱包部门对账等周期性兜底任务 + */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5d8d140ed..42f9a4f62 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -437,6 +437,36 @@ public class SysUserController { return R.ok(userService.listUserIdByDeptIds(CollUtil.toList(deptIdList))); } + /** + * 离线人员同步的固定快照上界:部门树内(不传部门时为全租户)的最大用户主键。 + * + * @param deptIds 部门ID列表;空表示全租户,仅非生产调试同步使用 + * @return 最大用户主键;无有效用户时为 0 + */ + @Inner + @GetMapping("/inner/offline-user-max-id") + public R offlineUserMaxId( + @RequestParam(value = "deptIds", required = false) List deptIds) { + return R.ok(userService.maxUserIdByDeptIds(deptIds)); + } + + /** + * 离线人员同步的游标用户页:按主键升序返回大于 afterId 的一页用户。 + * + * @param deptIds 部门ID列表;空表示全租户 + * @param afterId 排他游标 + * @param limit 返回条数上限,服务端钳制在 1 到 501 之间 + * @return 按主键升序的一页用户 + */ + @Inner + @GetMapping("/inner/offline-user-cursor") + public R> offlineUserCursor( + @RequestParam(value = "deptIds", required = false) List deptIds, + @RequestParam Long afterId, + @RequestParam Integer limit) { + return R.ok(userService.listByUserIdCursor(deptIds, afterId, limit)); + } + /** * 根据部门id查询用户列表 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index 1ec032d71..aaa7f8a21 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -7,8 +7,16 @@ 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.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.admin.service.SysUserFaceService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -28,6 +36,7 @@ import org.springframework.http.HttpHeaders; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; @@ -58,6 +67,54 @@ public class SysUserFaceController { } /* ************************************* end 回调 end ************************************* */ + /* ************************************* start 远程调用 start ************************************* */ + /** + * Hardware 内部复制订单参考人脸时读取审核通过的人脸来源。 + */ + @Inner + @Operation(summary = "查询订单证据参考人脸来源") + @GetMapping("/inner/evidence-source") + public R evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) { + return R.ok(sysUserFaceService.evidenceSource(userId, sampleId)); + } + + /** + * 批量查询指定用户的人脸状态数量,调用方必须先完成自身业务授权校验。 + */ + @Inner + @Operation(summary = "批量查询用户人脸状态数量", description = "返回审核通过和审核中的人脸数量") + @PostMapping("/inner/status-count") + public R> listStatusCounts(@RequestBody List userIds) { + return R.ok(sysUserFaceService.listStatusCounts(userIds)); + } + + /** + * 按 Hardware 提供的可信部门范围执行终端人脸删除。 + * + * @param dto 商户部门和样本编号 + * @return 实际删除及已不存在的样本编号 + */ + @Inner + @Operation(summary = "终端按部门范围删除人脸") + @PostMapping("/inner/terminal-delete") + public R terminalDelete(@Valid @RequestBody SysUserFaceTerminalDeleteDto dto) { + return R.ok(sysUserFaceService.terminalDelete(dto)); + } + + /** + * 按 Hardware 提供的可信设备和部门范围保存终端人脸质量结果。 + * + * @param dto 商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @Inner + @Operation(summary = "终端按部门范围上报人脸质量") + @PostMapping("/inner/terminal-quality-report") + public R terminalReportQuality(@Valid @RequestBody SysUserFaceTerminalQualityReportDto dto) { + return R.ok(sysUserFaceService.terminalReportQuality(dto)); + } + /* ************************************* end 远程调用 end ************************************* */ + /* ************************************* start app start ************************************* */ @Operation(summary = "app查询人脸列表", description = "app查询人脸列表") @GetMapping("/app/page-join") @@ -121,6 +178,18 @@ public class SysUserFaceController { dto.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); return R.ok(sysUserFaceService.allPageJoin(page, dto).getRecords()); } + + /** + * 按固定数据库主键上界游标返回本部门及子部门的人脸权威快照。 + * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页快照数据 + */ + @Operation(summary = "android人脸部门快照", description = "按数据库主键游标同步本部门及子部门审核通过的人脸") + @PostMapping("/ad/v3/sync-snapshot") + public R adV3SyncSnapshot(@Valid @RequestBody SysUserFaceSyncSnapshotDto dto) { + return R.ok(sysUserFaceService.syncSnapshot(dto)); + } /* ************************************* start android start ************************************* */ @Operation(summary = "设置为全局人脸", description = "设置为全局人脸") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java new file mode 100644 index 000000000..4aada4c9a --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.admin.handler; + +/** + * 用户默认钱包创建事件。 + * + *

事件在用户保存事务中发布,只携带异步调用所需的稳定标识,避免跨线程持有已结束的 HTTP 请求对象。

+ */ +public final class UserWalletCreateEvent { + + /** + * 已保存的用户主键;钱包服务使用该主键回查用户及其自动创建钱包角色。 + */ + private final Long userId; + + /** + * 发布事件时的租户主键;为空时沿用下游服务的默认租户处理规则。 + */ + private final Long tenantId; + + /** + * 创建用户默认钱包事件。 + * + * @param userId 已保存的用户主键 + * @param tenantId 用户所属租户主键,非 Web 调用场景下允许为空 + */ + public UserWalletCreateEvent(Long userId, Long tenantId) { + this.userId = userId; + this.tenantId = tenantId; + } + + /** + * 获取需要创建默认钱包的用户主键。 + * + * @return 已保存的用户主键 + */ + public Long getUserId() { + return userId; + } + + /** + * 获取事件发布时的租户主键。 + * + * @return 租户主键,非 Web 调用场景下可能为空 + */ + public Long getTenantId() { + return tenantId; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java new file mode 100644 index 000000000..ae1010a38 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.admin.handler; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 用户默认钱包创建事件监听器。 + * + *

监听器在用户事务提交成功后才进入 Spring 管理的异步线程池,避免钱包服务回查到未提交的用户数据。 + * 调用钱包服务失败时进行有限次重试(createWallet、userChange 均为幂等操作),重试仍失败则记录日志, + * 由调岗钱包对账定时任务兜底修复,保证钱包部门与用户部门最终一致。

+ */ +@Slf4j +@Component +public class UserWalletCreateEventListener { + + /** + * 最大尝试次数:首次调用 + 2 次重试。 + */ + private static final int MAX_ATTEMPTS = 3; + + /** + * 重试间隔(毫秒)。 + */ + private static final long RETRY_DELAY_MILLIS = 5_000L; + + /** + * 钱包服务远程接口,用于创建默认钱包并同步用户部门等关联信息。 + */ + private final RemoteWalletService remoteWalletService; + + /** + * 创建用户默认钱包事件监听器。 + * + * @param remoteWalletService 钱包服务远程接口 + */ + public UserWalletCreateEventListener(RemoteWalletService remoteWalletService) { + this.remoteWalletService = remoteWalletService; + } + + /** + * 在用户事务提交成功后异步创建默认钱包并通知钱包服务刷新用户信息。 + * + *

无事务调用使用 fallbackExecution 直接进入异步线程;此类调用中的用户修改已按单条语句提交, + * 不需要等待事务同步回调。异步线程通过 TenantBroker 恢复事件发布时的租户,确保 Feign 请求携带正确租户头。

+ * + * @param event 用户主键及事件发布时租户信息 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void createDefaultWallet(UserWalletCreateEvent event) { + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + Boolean success = TenantBroker.applyAs(event.getTenantId(), tenantId -> syncUserWallet(event.getUserId())); + if (Boolean.TRUE.equals(success)) { + if (attempt > 1) { + log.warn("用户钱包同步第{}次尝试成功,userId:{},tenantId:{}", attempt, event.getUserId(), event.getTenantId()); + } + return; + } + log.error("用户钱包同步第{}/{}次未成功,userId:{},tenantId:{}", attempt, MAX_ATTEMPTS, event.getUserId(), event.getTenantId()); + } catch (Exception exception) { + log.error("用户钱包同步第{}/{}次异常,userId:{},tenantId:{}", attempt, MAX_ATTEMPTS, event.getUserId(), event.getTenantId(), exception); + } + if (attempt < MAX_ATTEMPTS) { + try { + Thread.sleep(RETRY_DELAY_MILLIS); + } catch (InterruptedException interrupt) { + Thread.currentThread().interrupt(); + log.error("用户钱包同步重试等待被中断,userId:{},tenantId:{}", event.getUserId(), event.getTenantId()); + return; + } + } + } + log.error("用户钱包同步重试耗尽,userId:{},tenantId:{},等待对账任务兜底修复", event.getUserId(), event.getTenantId()); + } + + /** + * 创建缺失的默认钱包并同步用户部门等关联信息。 + * + * @return 全部调用成功返回 true;任一调用无返回或业务失败返回 false,由外层决定是否重试 + */ + private boolean syncUserWallet(Long userId) { + R createResult = remoteWalletService.createWallet(userId); + if (createResult == null) { + log.error("创建默认钱包失败,userId:{},钱包服务未返回结果", userId); + return false; + } + if (!createResult.isOk()) { + log.error("创建默认钱包失败,userId:{},code:{},msg:{}", userId, createResult.getCode(), createResult.getMsg()); + return false; + } + + R changeResult = remoteWalletService.userChange(userId, Boolean.FALSE); + if (changeResult == null) { + log.error("同步用户钱包信息失败,userId:{},钱包服务未返回结果", userId); + return false; + } + if (!changeResult.isOk()) { + log.error("同步用户钱包信息失败,userId:{},code:{},msg:{}", userId, changeResult.getCode(), changeResult.getMsg()); + return false; + } + return true; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java index 586092a7f..7d0b225de 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java @@ -2,12 +2,16 @@ package com.bsd.pjcloud.admin.mapper; import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; @@ -27,6 +31,115 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper Long selectAllCount(); + /** + * 查询大于当前全部数字人脸编号的下一个编号,作为新样本的虹软搜索标识。 + * + *

编号分配必须覆盖已逻辑删除行,否则删除后重新分配会与在用编号冲突; + * 非数字的历史编号不参与取最大值。

+ * + * @return 下一个可用的人脸编号,无数字编号时为 1 + */ + Long selectNextAutoCode(); + + /** + * 按用户和虹软样本编号读取审核通过的人脸原图来源,供订单证据服务复制订单专属快照。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @param processStatus 审核通过状态编码 + * @return 原图来源;不存在时为 {@code null} + */ + default SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId, String processStatus) { + SysUserFaceEntity entity = selectOne(new LambdaQueryWrapper() + .eq(SysUserFaceEntity::getUserId, userId) + .eq(SysUserFaceEntity::getAutoCode, sampleId) + .eq(SysUserFaceEntity::getProcessStatus, processStatus)); + if (entity == null) return null; + SysUserFaceEvidenceSourceVo source = new SysUserFaceEvidenceSourceVo(); + source.setUserId(entity.getUserId()); + source.setSampleId(entity.getAutoCode()); + source.setFaceImg(entity.getFaceImg()); + source.setSourceUpdatedAt(entity.getUpdateTime()); + return source; + } + + /** + * 查询仍存在的目标样本及其用户当前部门,供终端删除执行整批范围校验。 + * + * @param sampleIds 虹软样本编号 + * @return 人脸记录及用户部门;用户异常缺失时部门为空并由服务层拒绝 + */ + default List terminalDeleteCandidates(List sampleIds) { + MPJLambdaWrapper wrapper = new MPJLambdaWrapper() + .selectAll(SysUserFaceEntity.class) + .selectAs(SysUser::getDeptId, SysUserFaceVo::getDeptId) + .leftJoin(SysUser.class, SysUser::getUserId, SysUserFaceEntity::getUserId) + .in(SysUserFaceEntity::getAutoCode, sampleIds); + return selectJoinList(SysUserFaceVo.class, wrapper); + } + + /** + * 查询仍存在的目标样本、当前质量时间和用户部门,供终端质量上报执行整批范围校验。 + * + * @param sampleIds 虹软样本编号 + * @return 人脸记录、当前质量字段及用户部门;用户异常缺失时由服务层整批拒绝 + */ + default List terminalQualityCandidates(List sampleIds) { + MPJLambdaWrapper wrapper = new MPJLambdaWrapper() + .selectAll(SysUserFaceEntity.class) + .selectAs(SysUser::getDeptId, SysUserFaceVo::getDeptId) + .leftJoin(SysUser.class, SysUser::getUserId, SysUserFaceEntity::getUserId) + .in(SysUserFaceEntity::getAutoCode, sampleIds); + return selectJoinList(SysUserFaceVo.class, wrapper); + } + + /** + * 审计目标部门树内审核通过且未删除的人脸编号是否存在数值冲突。 + * + * @param deptIds 终端绑定部门及其有效子部门主键 + * @param processStatus 审核通过状态编码 + * @return 当前同步范围的重复数字编号分组数量;大于零时禁止下发快照 + */ + Long countSyncDuplicateAutoCodes(@Param("deptIds") List deptIds, @Param("processStatus") String processStatus); + + /** + * 查询目标部门树和固定主键上界内的最大人脸主键,用于建立稳定快照边界。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键,MQ 增量同步时使用 + * @param processStatus 审核通过状态编码 + * @return 快照最大主键;没有记录时返回 {@code 0} + */ + Long selectSyncSnapshotMaxId(@Param("deptIds") List deptIds, @Param("userId") Long userId, @Param("processStatus") String processStatus); + + /** + * 按数据库主键升序读取终端人脸快照的一页,多取一条用于判断是否存在下一页。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键 + * @param afterId 排他起始游标 + * @param snapshotMaxId 固定快照主键上界 + * @param processStatus 审核通过状态编码 + * @param fetchSize 实际读取数量,通常为页面大小加一 + * @return 按人脸数据库主键严格升序排列的记录 + */ + List listSyncSnapshotRecords(@Param("deptIds") List deptIds, + @Param("userId") Long userId, + @Param("afterId") Long afterId, + @Param("snapshotMaxId") Long snapshotMaxId, + @Param("processStatus") String processStatus, + @Param("fetchSize") int fetchSize); + + /** + * 按用户批量统计可用与审核中的人脸数量。 + * + * @param userIds 用户ID列表 + * @param availableStatus 审核通过状态编码 + * @param pendingStatus 审核中状态编码 + * @return 各用户的人脸状态数量 + */ + List listStatusCounts(@Param("userIds") List userIds, @Param("availableStatus") String availableStatus, @Param("pendingStatus") String pendingStatus); + default SysUserFaceVo join(Long id) { MPJLambdaWrapper wrapper = new MPJLambdaWrapper() .selectAll(SysUserFaceEntity.class) diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index 2f23be4c7..13e94ef06 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -3,18 +3,57 @@ package com.bsd.pjcloud.admin.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import java.util.ArrayList; +import java.util.List; public interface SysUserFaceService extends IService { Long create(SysUserFaceEntity sysUserFace); + /** + * 按固定主键上界游标读取本部门及子部门的审核通过人脸。 + * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页快照和下一页信息 + */ + SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto); + + /** + * 校验全部样本属于目标部门树后执行逻辑删除,任何越权样本都会整批拒绝。 + * + * @param dto 可信商户部门和样本编号 + * @return 实际删除及已不存在的样本编号 + */ + SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto); + + /** + * 幂等保存质量结果,图片版本和成功状态优先级均不得倒退;跨部门样本仅出现在 + * 全租户调试终端,质量写入按实测结果落库并记录来源设备。 + * + * @param dto 可信商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + SysUserFaceTerminalQualityReportVo terminalReportQuality(SysUserFaceTerminalQualityReportDto dto); + + /** + * 批量统计指定用户的人脸可用与审核中数量。 + */ + List listStatusCounts(List userIds); + void edit(SysUserFaceEntity sysUserFace); Boolean editGlobalFace(SysUserFaceDto dto); @@ -29,6 +68,15 @@ public interface SysUserFaceService extends IService { SysUserFaceVo join(Long id); + /** + * 读取审核通过人脸的受信原图来源,供订单证据服务复制订单快照。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @return 原图来源;不存在时为空 + */ + SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId); + /** * 开启审批 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 6664304d8..9e61b1f15 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -196,6 +196,24 @@ public interface SysUserService extends IService { */ List listUserIdByDeptIds(List deptIdList); + /** + * 查询部门范围内(不传部门时为全租户)的最大用户主键,作为离线人员同步的固定快照上界。 + * + * @param deptIdList 部门ID列表;空表示全租户 + * @return 最大用户主键;无有效用户时为 0 + */ + Long maxUserIdByDeptIds(List deptIdList); + + /** + * 按用户主键升序游标查询部门范围内(不传部门时为全租户)的一页用户。 + * + * @param deptIdList 部门ID列表;空表示全租户 + * @param afterId 排他游标,返回主键严格大于该值的用户 + * @param limit 返回条数上限,服务端钳制在 1 到 501 之间 + * @return 按主键升序的一页用户 + */ + List listByUserIdCursor(List deptIdList, Long afterId, Integer limit); + /** * 重置用户密码 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java index cc023e513..97d7b1405 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java @@ -109,14 +109,16 @@ public class SysFileServiceImpl extends ServiceImpl impl @Override public R innerUpload(MultipartFile file, String dir, Long groupId, String type) { String fileName = IdUtil.simpleUUID() + StrUtil.DOT + FileUtil.extName(file.getOriginalFilename()); - Map resultMap = new HashMap<>(4); + Map resultMap = new HashMap<>(5); resultMap.put("bucketName", properties.getBucketName()); resultMap.put("fileName", fileName); resultMap.put("url", String.format("/admin/sys-file/oss/file?fileName=%s", fileName)); try (InputStream inputStream = file.getInputStream()) { fileTemplate.putObject(properties.getBucketName(), dir, fileName, inputStream, file.getContentType()); // 文件管理数据记录,收集管理追踪文件 - innerFileLog(file, dir, fileName, groupId, type); + SysFile sysFile = innerFileLog(file, dir, fileName, groupId, type); + // 内部上传调用方需要文件中心主键执行后续替换、清理和保留周期管理。 + resultMap.put("id", sysFile.getId()); } catch (Exception e) { return R.failed(e.getLocalizedMessage()); } @@ -283,7 +285,7 @@ public class SysFileServiceImpl extends ServiceImpl impl } @SneakyThrows - private void innerFileLog(MultipartFile file, String dir, String fileName, Long groupId, String type) { + private SysFile innerFileLog(MultipartFile file, String dir, String fileName, Long groupId, String type) { // 创建SysFile对象并设置相关属性 SysFile sysFile = new SysFile(); sysFile.setFileName(fileName); @@ -304,6 +306,7 @@ public class SysFileServiceImpl extends ServiceImpl impl sysFile.setUpdateBy("SYSTEM"); // 调用save方法保存SysFile对象 this.save(sysFile); + return sysFile; } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index c98b8f52d..e777f0b58 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -1,17 +1,29 @@ package com.bsd.pjcloud.admin.service.impl; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceQualityReportItemDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.service.SysDeptService; @@ -43,10 +55,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * 人脸样本表 @@ -62,6 +81,31 @@ public class SysUserFaceServiceImpl extends ServiceImpl listStatusCounts(List userIds) { + List distinctUserIds = userIds == null ? List.of() : userIds.stream().filter(Objects::nonNull).distinct().toList(); + if (distinctUserIds.isEmpty()) { + return List.of(); + } + return baseMapper.listStatusCounts(distinctUserIds, TaskCheckStatusEnum.RESULT.getCode(), TaskCheckStatusEnum.PADDING.getCode()); + } + + /** + * 按固定数据库主键上界读取本部门及子部门的审核通过人脸快照。 + * + *

该方法显式排除外部门全局人脸。调用方必须完整拉取所有页面后再做本地孤儿清理; + * 服务端仅审计当前部门树内实际生效的人脸编号;外部门、已删除和未审核历史记录不会阻断本机同步。

+ * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页面、下一游标和固定快照上界 + */ + @Override + public SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto) { + Long merchantDeptId = dto.getMerchantDeptId(); + Long userId = dto.getUserId(); + Long afterId = dto.getAfterId(); + Integer limit = dto.getLimit(); + Long snapshotMaxId = dto.getSnapshotMaxId(); + if (merchantDeptId == null) { + throw new RuntimeException("商户部门不能为空"); + } + List deptIds = sysDeptService.listDescendant(merchantDeptId).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("商户部门不存在或已删除"); + } + assertSyncFaceAutoCodeUnique(deptIds, TaskCheckStatusEnum.RESULT.getCode()); + + long normalizedAfterId = afterId == null ? 0L : afterId; + if (normalizedAfterId < 0) { + throw new RuntimeException("人脸同步游标不能小于0"); + } + int normalizedLimit = limit == null ? FACE_SYNC_DEFAULT_LIMIT : limit; + if (normalizedLimit <= 0 || normalizedLimit > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("人脸同步分页大小必须在1到" + FACE_SYNC_MAX_LIMIT + "之间"); + } + Long normalizedSnapshotMaxId = snapshotMaxId; + if (normalizedSnapshotMaxId == null) { + normalizedSnapshotMaxId = baseMapper.selectSyncSnapshotMaxId(deptIds, userId, TaskCheckStatusEnum.RESULT.getCode()); + } + if (normalizedSnapshotMaxId == null || normalizedSnapshotMaxId < 0) { + throw new RuntimeException("人脸同步快照上界无效"); + } + if (normalizedAfterId > normalizedSnapshotMaxId) { + throw new RuntimeException("人脸同步游标超过快照上界"); + } + + List fetchedRecords = baseMapper.listSyncSnapshotRecords( + deptIds, userId, normalizedAfterId, normalizedSnapshotMaxId, + TaskCheckStatusEnum.RESULT.getCode(), normalizedLimit + 1); + boolean hasMore = fetchedRecords.size() > normalizedLimit; + List records = hasMore + ? new ArrayList<>(fetchedRecords.subList(0, normalizedLimit)) + : new ArrayList<>(fetchedRecords); + records.forEach(this::validateSyncRecord); + + SysUserFaceSyncSnapshotVo snapshot = new SysUserFaceSyncSnapshotVo(); + snapshot.setRecords(records); + snapshot.setNextAfterId(records.isEmpty() ? normalizedAfterId : records.get(records.size() - 1).getId()); + snapshot.setHasMore(hasMore); + snapshot.setSnapshotMaxId(normalizedSnapshotMaxId); + return snapshot; + } + + /** + * 按 Hardware 提供的可信商户部门树批量删除人脸。 + * + *

先一次性查询所有仍存在的样本并校验用户当前部门;任意记录不在部门树内时 + * 立即抛出异常,删除逻辑尚未执行,因此不会产生部分删除。重复样本按首次顺序 + * 去重;服务端已经不存在的样本作为幂等成功返回给终端。

+ * + * @param dto 商户部门和待删除样本编号 + * @return 实际删除及请求前已不存在的样本编号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto) { + if (dto == null || dto.getMerchantDeptId() == null || ObjectUtil.isEmpty(dto.getSampleIds())) { + throw new RuntimeException("终端人脸删除参数不能为空"); + } + if (dto.getSampleIds().size() > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("单次最多删除" + FACE_SYNC_MAX_LIMIT + "条人脸"); + } + + LinkedHashSet normalizedSet = new LinkedHashSet<>(); + for (String sampleId : dto.getSampleIds()) { + normalizedSet.add(normalizeTerminalSampleId(sampleId)); + } + List sampleIds = new ArrayList<>(normalizedSet); + List deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("终端绑定部门不存在或已删除"); + } + + List candidates = baseMapper.terminalDeleteCandidates(sampleIds); + Set allowedDeptIds = new HashSet<>(deptIds); + boolean outOfScope = candidates.stream() + .anyMatch(face -> face.getDeptId() == null || !allowedDeptIds.contains(face.getDeptId())); + if (outOfScope) { + throw new RuntimeException("请求包含不属于当前设备部门范围的人脸"); + } + + Set existingSampleIds = candidates.stream() + .map(SysUserFaceVo::getAutoCode) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + List deletedSampleIds = sampleIds.stream() + .filter(existingSampleIds::contains) + .toList(); + List alreadyAbsentSampleIds = sampleIds.stream() + .filter(sampleId -> !existingSampleIds.contains(sampleId)) + .toList(); + ArrayList databaseIds = candidates.stream() + .map(SysUserFaceVo::getId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + delByIds(databaseIds); + return SysUserFaceTerminalDeleteVo.builder() + .deletedSampleIds(deletedSampleIds) + .alreadyAbsentSampleIds(alreadyAbsentSampleIds) + .build(); + } + + /** + * 按 Hardware 提供的可信设备和商户部门树幂等保存人脸质量结果。 + * + *

先校验完整请求、审核通过状态和部门范围,再逐条按图片版本与状态优先级更新。 + * 质量回写故意不修改 {@code update_time} 且不发布人脸变更事件,避免终端将 + * 质量变化误判为图片版本变化并形成重复下载、检测和上报循环。

+ * + * @param dto 商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SysUserFaceTerminalQualityReportVo terminalReportQuality(SysUserFaceTerminalQualityReportDto dto) { + if (dto == null || dto.getMerchantDeptId() == null || dto.getQualityDeviceId() == null + || dto.getQualityDeviceId() <= 0 || ObjectUtil.isEmpty(dto.getItems())) { + throw new RuntimeException("终端人脸质量上报参数不能为空"); + } + if (dto.getItems().size() > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("单次最多上报" + FACE_SYNC_MAX_LIMIT + "条人脸质量结果"); + } + + LinkedHashMap itemBySampleId = new LinkedHashMap<>(); + for (SysUserFaceQualityReportItemDto item : dto.getItems()) { + validateQualityReportItem(item); + String sampleId = normalizeTerminalSampleId(item.getSampleId()); + item.setSampleId(sampleId); + if (itemBySampleId.putIfAbsent(sampleId, item) != null) { + throw new RuntimeException("人脸质量上报包含重复样本编号"); + } + } + + List deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("终端绑定部门不存在或已删除"); + } + + List candidates = baseMapper.terminalQualityCandidates( + new ArrayList<>(itemBySampleId.keySet())); + boolean notApproved = candidates.stream() + .anyMatch(face -> !TaskCheckStatusEnum.RESULT.getCode().equals(face.getProcessStatus())); + if (notApproved) { + throw new RuntimeException("请求包含未审核通过的人脸"); + } + + LinkedHashMap candidateBySampleId = new LinkedHashMap<>(); + for (SysUserFaceVo candidate : candidates) { + String sampleId = StrUtil.trim(candidate.getAutoCode()); + if (candidate.getId() == null || StrUtil.isBlank(sampleId) + || candidateBySampleId.putIfAbsent(sampleId, candidate) != null) { + throw new RuntimeException("服务端存在重复或无效人脸样本编号"); + } + } + + // 不限制样本所属部门:生产终端经 V3 部门快照只持有本部门树人脸,跨部门样本仅出现在 + // 全租户调试同步中;质量写入受图片版本严格相等保护并记录来源设备,按实测结果落库。 + List updatedSampleIds = new ArrayList<>(); + List ignoredStaleSampleIds = new ArrayList<>(); + List alreadyAbsentSampleIds = new ArrayList<>(); + for (Map.Entry entry : itemBySampleId.entrySet()) { + String sampleId = entry.getKey(); + SysUserFaceQualityReportItemDto item = entry.getValue(); + SysUserFaceVo candidate = candidateBySampleId.get(sampleId); + if (candidate == null) { + alreadyAbsentSampleIds.add(sampleId); + continue; + } + LocalDateTime sourceUpdatedAt = candidate.getUpdateTime() == null + ? candidate.getCreateTime() + : candidate.getUpdateTime(); + if (sourceUpdatedAt == null || !sourceUpdatedAt.equals(item.getSourceUpdatedAt())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + // 同一图片版本的首个有效 detected 是服务端权威结果,失败设备和重复设备均不得覆盖。 + if (FACE_QUALITY_STATUS_DETECTED.equals(candidate.getQualityStatus())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + if (FACE_QUALITY_STATUS_FAILED.equals(item.getStatus()) + && candidate.getQualityCheckedAt() != null + && !item.getCheckedAt().isAfter(candidate.getQualityCheckedAt())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .set(SysUserFaceEntity::getQualityScore, item.getScore()) + .set(SysUserFaceEntity::getQualityStatus, item.getStatus()) + .set(SysUserFaceEntity::getQualityCheckedAt, item.getCheckedAt()) + .set(SysUserFaceEntity::getQualityErrorCode, item.getErrorCode()) + .set(SysUserFaceEntity::getQualityDeviceId, dto.getQualityDeviceId()) + .eq(SysUserFaceEntity::getId, candidate.getId()) + .and(version -> version.eq(SysUserFaceEntity::getUpdateTime, item.getSourceUpdatedAt()) + .or(fallback -> fallback.isNull(SysUserFaceEntity::getUpdateTime) + .eq(SysUserFaceEntity::getCreateTime, item.getSourceUpdatedAt()))); + if (FACE_QUALITY_STATUS_DETECTED.equals(item.getStatus())) { + updateWrapper.and(status -> status.isNull(SysUserFaceEntity::getQualityStatus) + .or() + .ne(SysUserFaceEntity::getQualityStatus, FACE_QUALITY_STATUS_DETECTED)); + } else { + updateWrapper.and(status -> status.isNull(SysUserFaceEntity::getQualityStatus) + .or(failed -> failed.eq(SysUserFaceEntity::getQualityStatus, FACE_QUALITY_STATUS_FAILED) + .and(time -> time.isNull(SysUserFaceEntity::getQualityCheckedAt) + .or() + .lt(SysUserFaceEntity::getQualityCheckedAt, item.getCheckedAt())))); + } + int updatedRows = baseMapper.update(null, updateWrapper); + if (updatedRows > 0) { + updatedSampleIds.add(sampleId); + } else { + ignoredStaleSampleIds.add(sampleId); + } + } + return SysUserFaceTerminalQualityReportVo.builder() + .updatedSampleIds(updatedSampleIds) + .ignoredStaleSampleIds(ignoredStaleSampleIds) + .alreadyAbsentSampleIds(alreadyAbsentSampleIds) + .build(); + } + + /** + * 校验一条质量结果的状态、分数、错误码和检测时间组合。 + * + * @param item 待校验的终端质量结果 + */ + private void validateQualityReportItem(SysUserFaceQualityReportItemDto item) { + if (item == null || item.getSourceUpdatedAt() == null || item.getCheckedAt() == null) { + throw new RuntimeException("人脸质量结果、图片版本或检测时间不能为空"); + } + String status = StrUtil.trim(item.getStatus()); + item.setStatus(status); + if (FACE_QUALITY_STATUS_DETECTED.equals(status)) { + if (item.getScore() == null || item.getScore().compareTo(BigDecimal.ZERO) < 0 + || item.getScore().compareTo(BigDecimal.ONE) > 0) { + throw new RuntimeException("检测成功的人脸质量分必须在0到1之间"); + } + if (item.getErrorCode() != null) { + throw new RuntimeException("检测成功的人脸质量结果不能包含错误码"); + } + return; + } + if (FACE_QUALITY_STATUS_FAILED.equals(status)) { + if (item.getScore() != null) { + throw new RuntimeException("检测失败的人脸质量结果不能包含分数"); + } + return; + } + throw new RuntimeException("人脸质量检测状态仅支持detected或failed"); + } + + /** + * 将终端样本编号规范化为虹软可接受的 32 位正整数文本。 + * + * @param sampleId 原始样本编号 + * @return 去除首尾空白后的样本编号 + */ + private String normalizeTerminalSampleId(String sampleId) { + String normalized = StrUtil.trim(sampleId); + try { + long numeric = Long.parseLong(normalized); + if (numeric <= 0 || numeric > FACE_AUTO_CODE_MAX_VALUE) { + throw new NumberFormatException("out of range"); + } + } catch (Exception exception) { + throw new RuntimeException("存在无效人脸样本编号"); + } + return normalized; + } + + /** + * 校验快照记录的虹软搜索标识可被 Android 端安全解析为 32 位正整数。 + * + * @param record 待下发的快照记录 + */ + private void validateSyncRecord(SysUserFaceSyncRecordVo record) { + try { + long sampleId = Long.parseLong(record.getSampleId()); + if (sampleId <= 0 || sampleId > FACE_AUTO_CODE_MAX_VALUE) { + throw new NumberFormatException("out of range"); + } + } catch (NumberFormatException exception) { + throw new RuntimeException("存在无效的人脸编号,faceId=" + record.getId() + ",请先完成数据修复", exception); + } + } + + /** + * 审计当前终端部门树内实际生效的人脸编号是否冲突。 + * + *

同步只维护本部门及子部门,因此外部门、已删除和未审核记录即使存在历史重复编号, + * 也不应阻断当前终端;当前范围内的冲突仍必须停止下发,避免虹软相同搜索标识互相覆盖。

+ * + * @param deptIds 终端绑定部门及其有效子部门主键 + * @param processStatus 审核通过状态编码 + */ + private void assertSyncFaceAutoCodeUnique(List deptIds, String processStatus) { + Long duplicateCount = baseMapper.countSyncDuplicateAutoCodes(deptIds, processStatus); + if (duplicateCount != null && duplicateCount > 0) { + throw new RuntimeException("当前部门范围存在重复的人脸编号,已停止同步,请先修复冲突数据"); + } + } + /** * 开启审批 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 0c84fef92..db2d39a3d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -45,6 +45,7 @@ import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.*; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.mapper.SysUserPostMapper; import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper; import com.bsd.pjcloud.admin.service.*; @@ -80,6 +81,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisCallback; @@ -136,6 +138,7 @@ public class SysUserServiceImpl extends ServiceImpl impl private final CacheManager cacheManager; private final RemoteWalletService remoteWalletService; + private final ApplicationEventPublisher applicationEventPublisher; private final SysFileService sysFileService; @@ -208,15 +211,8 @@ public class SysUserServiceImpl extends ServiceImpl impl if (isDeptAdmin) { getSelf().applyFlow(sysUser); } else { - // 创建用户默认钱包、保证线程header - RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - CompletableFuture.runAsync(() -> { - RequestContextHolder.setRequestAttributes(requestAttributes); - // 创建钱包 - remoteWalletService.createWallet(sysUser.getUserId()); - // 通知钱包 - remoteWalletService.userChange(sysUser.getUserId(), Boolean.FALSE); - }); + // 事务提交前其他连接无法读取新用户,因此仅发布事件,由监听器在提交成功后异步创建钱包。 + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId())); } return sysUser.getUserId(); @@ -958,6 +954,26 @@ public class SysUserServiceImpl extends ServiceImpl impl return baseMapper.selectList(Wrappers.lambdaQuery().in(SysUser::getDeptId, deptIdList)); } + @Override + public Long maxUserIdByDeptIds(List deptIdList) { + SysUser top = baseMapper.selectOne(Wrappers.lambdaQuery() + .select(SysUser::getUserId) + .in(CollUtil.isNotEmpty(deptIdList), SysUser::getDeptId, deptIdList) + .orderByDesc(SysUser::getUserId) + .last("limit 1")); + return top == null || top.getUserId() == null ? 0L : top.getUserId(); + } + + @Override + public List listByUserIdCursor(List deptIdList, Long afterId, Integer limit) { + int normalizedLimit = limit == null ? 1 : Math.max(1, Math.min(limit, 501)); + return baseMapper.selectList(Wrappers.lambdaQuery() + .gt(SysUser::getUserId, afterId == null ? 0L : afterId) + .in(CollUtil.isNotEmpty(deptIdList), SysUser::getDeptId, deptIdList) + .orderByAsc(SysUser::getUserId) + .last("limit " + normalizedLimit)); + } + @Override public List getUserDeptByIds(List userIds) { return baseMapper.selectVoListByScope(new UserDTO(), userIds.toArray(new Long[0]), null); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java index e5a23ec85..aa8e33d8e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java @@ -18,6 +18,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserTransactionEntity; import com.bsd.pjcloud.admin.api.vo.SysUserTransactionVo; import com.bsd.pjcloud.admin.mapper.SysUserTransactionMapper; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.service.SysDeptService; import com.bsd.pjcloud.admin.service.SysUserService; import com.bsd.pjcloud.admin.service.SysUserTransactionService; @@ -34,22 +35,19 @@ import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import com.bsd.pjcloud.flow.task.dto.TaskParamDto; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRuleTransactionService; -import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletThirdZyService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; /** * 人员调动表 @@ -69,7 +67,7 @@ public class SysUserTransactionServiceImpl extends ServiceImpl { - RequestContextHolder.setRequestAttributes(requestAttributes); - remoteWalletService.userChange(detail.getUserId(), Boolean.FALSE); - }); + // 通知钱包进行相关修改:发布事件由监听器异步执行,携带租户上下文并记录失败日志 + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(detail.getUserId(), TenantContextHolder.getTenantId())); sysUserService.deleteDiscountsCacheByUserId(detail.getUserId()); } } catch (Exception e) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java new file mode 100644 index 000000000..8cee9d6e9 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.admin.task; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.entity.SysUserTransactionEntity; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; +import com.bsd.pjcloud.admin.service.SysUserTransactionService; +import com.bsd.pjcloud.admin.service.SysUserService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 调岗审批后钱包部门对账任务。 + * + *

审批回调通过事件异步通知钱包服务并带重试,但钱包服务长时间不可用等情况下重试仍会耗尽, + * 导致钱包部门与用户部门不一致。本任务周期性比对近期审批通过的调岗记录,发现不一致即重新发布同步事件, + * 使钱包部门与用户部门最终一致。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SysUserTransactionWalletSyncTask { + + private static final String TAG = "调岗钱包对账"; + + /** + * 对账窗口:比对最近 N 天内审批通过的调岗记录。 + */ + private static final int CHECK_DAYS = 7; + + private final SysUserTransactionService sysUserTransactionService; + private final SysUserService sysUserService; + private final RemoteWalletService remoteWalletService; + private final ApplicationEventPublisher applicationEventPublisher; + + /** + * 每小时对账一次,兜底审批回调时丢失的钱包部门同步。 + * + *

定时线程无租户上下文,租户拦截器对空租户不过滤,因此初始查询能覆盖全部租户的记录; + * 单条记录的比对在记录自身租户下执行,保证用户和钱包查询的租户条件正确。

+ */ + @Scheduled(cron = "0 25 * * * ?") + public void reconcileWalletDept() { + try { + List list = sysUserTransactionService.list(Wrappers.lambdaQuery() + .eq(SysUserTransactionEntity::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode()) + .ge(SysUserTransactionEntity::getProcessTime, LocalDateTime.now().minusDays(CHECK_DAYS))); + if (CollUtil.isEmpty(list)) { + return; + } + // 同一用户多条调岗记录只对账一次,以最新记录的租户为准 + Map userTenantMap = list.stream().collect(Collectors.toMap( + SysUserTransactionEntity::getUserId, SysUserTransactionEntity::getTenantId, (first, second) -> second)); + int mismatchCount = 0; + for (Map.Entry entry : userTenantMap.entrySet()) { + Long userId = entry.getKey(); + try { + if (isWalletDeptMismatched(userId, entry.getValue())) { + mismatchCount++; + log.warn("|{}|用户<{}>钱包部门与用户部门不一致,重新触发钱包同步,tenantId:{}", TAG, userId, entry.getValue()); + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(userId, entry.getValue())); + } + } catch (Exception e) { + log.error("|{}|用户<{}>对账异常", TAG, userId, e); + } + } + if (mismatchCount > 0) { + log.warn("|{}|比对{}个用户,{}个钱包部门不一致已重新触发同步", TAG, userTenantMap.size(), mismatchCount); + } else { + log.info("|{}|比对{}个用户,钱包部门全部一致", TAG, userTenantMap.size()); + } + } catch (Exception e) { + log.error("|{}|执行异常", TAG, e); + } + } + + /** + * 判断用户饭卡钱包部门是否与用户当前部门不一致。 + * + * @return 不一致返回 true;用户已删除、部门为空、没有饭卡钱包等无法比对的情况返回 false + */ + private boolean isWalletDeptMismatched(Long userId, Long tenantId) { + return Boolean.TRUE.equals(TenantBroker.applyAs(tenantId, tenant -> { + SysUser user = sysUserService.getById(userId); + if (user == null || user.getDeptId() == null) { + return false; + } + R walletResult = remoteWalletService.getWallet(userId, WalletTypeEnum.EAT); + if (walletResult == null || !walletResult.isOk() || walletResult.getData() == null) { + return false; + } + return !ObjectUtil.equals(walletResult.getData().getDeptId(), user.getDeptId()); + })); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml index 6f13c9201..1890a5838 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml @@ -10,6 +10,11 @@ a.process_id, a.process_status, a.face_img, + a.quality_score, + a.quality_status, + a.quality_checked_at, + a.quality_error_code, + a.quality_device_id, a.create_by, a.create_time, a.update_by, @@ -33,6 +38,23 @@ and a.process_status = #{pa.processStatus} + + + + + and a.quality_status = 'detected' and a.quality_score >= 0.60 + + + and a.quality_status = 'detected' and a.quality_score < 0.60 + + + and a.quality_status = 'failed' + + + and a.quality_status is null + + + and a.process_id is not null @@ -65,6 +87,12 @@ select count(0) from sys_user_face + + + + + + + + + + + \ No newline at end of file diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml index 9364a3abe..bb0f6046d 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml @@ -9,10 +9,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: diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java index c8bb8dddb..43fcc7713 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java @@ -34,4 +34,9 @@ public class WalletActiveDto extends WalletActiveEntity { * 是否硬件激活 */ private Boolean isMchActive; + + /** + * 卡号长度 + */ + private Integer carNumberLength; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardCountDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardCountDto.java new file mode 100644 index 000000000..e20dd85b0 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardCountDto.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class WalletRemoteOpenCardCountDto { + + /** + * 商户id + */ + @NotNull(message = "商户id不能为空") + private Long mchId; + + /** + * 卡号长度 + */ + private Integer carNumberLength; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardListDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardListDto.java new file mode 100644 index 000000000..437e798e8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletRemoteOpenCardListDto.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import lombok.Data; + +@Data +public class WalletRemoteOpenCardListDto { + + /** + * 当前页 + */ + private Long current; + + /** + * 分页 + */ + private Long pageSize; + + /** + * 父部门id + */ + private Long parentDeptId; + + /** + * 商户id + */ + private Long mchId; + + /** + * 卡号长度 + */ + private Integer carNumberLength; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java index 5aa53a2f1..fda76018d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java @@ -41,6 +41,11 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas private List bizTypes; + /** + * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。 + */ + private List tagList; + private LocalDate[] createTimeList; /** diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java new file mode 100644 index 000000000..74ef1f7a4 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 钱包流水备注修改参数,只允许后台修改备注,不承载金额、余额和业务关联等不可变流水信息。 + */ +@Data +@Schema(description = "钱包流水备注修改参数") +public class WalletTransactionRemarkDto { + + /** + * 需要修改备注的钱包流水编号,由钱包流水列表返回。 + */ + @NotNull(message = "钱包流水编号不能为空") + @Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + /** + * 流水业务备注;允许传空以清除原备注,非空内容最多 256 个字符。 + */ + @Size(max = 256, message = "流水备注不能超过256个字符") + @Schema(description = "流水备注,最多256个字符") + private String remark; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java new file mode 100644 index 000000000..66ca88a0d --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java @@ -0,0 +1,37 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 钱包流水信息修改参数,仅允许后台修正流水触发事件的归属部门和创建时间, + * 不承载金额、余额、业务关联等不可变流水信息。 + */ +@Data +@Schema(description = "钱包流水信息修改参数") +public class WalletTransactionUpdateInfoDto { + + /** + * 需要修改信息的钱包流水编号,由钱包流水列表返回。 + */ + @NotNull(message = "钱包流水编号不能为空") + @Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + /** + * 流水触发事件归属的新部门编号;必须是当前租户有效部门,用于更正部门归属统计口径。 + */ + @NotNull(message = "流水归属部门不能为空") + @Schema(description = "流水归属部门编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long deptId; + + /** + * 流水新的创建时间,用于更正流水归属的统计周期;时间格式为 yyyy-MM-dd HH:mm:ss。 + */ + @NotNull(message = "流水创建时间不能为空") + @Schema(description = "流水创建时间,格式为 yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java index a18bcfc38..bda1e3637 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; import com.bsd.pjcloud.common.core.util.TenantTable; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; @@ -78,6 +79,19 @@ public class WalletTransactionEntity extends Model { @Schema(description = "余额, 单位分") private Integer balance; + /** + * 财务统计标签,保存 {@code wallet_transaction_tag} 字典值;多个值使用英文逗号分隔,历史流水可为空。 + */ + @Schema(description = "财务统计标签,多个字典值使用英文逗号分隔") + private String tags; + + /** + * 流水备注,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,数据库最多保存 256 个字符。 + */ + @Size(max = 256, message = "流水备注不能超过256个字符") + @Schema(description = "流水备注,最多256个字符") + private String remark; + /** * 乐观锁 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java index 9db4b75b2..75fe9ef26 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java @@ -22,6 +22,10 @@ public interface RemoteCodeUseService { @GetMapping("/wallet-code-use/get-by-biz-id") R getByBizId(@RequestParam Long id); + @NoToken + @GetMapping("/wallet-code-use/info-by-short-code") + R getInfoByShortCode(@RequestParam String shortCode); + /** * 核销二维码 diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java index eae2620a9..74996c4eb 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java @@ -1,14 +1,20 @@ package com.bsd.pjcloud.wallet.api.feign; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.feign.annotation.NoToken; import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardListDto; +import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @@ -24,6 +30,10 @@ public interface RemoteWalletService { @GetMapping("/wallet-manage/get-wallet") R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode); + @NoToken + @GetMapping("/wallet-manage/get-wallets-by-user-ids") + R> getWalletsByUserIds(@RequestParam List userIds, @RequestParam WalletTypeEnum walletTypeCode); + @NoToken @GetMapping("/wallet-manage/get-wallet-by-wallet-id") R getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId); @@ -64,4 +74,12 @@ public interface RemoteWalletService { @GetMapping("/wallet-manage/inner/is-use-door") R innserIsUseDoor(@RequestParam Long userId); + + @NoToken + @PostMapping("/wallet-active/remote/open-card-list") + R> remoteOpenCardList(@RequestBody WalletRemoteOpenCardListDto body); + + @NoToken + @PostMapping("/wallet-active/remote/open-card-count") + R remoteOpenCardCount(@RequestBody WalletRemoteOpenCardCountDto dto); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java index 0de68261b..5d8b8a596 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.wallet.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; @@ -9,10 +10,13 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.core.validation.Group; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.wallet.api.dto.WalletActiveBindCardDto; import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardListDto; import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.bsd.pjcloud.wallet.service.WalletActiveService; @@ -46,6 +50,33 @@ public class WalletActiveController { private final WalletActiveService walletActiveService; + /* ************************************* start 远程调用 start ************************************* */ + + @Inner + @PostMapping("/remote/open-card-list") + @Operation(summary = "远程调用开卡并且绑定用户的列表") + public R> remoteOpenCardList(@RequestBody WalletRemoteOpenCardListDto body) { + if (ObjectUtil.isEmpty(body.getCurrent()) || ObjectUtil.isEmpty(body.getPageSize())) { + return null; + } + Page page = new Page(); + page.setCurrent(body.getCurrent()); + page.setSize(body.getPageSize()); + WalletActiveDto dto = new WalletActiveDto(); + dto.setMerchantId(body.getMchId()); + dto.setCarNumberLength(body.getCarNumberLength()); + return R.ok(walletActiveService.openCardList(page, dto)); + } + + @Inner + @PostMapping("/remote/open-card-count") + @Operation(summary = "远程调用开卡并且绑定用户的总数") + public R remoteOpenCardCount(@RequestBody WalletRemoteOpenCardCountDto dto) { + return R.ok(walletActiveService.openCardCount(dto)); + } + + /* ************************************* end 远程调用 end ************************************* */ + /* ************************************* start android start ************************************* */ @GetMapping("/ad/open-card-list") @Operation(summary = "android开卡列表") diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java index d61004093..0c896e019 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java @@ -87,6 +87,15 @@ public class WalletCodeUseController { ); return R.ok(detail); } + + /** + * 根据短码查询 + */ + @Inner + @GetMapping("/info-by-short-code") + public R getInfoByShortCode(@RequestParam String shortCode) { + return R.ok(walletCodeUseService.getInfoByShortCode(shortCode)); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index 49d7012b8..729c98627 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -143,6 +143,19 @@ public class WalletManageController { return R.ok(wallet); } + /** + * 按用户 id 列表批量查询同一钱包类型的钱包。 + * + *

供硬件服务聚合离线人员快照使用;未开通该类型钱包的用户不出现在结果中, + * 空入参返回空列表而不是失败。

+ */ + @Inner + @GetMapping("/get-wallets-by-user-ids") + public R> getWalletsByUserIds(@RequestParam List userIds, + @RequestParam WalletTypeEnum walletTypeCode) { + return R.ok(walletManageService.getWalletsByUserIds(userIds, walletTypeCode)); + } + @Inner @GetMapping("/del-wallet") public R delWallet(@RequestParam("userIds") List userIds) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index a59c229ae..76a48e651 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -13,7 +13,9 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; @@ -87,10 +89,38 @@ public class WalletTransactionController { @PutMapping("/update-balance") @Operation(summary = "更新会员用户余额") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_balance')") - public R updateWalletBalance(@RequestBody WalletTransactionDto dto) { + public R updateWalletBalance(@Valid @RequestBody WalletTransactionDto dto) { return R.ok(walletTransactionService.updateWalletBalance(dto)); } + /** + * 修改单笔钱包流水的业务备注,不开放金额、余额、业务类型等流水核心字段的变更。 + * + * @param dto 流水编号和新备注,备注为空时清除原值 + * @return 更新到当前租户有效流水时返回 {@code true} + */ + @PutMapping("/update-remark") + @Operation(summary = "修改钱包流水备注") + @SysLog("修改钱包流水备注") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')") + public R updateRemark(@Valid @RequestBody WalletTransactionRemarkDto dto) { + return R.ok(walletTransactionService.updateRemark(dto)); + } + + /** + * 修改单笔钱包流水的归属部门和创建时间,用于更正流水归属统计口径,不开放金额、余额等核心字段变更。 + * + * @param dto 流水编号、新部门编号和新创建时间 + * @return 更新到当前租户有效流水时返回 {@code true} + */ + @PutMapping("/update-info") + @Operation(summary = "修改钱包流水信息") + @SysLog("修改钱包流水信息") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_info')") + public R updateInfo(@Valid @RequestBody WalletTransactionUpdateInfoDto dto) { + return R.ok(walletTransactionService.updateInfo(dto)); + } + @Operation(summary = "后台充值分页", description = "后台充值分页") @PostMapping("/page-cz") public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java index c964a0d16..3fa632419 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java @@ -8,10 +8,12 @@ import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto; import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.github.yulichang.query.MPJLambdaQueryWrapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; @Mapper public interface WalletActiveMapper extends PjcloudBaseMapper { @@ -56,4 +58,14 @@ public interface WalletActiveMapper extends PjcloudBaseMapper openCardList(Page page, @Param("dto") WalletActiveDto dto); + + /** + * 远程调用开卡并且绑定用户的总数 + */ + Integer openCardCount(@Param("dto") WalletRemoteOpenCardCountDto dto); } \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java index 8f83c7791..6e8338a12 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.wallet.api.dto.WalletActiveBindCardDto; import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto; import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; @@ -67,4 +68,14 @@ public interface WalletActiveService extends IService { Page pageJoin(Page page, WalletActiveDto dto); Page scopePageList(Page page, WalletActiveDto dto); + + /** + * 远程调用开卡并且绑定用户的列表 + */ + Page openCardList(Page page, WalletActiveDto dto); + + /** + * 远程调用开卡并且绑定用户的总数 + */ + Integer openCardCount(WalletRemoteOpenCardCountDto dto); } \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java index f0dec9a0d..7c8913f5d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java @@ -36,4 +36,9 @@ public interface WalletCodeUseService extends IService { * 根据短码查询加密信息 */ String getByShortCode(String shortCode); + + /** + * 根据短码查询二维码记录 + */ + WalletCodeUseEntity getInfoByShortCode(String shortCode); } \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 45f93f985..b02346ac5 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -37,6 +37,14 @@ public interface WalletManageService extends IService { */ WalletManageEntity getWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode); + /** + * 按用户 id 列表批量获取同一钱包类型的全部钱包(没有缓存)。 + * + *

供离线人员快照等批量聚合场景使用;入参为空或钱包类型不存在时返回空列表, + * 未开通该类型钱包的用户不出现在结果中。

+ */ + List getWalletsByUserIds(List<@NotNull Long> userIds, @NotNull WalletTypeEnum walletTypeCode); + /** * 获取钱包(没有缓存) */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index 66f7f9174..a14bf0715 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo; @@ -46,11 +48,26 @@ public interface WalletTransactionService extends IService openCardList(Page page, WalletActiveDto dto) { + return baseMapper.openCardList(page, dto); + } + + /** + * 远程调用开卡并且绑定用户的总数 + */ + @Override + public Integer openCardCount(WalletRemoteOpenCardCountDto dto) { + return baseMapper.openCardCount(dto); + } + private WalletActiveServiceImpl getSelf() { return SpringUtil.getBean(getClass()); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java index 68ad9f0f1..018294f88 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java @@ -264,6 +264,19 @@ public class WalletCodeUseServiceImpl extends ServiceImpl() + .eq(WalletCodeUseEntity::getShortCode, shortCode) + .orderByDesc(WalletCodeUseEntity::getCreateTime) + .orderByDesc(WalletCodeUseEntity::getId) + .last("limit 1") + ); + } + // 生成短码 public String generateShortCode(String codeContent) { while (true) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index c8062d4ef..4c58322c0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -71,6 +71,11 @@ public class WalletManageServiceImpl extends ServiceImpl getWalletsByUserIds(List userIds, WalletTypeEnum walletTypeCode) { + if (ObjectUtil.isEmpty(userIds)) { + return List.of(); + } + WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode); + if (ObjectUtil.isEmpty(walletType)) { + return List.of(); + } + // 单条 in 查询按批拆分,避免部门人数较多时超出数据库参数上限。 + List wallets = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += WALLETS_BY_USER_IDS_BATCH) { + int end = Math.min(start + WALLETS_BY_USER_IDS_BATCH, userIds.size()); + wallets.addAll(baseMapper.selectList(new LambdaQueryWrapper() + .in(WalletManageEntity::getUserId, userIds.subList(start, end)) + .eq(WalletManageEntity::getWalletTypeId, walletType.getId()) + )); + } + return wallets; + } + @Override public WalletManageEntity getWallet(String cardNumber) { return baseMapper.selectOne(new LambdaQueryWrapper() @@ -481,8 +507,11 @@ public class WalletManageServiceImpl extends ServiceImpllambdaUpdate() + .eq(WalletTransactionEntity::getId, dto.getId()) + .set(WalletTransactionEntity::getRemark, remark)); + // SET 中 version 必然变化,0 行只可能是并发修改导致的版本冲突。 + if (updated == 0) { + throw new RuntimeException("钱包流水已被其他操作修改,请刷新后重试"); + } + return true; + } + + /** + * 校验目标部门有效后,使用独立更新条件仅重写归属部门和创建时间,不触碰流水其他字段。 + * + * @param dto 流水编号、新部门编号和新创建时间 + * @return 更新到当前租户有效流水时返回 {@code true} + */ + @Override + public Boolean updateInfo(WalletTransactionUpdateInfoDto dto) { + // 部门归属直接决定部门统计口径,先经 UPMS 校验部门真实存在,避免把无效部门编号写入流水。 + R deptResult = deptService.getDeptByDeptId(dto.getDeptId()); + if (deptResult == null || !deptResult.isOk() || ObjectUtil.isEmpty(deptResult.getData())) { + throw new RuntimeException("流水归属部门不存在"); + } + // 乐观锁依赖库中当前 version 做 CAS,new 出来的默认 0 会让条件永远匹配不到已递增过的行。 + WalletTransactionEntity exist = baseMapper.selectById(dto.getId()); + if (exist == null) { + throw new RuntimeException("钱包流水不存在或已删除"); + } + // 保留更新实体承载审计字段填充;version 用库中当前值,乐观锁拦截器据此追加条件并自增。 + WalletTransactionEntity updateEntity = new WalletTransactionEntity(); + updateEntity.setVersion(exist.getVersion()); + int updated = baseMapper.update(updateEntity, Wrappers.lambdaUpdate() + .eq(WalletTransactionEntity::getId, dto.getId()) + .set(WalletTransactionEntity::getDeptId, dto.getDeptId()) + .set(WalletTransactionEntity::getCreateTime, dto.getCreateTime())); + // SET 中 version 必然变化,0 行只可能是并发修改导致的版本冲突。 + if (updated == 0) { + throw new RuntimeException("钱包流水已被其他操作修改,请刷新后重试"); + } + return true; + } + private WalletTransactionServiceImpl getSelf() { return SpringUtil.getBean(getClass()); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletActiveMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletActiveMapper.xml index 8f6ff1f0d..ef6b8c50e 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletActiveMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletActiveMapper.xml @@ -23,4 +23,60 @@ + + + + \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml index dcdb07e54..e306d77ba 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml @@ -22,6 +22,8 @@ a.title, a.price, a.balance, + a.tags, + a.remark, a.version, a.create_by, a.create_time, @@ -100,6 +102,13 @@ #{v} + + and ( + + FIND_IN_SET(#{tag}, a.tags) > 0 + + ) +