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..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/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/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-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java index 581ae65da..5450d3480 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java @@ -169,6 +169,8 @@ public interface CacheConstants { String HARDER_ORDER_NON_PAY = "hard:order:non_pay"; + String HARD_XFJ_WHITE_LIST_LOCK = "hard:xfj:whitelist:lock"; + /* ************************************* end 订单相关 end ************************************* */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java new file mode 100644 index 000000000..065e764e4 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.constant; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 打印任务失败阶段,帮助管理端区分配置问题、服务端故障和业务终端代理打印故障。 + */ +@Getter +@AllArgsConstructor +public enum HardPrintFailureStageEnum { + + /** 来源业务终端缺失、未绑定打印机或绑定关系失效。 */ + BINDING("BINDING", "设备绑定"), + + /** 服务端读取业务数据、绘制 PNG 或上传文件失败。 */ + IMAGE_GENERATION("IMAGE_GENERATION", "图片生成"), + + /** 服务端向绑定的来源业务终端发布 MQTT 消息失败。 */ + MQTT_DISPATCH("MQTT_DISPATCH", "MQTT下发"), + + /** 来源业务终端下载图片、连接 USB 或写入 ESC/POS 数据失败。 */ + TERMINAL_PRINT("TERMINAL_PRINT", "终端打印"); + + /** 持久化阶段编码。 */ + private final String stage; + + /** 阶段中文说明。 */ + private final String description; + + /** + * 按持久化编码解析失败阶段。 + * + * @param stage 阶段编码 + * @return 匹配阶段;编码为空或未知时返回 {@code null} + */ + public static HardPrintFailureStageEnum valueOfByStage(String stage) { + if (ObjectUtil.isEmpty(stage)) return null; + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(stage, value.stage)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java index 9a0eb75d9..4de0d8815 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java @@ -4,23 +4,46 @@ import cn.hutool.core.util.ObjectUtil; import lombok.AllArgsConstructor; import lombok.Getter; -import javax.xml.stream.events.StartDocument; import java.util.Arrays; /** - * 打印状态 + * 打印任务状态。 + * + *

编码 {@code 1} 和 {@code 2} 分别兼容历史“已生成”和“已打印”记录。

*/ @AllArgsConstructor @Getter public enum HardPrintStatusEnum { - GENERATE("1", "已生成"), + /** 任务已落库,等待后端生成并上传打印图片。 */ + PENDING_GENERATION("0", "待生成"), + + /** 图片已生成并允许下发,历史枚举名保留为 GENERATE 以保持二进制兼容。 */ + GENERATE("1", "待打印"), + + /** 来源业务终端已确认完整 USB 数据和切纸命令写入成功。 */ PRINT("2", "已打印"), + + /** 绑定、图片生成、MQTT 下发或业务终端代理打印阶段失败,可重试原任务。 */ + FAILED("3", "打印失败"), + + /** 核销回退等业务动作取消了尚未打印的任务。 */ + CANCELLED("4", "已取消"), ; + + /** 持久化状态编码。 */ private final String type; + + /** 状态中文说明。 */ private final String description; + /** + * 按持久化编码解析打印状态。 + * + * @param type 状态编码 + * @return 匹配状态;编码为空或未知时返回 {@code null} + */ public static HardPrintStatusEnum valueOfByType(String type) { if (ObjectUtil.isEmpty(type)) return null; - return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); } } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java new file mode 100644 index 000000000..bea9223dc --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.constant; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 打印凭证类型,用于选择后端图片模板和管理端业务动作。 + */ +@Getter +@AllArgsConstructor +public enum HardPrintTypeEnum { + + /** 支付订单的发票或消费小票。 */ + INVOICE("1", "发票"), + + /** 预约订单核销成功后生成的纸质兑换凭证。 */ + REDEMPTION_VOUCHER("2", "兑换券"); + + /** 持久化类型编码。 */ + private final String type; + + /** 类型中文说明。 */ + private final String description; + + /** + * 按持久化编码解析打印类型。 + * + * @param type 类型编码 + * @return 匹配类型;编码为空或未知时返回 {@code null} + */ + public static HardPrintTypeEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) return null; + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(type, value.type)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java index 73afb35bf..35109ccfc 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java @@ -23,8 +23,11 @@ public enum HardTypeEnum { OTHER("6", "其他支付方式(第三方接口对接)"), MERCHANT_ACT("7", "商户活动"), FARMER("8", "共富商城(助农)"), + YUN_XFJ("9", "云版消费机"), YYX("10", "4G-云音响"), YYX_WIFI("11", "wifi-云音响"), + /** 由绑定的超市或食堂 Android 终端代理驱动的无屏尔印 USB 打印机。 */ + EY_PRINT("12", "尔印打印机器"), ; private final String type; private final String description; @@ -44,4 +47,15 @@ public enum HardTypeEnum { return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); } + /** + * 判断指定设备类型是否为尔印逻辑打印机资产。 + * + * @param hardTypeEnum 待判断的设备类型 + * @return 类型为 {@link #EY_PRINT} 时返回 {@code true} + */ + public static boolean isPrintHard(HardTypeEnum hardTypeEnum) { + if (ObjectUtil.isEmpty(hardTypeEnum)) return false; + return List.of(EY_PRINT).contains(hardTypeEnum); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/TerminalHeartbeatSceneEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/TerminalHeartbeatSceneEnum.java new file mode 100644 index 000000000..02ae89603 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/TerminalHeartbeatSceneEnum.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.hard.api.constant; + +/** + * 终端心跳场景。 + * + *

枚举名称是终端与服务端之间的固定接口编码,不得随展示文案调整。

+ */ +public enum TerminalHeartbeatSceneEnum { + + /** + * 普通在线心跳;收到后清除之前的计划更新断开原因。 + */ + ONLINE, + + /** + * 自动更新安装前心跳;用于在应用重启期间保留可解释的离线原因。 + */ + AUTO_UPDATE +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java index 2fe85d8ef..eb206c679 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java @@ -15,6 +15,7 @@ public enum HardHourTypeEnum { MORNING("1", "早餐"), NOONING("2", "午餐"), NIGHT("3", "晚餐"), + LATE_NIGHT("4", "夜间餐"), ; private final String type; private final String description; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java new file mode 100644 index 000000000..64e0846f0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据图片角色,编码用于唯一位置约束和跨端展示。 + */ +@Getter +public enum HardOrderEvidenceRoleEnum { + + /** + * 人脸库中被命中的后台参考人脸,由服务端从受信文件中心复制为订单快照。 + */ + FACE_REFERENCE("FACE_REFERENCE", "后台参考人脸"), + + /** + * 终端实际通过比对的现场人脸裁剪图,由终端异步上传。 + */ + FACE_LIVE("FACE_LIVE", "现场识别人脸"), + + /** + * 菜谱识别的现场餐盘图片预留角色,当前版本不创建该类证据。 + */ + MENU_LIVE("MENU_LIVE", "餐盘现场图"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据图片角色。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceRoleEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析图片角色,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应角色;未知时返回 {@code null} + */ + public static HardOrderEvidenceRoleEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java new file mode 100644 index 000000000..f74b4c130 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 服务端订单证据状态,表达证据是否仍待补传、已经可查看或已永久缺失。 + */ +@Getter +public enum HardOrderEvidenceStatusEnum { + + /** + * 订单已记录识别元数据,但图片仍等待终端上传或服务端复制。 + */ + PENDING("PENDING", "待补传"), + + /** + * 文件中心已经保存订单专属图片并可在订单详情中查看。 + */ + AVAILABLE("AVAILABLE", "可查看"), + + /** + * 本地文件过期、损坏或参考图片变化,已确认无法继续补齐。 + */ + UNAVAILABLE("UNAVAILABLE", "永久缺失"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据状态。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceStatusEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据状态,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应状态;未知时返回 {@code null} + */ + public static HardOrderEvidenceStatusEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java new file mode 100644 index 000000000..53101e286 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据业务类型,编码同时用于数据库、终端 SQLite 和跨端接口,禁止随展示文案调整。 + */ +@Getter +public enum HardOrderEvidenceTypeEnum { + + /** + * 人脸识别证据,当前版本保存后台参考人脸和现场识别人脸。 + */ + FACE_RECOGNITION("FACE_RECOGNITION", "人脸识别"), + + /** + * 菜谱识别证据预留值,当前版本不执行菜谱识别或图片采集。 + */ + MENU_RECOGNITION("MENU_RECOGNITION", "菜谱识别"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据业务类型。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceTypeEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据类型,未知编码返回 {@code null} 供调用方拒绝非法请求。 + * + * @param code 数据库或接口编码 + * @return 对应类型;未知时返回 {@code null} + */ + public static HardOrderEvidenceTypeEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderStatusEnum.java index 742a76838..a4c84a9c5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderStatusEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderStatusEnum.java @@ -20,6 +20,11 @@ public enum HardOrderStatusEnum { REFUND("10", "退款"), CANCEL("20", "已核销"), UNFOLLOW("30", "已取消(智能柜)"), + TAKE_OUT_AWAIT("100", "待接单(外卖)"), + TAKE_OUT_ING("110", "配送中(外卖)"), + SHOP_WAIT_PAY_SUCCESS("200", "待发货/已支付(商城购买)"), + SHOP_WAIT_RECEIVE("210", "待收货(商城购买)"), + SHOP_WAIT_EVALUATE("220", "订单完成/待评价(商城购买)"), ; private final String type; private final String description; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java index 4f1e1fd25..fd2380239 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java @@ -21,6 +21,8 @@ public enum HardOrderTypeEnum { LB("6", "宁波银行(美好生活)"), FARMER("7", "共富商城(助农)"), TOC_VISITOR("8", "toc访客购买(不记名用户)"), + XFJ("9", "消费机支付"), + TAKE_OUT("10", "外卖"), ; private final String type; private final String description; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/xfj/HardXfjTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/xfj/HardXfjTypeEnum.java new file mode 100644 index 000000000..f2ece356a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/xfj/HardXfjTypeEnum.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.constant.xfj; + +import cn.hutool.core.util.ObjectUtil; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 硬件授权类型 + */ +@AllArgsConstructor +@Getter +public enum HardXfjTypeEnum { + + QRCODE_PAY("10", "钱包二维码支付"), + WECHAT_QRCODE_PAY("11", "微信二维码支付"), + ALI_QRCODE_PAY("12", "支付宝二维码支付"), + CARD_PAY("20", "饭卡支付"), + ; + private final String type; + private final String description; + + public static HardXfjTypeEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) return null; + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); + } + + public static HardPayTypeEnum getHardPayType(HardXfjTypeEnum type) { + return switch (type) { + case QRCODE_PAY -> HardPayTypeEnum.WALLET_QR_CODE; + case WECHAT_QRCODE_PAY -> HardPayTypeEnum.WECHANT_SELF_QR_CODE_PAY; + case ALI_QRCODE_PAY -> HardPayTypeEnum.ALI_SELF_QR_CODE_PAY; + case CARD_PAY -> HardPayTypeEnum.WALLET_CARD; + }; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java new file mode 100644 index 000000000..556b94251 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理端创建发票或兑换券打印任务的请求。 + */ +@Data +@Schema(description = "创建打印任务请求") +public class HardPrintRequestDto { + + /** 待打印凭证所属订单 ID。 */ + @NotNull(message = "订单ID不能为空") + @Schema(description = "订单ID") + private Long orderId; + + /** 打印类型编码,1 发票、2 兑换券。 */ + @NotBlank(message = "打印类型不能为空") + @Schema(description = "打印类型") + private String printType; + + /** 来源业务终端 ID;订单没有来源终端且同商户存在多个绑定时必填。 */ + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** 已打印任务再次创建下一序号时必填的重打原因。 */ + @Schema(description = "重打原因") + private String reprintReason; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java new file mode 100644 index 000000000..d40276456 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 食堂或超市终端代理无屏 EY_PRINT 提交的打印结果回执。 + */ +@Data +@Schema(description = "业务终端代理打印结果回执") +public class HardPrintResultDto { + + /** hard_invoice 打印任务主键。 */ + @NotNull(message = "打印任务ID不能为空") + @Schema(description = "打印任务ID") + private Long taskId; + + /** 任务所属订单 ID,用于阻断任务与订单错配。 */ + @NotNull(message = "订单ID不能为空") + @Schema(description = "订单ID") + private Long orderId; + + /** 来源业务终端实际处理的重试版本。 */ + @NotNull(message = "重试版本不能为空") + @Min(value = 0, message = "重试版本不能小于0") + @Schema(description = "重试版本") + private Integer retryVersion; + + /** 完整 USB 数据及切纸命令是否已经被接口接受。 */ + @NotNull(message = "打印结果不能为空") + @Schema(description = "是否成功") + private Boolean success; + + /** 本版本在来源业务终端执行的 USB 尝试次数。 */ + @NotNull(message = "尝试次数不能为空") + @Min(value = 1, message = "尝试次数不能小于1") + @Schema(description = "终端尝试次数") + private Integer attemptCount; + + /** 失败时的可诊断错误摘要,不得包含凭证或密码。 */ + @Schema(description = "错误摘要") + private String errorMessage; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java new file mode 100644 index 000000000..ca940e5c4 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 对既有打印任务执行失败重试或已打印重打的请求。 + */ +@Data +@Schema(description = "打印任务重试或重打请求") +public class HardPrintRetryDto { + + /** 既有 hard_invoice 打印任务主键。 */ + @NotNull(message = "打印任务ID不能为空") + @Schema(description = "打印任务ID") + private Long historyId; + + /** + * 失败任务需要补录的来源业务终端 ID。 + * + *

仅当原任务因缺少来源终端而失败时使用;服务端仍会校验该终端与订单同商户, + * 并根据服务端绑定关系确定目标 EY_PRINT,客户端不能直接指定打印机。

+ */ + @Schema(description = "补录的来源业务终端ID") + private Long sourceHardId; + + /** 已打印任务重打时必须填写的原因;失败任务原任务重试时可为空。 */ + @Schema(description = "重打原因") + private String reprintReason; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java new file mode 100644 index 000000000..cd1a4cf8e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 创建业务终端与无屏尔印 USB 打印机绑定关系的请求。 + */ +@Data +@Schema(description = "USB 打印机绑定请求") +public class HardPrinterBindingDto { + + /** SUPERMARKET 或 CANTEEN 来源终端 ID。 */ + @NotNull(message = "来源终端不能为空") + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** EY_PRINT 逻辑打印机资产 ID。 */ + @NotNull(message = "打印机不能为空") + @Schema(description = "逻辑打印机资产ID") + private Long printerHardId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java new file mode 100644 index 000000000..1e4bff1b3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 保存无屏尔印 USB 打印机参数的请求。 + */ +@Data +@Schema(description = "USB 打印机配置请求") +public class HardPrinterConfigDto { + + /** EY_PRINT 逻辑打印机资产 ID。 */ + @NotNull(message = "打印机不能为空") + @Schema(description = "逻辑打印机资产ID") + private Long printerHardId; + + /** 真机型号;未联调前允许为空。 */ + @Schema(description = "打印机型号") + private String printerModel; + + /** USB Vendor ID 十进制值;未联调前允许为空。 */ + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID 十进制值;未联调前允许为空。 */ + @Schema(description = "USB PID") + private Integer usbProductId; + + /** 纸张宽度,首期只允许 80。 */ + @NotNull(message = "纸宽不能为空") + @Schema(description = "纸宽,单位mm") + private Integer paperWidth; + + /** 自动切刀开关,1 开启、0 关闭。 */ + @NotBlank(message = "自动切刀开关不能为空") + @Schema(description = "自动切刀开关") + private String autoCut; + + /** 图片指令模式,只允许 GS_V0 或 ESC_STAR。 */ + @NotBlank(message = "图片指令模式不能为空") + @Schema(description = "图片指令模式") + private String imageMode; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java new file mode 100644 index 000000000..c7c5312cf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java @@ -0,0 +1,37 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 食堂或超市终端代理上报实际 USB 打印机信息的请求。 + */ +@Data +@Schema(description = "业务终端代理上报的 USB 打印机信息") +public class HardPrinterInfoReportDto { + + /** USB 描述符中的厂商和产品名称组合,最长保存 128 个字符。 */ + @NotBlank(message = "打印机型号不能为空") + @Size(max = 128, message = "打印机型号不能超过128个字符") + @Schema(description = "实际打印机型号") + private String printerModel; + + /** USB Vendor ID 十进制值,取值范围为无符号 16 位整数。 */ + @NotNull(message = "USB VID不能为空") + @Min(value = 0, message = "USB VID必须在0到65535之间") + @Max(value = 65535, message = "USB VID必须在0到65535之间") + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID 十进制值,取值范围为无符号 16 位整数。 */ + @NotNull(message = "USB PID不能为空") + @Min(value = 0, message = "USB PID必须在0到65535之间") + @Max(value = 65535, message = "USB PID必须在0到65535之间") + @Schema(description = "USB PID") + private Integer usbProductId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/TerminalHeartbeatDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/TerminalHeartbeatDto.java new file mode 100644 index 000000000..a5b7d93b6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/TerminalHeartbeatDto.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import com.bsd.pjcloud.hard.api.constant.TerminalHeartbeatSceneEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.util.Map; + +/** + * 食堂、超市 Android 终端心跳请求。 + */ +@Data +@Schema(description = "终端心跳请求") +public class TerminalHeartbeatDto { + + /** + * APK 展示版本号,来源于终端当前安装包;取不到时允许为空。 + */ + @Schema(description = "APK展示版本号") + private String apkVersionName; + + /** + * APK 内部版本码,来源于终端当前安装包;取不到时允许为空。 + */ + @Schema(description = "APK内部版本码") + private Long apkVersionCode; + + /** + * 本次上报场景,使用 {@link TerminalHeartbeatSceneEnum} 的固定接口名称。 + */ + @NotNull(message = "心跳场景不能为空") + @Schema(description = "心跳场景", requiredMode = Schema.RequiredMode.REQUIRED) + private TerminalHeartbeatSceneEnum scene; + + /** + * Android 系统本次启动后的运行秒数,用于区分整机重启与应用进程重启。 + */ + @Schema(description = "Android系统运行秒数") + private Long systemUptimeSeconds; + + /** + * 当前应用进程运行秒数,用于识别应用崩溃或被系统重新拉起。 + */ + @Schema(description = "应用进程运行秒数") + private Long appUptimeSeconds; + + /** + * 最近上报时使用的网络类型,例如 ethernet、wifi、4g 或 offline。 + */ + @Schema(description = "网络类型") + private String networkType; + + /** + * 应用私有目录所在存储卷的可用空间,单位为字节。 + */ + @Schema(description = "可用存储空间,单位字节") + private Long availableStorageBytes; + + /** + * Root 最近检测状态,持久化值固定为 unknown、available 或 unavailable。 + */ + @Pattern(regexp = "unknown|available|unavailable", message = "Root状态不正确") + @Schema(description = "Root状态") + private String rootStatus; + + /** + * 当前应用是否已被 Android 配置为 Device Owner;无法读取时为空。 + */ + @Schema(description = "是否为Device Owner") + private Boolean deviceOwner; + + /** + * 最近上报时是否处于 Kiosk 锁定任务模式;无法读取时为空。 + */ + @Schema(description = "是否处于Kiosk模式") + private Boolean kioskActive; + + /** + * 可选外围硬件状态,以稳定功能编码映射布尔值,例如 printer、steelyard、cardReader、subScreen。 + */ + @Schema(description = "可选外围硬件状态") + private Map deviceStatus; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogFileQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogFileQueryDto.java new file mode 100644 index 000000000..fd347365c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogFileQueryDto.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.hard.api.dto.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 终端日志管理端查询条件。 + */ +@Data +@Schema(description = "终端日志文件查询条件") +public class HardTerminalLogFileQueryDto { + + @Schema(description = "设备ID") + private Long hardId; + + @Schema(description = "设备名称") + private String hardName; + + @Schema(description = "设备唯一编号") + private String uniqueCode; + + @Schema(description = "商户ID") + private Long merchantId; + + @Schema(description = "部门ID") + private Long deptId; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "日志日期开始") + private LocalDate logDateStart; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "日志日期结束") + private LocalDate logDateEnd; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "上传时间开始") + private LocalDateTime uploadTimeStart; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "上传时间结束") + private LocalDateTime uploadTimeEnd; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogPreflightDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogPreflightDto.java new file mode 100644 index 000000000..a2c6ecaf8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogPreflightDto.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.dto.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 终端日志上传预检参数。 + */ +@Data +@Schema(description = "终端日志上传预检参数") +public class HardTerminalLogPreflightDto { + + @NotBlank(message = "日志相对路径不能为空") + @Size(max = 255, message = "日志相对路径不能超过255个字符") + @Schema(description = "终端日志根目录下的相对路径") + private String relativePath; + + @NotNull(message = "文件大小不能为空") + @Min(value = 0, message = "文件大小不能小于0") + @Schema(description = "脱敏文件大小") + private Long fileSize; + + @NotBlank(message = "文件哈希不能为空") + @Pattern(regexp = "(?i)^[0-9a-f]{32}$", message = "文件哈希必须为32位MD5") + @Schema(description = "脱敏文件MD5") + private String fileHash; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogUploadDto.java new file mode 100644 index 000000000..8d8868cf1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/manager/HardTerminalLogUploadDto.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.hard.api.dto.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 终端日志上传元数据。设备快照由服务端根据请求头中的设备ID填充。 + */ +@Data +@Schema(description = "终端日志上传元数据") +public class HardTerminalLogUploadDto { + + @NotBlank(message = "日志相对路径不能为空") + @Size(max = 255, message = "日志相对路径不能超过255个字符") + @Schema(description = "终端日志根目录下的相对路径") + private String relativePath; + + @NotNull(message = "日志日期不能为空") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "日志所属日期") + private LocalDate logDate; + + @NotNull(message = "本地修改时间不能为空") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "终端本地文件最后修改时间") + private LocalDateTime fileModifiedTime; + + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "应用版本") + private String appVersion; + + @NotBlank(message = "脱敏规则版本不能为空") + @Size(max = 32, message = "脱敏规则版本不能超过32个字符") + @Schema(description = "脱敏规则版本") + private String sanitizeVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java new file mode 100644 index 000000000..585bbd7bd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java @@ -0,0 +1,130 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单创建和证据补传共用的识别元数据,不包含图片字节和服务端文件信息。 + */ +@Data +@Schema(description = "订单识别证据元数据") +public class HardOrderEvidenceMetadataDto { + + /** + * 证据业务类型,使用 HardOrderEvidenceTypeEnum 的稳定编码。 + */ + @NotBlank(message = "证据类型不能为空") + @Size(max = 32, message = "证据类型不能超过32个字符") + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,使用 HardOrderEvidenceRoleEnum 的稳定编码。 + */ + @NotBlank(message = "证据图片角色不能为空") + @Size(max = 32, message = "证据图片角色不能超过32个字符") + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同类型同角色下的图片序号,从0开始;人脸证据固定为0,菜谱识别可递增。 + */ + @NotNull(message = "证据图片序号不能为空") + @Min(value = 0, message = "证据图片序号不能小于0") + @Max(value = 99, message = "证据图片序号不能大于99") + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键;未识别到平台用户时为空,不能由终端使用其他用户替代。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软搜索样本编号;仅人脸识别角色使用,服务端据此校验后台参考人脸。 + */ + @Size(max = 64, message = "人脸样本编号不能超过64个字符") + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端人脸库记录的参考图片地址,仅作为服务端复制参考图的线索,不直接作为可访问地址返回。 + */ + @Size(max = 512, message = "参考人脸地址不能超过512个字符") + @Schema(description = "参考人脸地址") + private String referenceImageUrl; + + /** + * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。 + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "参考人脸来源版本") + private LocalDateTime referenceImageVersion; + + /** + * 识别时实际相似度,取值0到1;为空表示当前证据类型不使用相似度。 + */ + @DecimalMin(value = "0", message = "相似度不能小于0") + @DecimalMax(value = "1", message = "相似度不能大于1") + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 识别时终端生效的相似度阈值快照,取值0到1。 + */ + @DecimalMin(value = "0", message = "相似度阈值不能小于0") + @DecimalMax(value = "1", message = "相似度阈值不能大于1") + @Schema(description = "相似度阈值快照") + private BigDecimal similarityThreshold; + + /** + * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。 + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 终端按原始证据文件计算的 SHA-256;图片尚未生成或永久缺失时允许为空。 + */ + @Pattern(regexp = "(?i)^[0-9a-f]{64}$", message = "图片哈希必须为64位SHA-256") + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 终端证据文件大小,单位字节;参考图无法读取或文件尚未生成时允许为空。 + */ + @Min(value = 0, message = "图片大小不能小于0") + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 终端解码后的图片宽度,单位像素。 + */ + @Min(value = 1, message = "图片宽度必须大于0") + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 终端解码后的图片高度,单位像素。 + */ + @Min(value = 1, message = "图片高度必须大于0") + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java new file mode 100644 index 000000000..510a7e4e8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端订单识别证据的订单定位参数。 + * + *

消费机现场下单继续使用终端订单号对账;预约核销订单不是当前终端创建的,核销成功后改用 + * 服务端订单 ID 定位。两种方式都必须结合请求头中的设备 ID 校验订单归属。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据的订单定位参数") +public abstract class HardOrderEvidenceOrderRefDto extends HardOrderEvidenceMetadataDto { + + /** + * 终端本地稳定订单号,用于消费机现场下单后对账;预约核销证据允许为空。 + */ + @Size(max = 128, message = "终端订单号不能超过128个字符") + @Schema(description = "终端订单号,与服务端订单ID二选一") + private String androidOrderCode; + + /** + * 已存在的服务端订单主键,仅用于当前设备已经成功核销的预约订单;现场下单证据允许为空。 + */ + @Positive(message = "服务端订单ID必须大于0") + @Schema(description = "服务端订单ID,与终端订单号二选一") + private Long orderId; + + /** + * 校验至少提供一种订单定位信息,防止无条件查询或写入订单证据。 + * + * @return 提供终端订单号或服务端订单 ID 时返回 {@code true} + */ + @AssertTrue(message = "终端订单号和服务端订单ID不能同时为空") + public boolean isOrderReferencePresent() { + return StrUtil.isNotBlank(androidOrderCode) || orderId != null; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java new file mode 100644 index 000000000..343b09788 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端提交订单证据元数据或永久缺失结果的请求。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单证据元数据提交请求") +public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceOrderRefDto { + + /** + * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。 + */ + @NotBlank(message = "证据状态不能为空") + @Size(max = 32, message = "证据状态不能超过32个字符") + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失原因;状态为 UNAVAILABLE 时必填,禁止包含本地完整敏感路径。 + */ + @Size(max = 500, message = "失败原因不能超过500个字符") + @Schema(description = "永久缺失原因") + private String failureReason; + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java new file mode 100644 index 000000000..4b8f33633 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端上传订单现场证据图片时随 multipart 一并提交的元数据。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单现场证据上传请求") +public class HardOrderEvidenceUploadDto extends HardOrderEvidenceOrderRefDto { + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/xfj/HardXfjFastCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/xfj/HardXfjFastCreateOrderDto.java new file mode 100644 index 000000000..60df1bf4d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/xfj/HardXfjFastCreateOrderDto.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.hard.api.dto.order.xfj; + +import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import lombok.Data; + +@Data +public class HardXfjFastCreateOrderDto { + + /** + * 用户id + */ + private Long userId; + + /** + * 设备id + */ + private Long hardId; + + /** + * 订单编号 + */ + private String orderNo; + + /** + * 支付金额(分) + */ + private int price; + + /** + * 钱包类型 + */ + private WalletTypeEnum walletType; + + /** + * 消费机支付类型 + */ + private HardXfjTypeEnum xfjPayType; + + /** + * 终端支付类型 + */ + private AppPlusPlatformTypeEnum platformType; + + /** + * 付款码支付code + */ + private String devicePayCode; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/HardXfjLogDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/HardXfjLogDto.java new file mode 100644 index 000000000..484765e44 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/HardXfjLogDto.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class HardXfjLogDto extends HardXfjLogEntity { + + /** + * 设备名称 + */ + private String hardName; + + private LocalDateTime[] createTimeList; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjConsumTransactionsDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjConsumTransactionsDto.java new file mode 100644 index 000000000..0e4edb731 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjConsumTransactionsDto.java @@ -0,0 +1,72 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +import java.util.List; + +@Data +public class XfjConsumTransactionsDto { + + /** + * 消费序号(年月日时分秒+2个字节序号(0~65535)),用于识别上传数据不重复 + */ + private String Order; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 卡模式 + * 0:实体卡 + * 1:电子卡 + */ + private Integer CardMode; + + /** + * 交易模式 + * 0: 刷卡扣费 + * 1: 现金充值 + * 2: 余额查询 + * 3: 钱包转账 + * 4: 现金退款 + */ + private Integer Mode; + + /** + * 扣费类型 + * 0:手动 + * 1:菜单 + * 2:定值 + * 3:取餐 + * 4:计时 + * 5:计次 + */ + private Integer PayType; + + /** + * 消费金额(Mode为2时,此字段传入值可能为0) + */ + private String Amount; + + /** + * 菜品消费列表 + */ + List Menus; + + @Data + static public class Menus { + + /** + * 菜品编号(与菜品查询接口菜品编号一致) + */ + private String MenuID; + + /** + * 菜品数量(支持小数) + */ + private String Count; + + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjMenusDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjMenusDto.java new file mode 100644 index 000000000..f266612b8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjMenusDto.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjMenusDto { + + /** + * 菜品编号(操作员在消费机键盘输入的数字) + */ + private String MenuNo; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOffLinesDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOffLinesDto.java new file mode 100644 index 000000000..daa9bc76b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOffLinesDto.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjOffLinesDto { + + /** + * 终端设备ID + */ + private Integer DeviceNumber; + + /** + * 消费序号(年月日时分秒+2个字节序号),用于识别上传数据不重复 + */ + private String Order; + + /** + * 扣费类型 + * 0:手动模式 + * 1:菜单模式 + * 2:定值模式 + */ + private Integer PayType; + + /** + * 卡模式 + * 0:实体卡 + * 1:电子卡 + */ + private Integer CardMode; + + /** + * 消费时间年月日时分秒 + */ + private String Time; + + /** + * 卡号10进制(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 扣费金额 + */ + private String Money; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderQueryDto.java new file mode 100644 index 000000000..324ef273a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderQueryDto.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjOrderQueryDto { + + /** + * 消费序号(年月日时分秒+2个字节序号(0~65535)),用于识别上传数据不重复 + */ + private String Order; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 卡模式 + * 0:实体卡 + * 1:电子卡 + */ + private Integer CardMode; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderRefundDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderRefundDto.java new file mode 100644 index 000000000..6098006ef --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjOrderRefundDto.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjOrderRefundDto { + + /** + * 消费订单号(最大30字符) + */ + private String OrderSeralid; + + /** + * 退款金额 + */ + private String Amount; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjQRCodeTransactionDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjQRCodeTransactionDto.java new file mode 100644 index 000000000..0ccc8b8d7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjQRCodeTransactionDto.java @@ -0,0 +1,55 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +import java.util.List; + +@Data +public class XfjQRCodeTransactionDto { + + /** + * 交易模式 + * 0:扣费 + * 1:充值 + * 3:信息查询 + */ + private String Mode; + + /** + * 扣费类型 + * 0:手动 + * 1:菜单 + * 2:定值 + * 3:取餐 + * 4:计时 + */ + private Integer PayType; + + /** + * 消费金额(Mode为3时,此字段传入值可能为0) + */ + private String Amount; + + /** + * 扫描到的二维码字符串(最大长度为200) + */ + private String QR; + + private List Menus; + + @Data + public static class MenuItem { + + /** + * 菜品编号(与菜品查询接口菜品编号一致) + */ + private String MenuID; + + /** + * 菜品数量(支持小数) + */ + private String Count; + + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjServerTimeDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjServerTimeDto.java new file mode 100644 index 000000000..8bea6a2df --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjServerTimeDto.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjServerTimeDto { + + /** + * 设备中的总白名单数 + */ + private Integer WLSum; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionDto.java new file mode 100644 index 000000000..6f55c0055 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionDto.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjTransactionDto { + + /** + * 从最后一笔消费往前算的位置 + * 1:就是最后一笔 + * 2:就是倒数第二笔 + */ + private Integer Number; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionInquiryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionInquiryDto.java new file mode 100644 index 000000000..849822c11 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjTransactionInquiryDto.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjTransactionInquiryDto { + + /** + * 二维码交易订单号(该值来自于第六及第七接口返回的QROrder) + */ + private String QROrder; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjWhiteListDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjWhiteListDto.java new file mode 100644 index 000000000..364fc66c8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/xfj/XfjWhiteListDto.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.dto.xfj; + +import lombok.Data; + +@Data +public class XfjWhiteListDto { + + /** + * 通讯序号 + */ + private Integer CommID; + + /** + * 页数 + */ + private Long Page; + + /** + * 页个数(一页最多有多少个名单) + */ + private Long PageNumber; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java index b7e4b2c9e..a42013aa0 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java @@ -8,7 +8,7 @@ import com.bsd.pjcloud.common.core.util.TenantTable; import java.time.LocalDateTime; /** - * 发票记录 + * 打印任务与凭证记录 * * @author dzc * @date 2025-01-05 14:17:32 @@ -20,7 +20,7 @@ import java.time.LocalDateTime; @TenantTable @TableName("hard_invoice") @EqualsAndHashCode(callSuper = true) -@Schema(description = "发票记录") +@Schema(description = "打印任务与凭证记录") public class HardInvoiceEntity extends Model { @@ -37,55 +37,109 @@ public class HardInvoiceEntity extends Model { @Schema(description="部门id") private Long deptId; - /** - * 设备id - */ - @Schema(description="设备id") + /** + * 来源业务终端 ID;历史记录继续保留原设备语义,不改写为打印机 ID。 + */ + @Schema(description="来源业务终端ID") private Long hardId; + /** + * 任务路由关联的尔印逻辑打印机资产 ID;历史记录和绑定失败任务允许为空。 + */ + @Schema(description="逻辑打印机资产ID") + private Long printerHardId; + /** * 硬件订单id */ @Schema(description="硬件订单id") private Long orderId; - /** - * 状态 - */ - @Schema(description="状态") + /** + * 打印任务状态,编码来自 HardPrintStatusEnum。 + */ + @Schema(description="打印任务状态") private String status; - /** - * 发票图片 - */ - @Schema(description="发票图片") + /** + * 打印凭证类型,1 发票、2 兑换券;历史空值按发票兼容。 + */ + @Schema(description="打印凭证类型") + private String printType; + + /** + * 同一订单同一凭证类型的打印序号;重试同一任务时保持不变。 + */ + @Schema(description="打印序号") + private Integer printNo; + + /** + * 人工重试版本;来源业务终端用任务 ID 和该版本共同判断是否允许再次打印。 + */ + @Schema(description="重试版本") + private Integer retryVersion; + + /** + * 来源业务终端代理上报的 USB 累计尝试次数。 + */ + @Schema(description="终端尝试次数") + private Integer attemptCount; + + /** + * 最近失败阶段,编码来自 HardPrintFailureStageEnum。 + */ + @Schema(description="失败阶段") + private String failureStage; + + /** + * 最近失败摘要,不保存 MQTT 密码、访问令牌等敏感信息。 + */ + @Schema(description="最近失败原因") + private String lastError; + + /** + * 来源业务终端确认完整 USB 数据和切纸命令写入成功的时间。 + */ + @Schema(description="打印成功时间") + private LocalDateTime printedAt; + + /** + * 人工重打原因,自动首次打印或失败重试允许为空。 + */ + @Schema(description="重打原因") + private String reprintReason; + + /** + * 后端生成并上传的打印 PNG 地址;待生成和生成失败时允许为空。 + */ + @Schema(description="打印图片") private String pic; /** * 创建人 */ - @TableField(fill = FieldFill.INSERT) + @TableField(fill = FieldFill.INSERT) @Schema(description="创建人") private String createBy; /** * 创建时间 */ - @TableField(fill = FieldFill.INSERT) + @TableField(fill = FieldFill.INSERT) @Schema(description="创建时间") private LocalDateTime createTime; /** * 修改人 */ - @TableField(fill = FieldFill.INSERT_UPDATE) + @TableField(fill = FieldFill.INSERT_UPDATE) @Schema(description="修改人") private String updateBy; /** * 修改时间 */ - @TableField(fill = FieldFill.INSERT_UPDATE) + @TableField(fill = FieldFill.INSERT_UPDATE) @Schema(description="修改时间") private LocalDateTime updateTime; @@ -93,7 +147,7 @@ public class HardInvoiceEntity extends Model { * 删除标记 */ @TableLogic - @TableField(fill = FieldFill.INSERT) + @TableField(fill = FieldFill.INSERT) @Schema(description="删除标记") private String delFlag; @@ -102,4 +156,4 @@ public class HardInvoiceEntity extends Model { */ @Schema(description="租户ID") private Long tenantId; -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java new file mode 100644 index 000000000..51743f215 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java @@ -0,0 +1,78 @@ +package com.bsd.pjcloud.hard.api.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 业务 Android 终端与无屏尔印 USB 打印机资产的一对一绑定关系。 + * + *

记录使用物理删除,数据库分别对来源和目标设备设置唯一键,确保任何时刻都不能出现多重绑定。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_printer_binding") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "业务终端与 USB 打印机绑定关系") +public class HardPrinterBindingEntity extends Model { + + /** 绑定关系主键。 */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "绑定关系主键") + private Long id; + + /** 来源业务终端 ID,只允许 SUPERMARKET 或 CANTEEN。 */ + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** 逻辑打印机资产 ID,只允许 EY_PRINT;设备本身不登录、不上报心跳。 */ + @Schema(description = "逻辑打印机资产ID") + private Long printerHardId; + + /** 双方共同所属商户 ID 快照。 */ + @Schema(description = "商户ID") + private Long merchantId; + + /** 双方共同所属商户部门 ID 快照。 */ + @Schema(description = "商户部门ID") + private Long deptId; + + /** 创建人。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** 创建时间。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** 修改人。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** 修改时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** 租户 ID。 */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java new file mode 100644 index 000000000..404df7f7f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java @@ -0,0 +1,88 @@ +package com.bsd.pjcloud.hard.api.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 无屏尔印 USB 打印机资产的 USB 和 ESC/POS 参数配置。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_printer_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "尔印 USB 打印机配置") +public class HardPrinterConfigEntity extends Model { + + /** 配置主键。 */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "配置主键") + private Long id; + + /** 配置所属 EY_PRINT 逻辑打印机资产 ID。 */ + @Schema(description = "逻辑打印机资产ID") + private Long printerHardId; + + /** 真机联调后填写的打印机型号。 */ + @Schema(description = "打印机型号") + private String printerModel; + + /** USB Vendor ID,使用十进制持久化。 */ + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID,使用十进制持久化。 */ + @Schema(description = "USB PID") + private Integer usbProductId; + + /** 纸张宽度,首期固定为 80mm。 */ + @Schema(description = "纸宽,单位mm") + private Integer paperWidth; + + /** 是否在完整数据写入后发送自动切纸命令,1 开启、0 关闭。 */ + @Schema(description = "自动切刀开关") + private String autoCut; + + /** 图片指令模式,GS_V0 为默认值,ESC_STAR 为兼容回退。 */ + @Schema(description = "图片指令模式") + private String imageMode; + + /** 创建人。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** 创建时间。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** 修改人。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** 修改时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** 租户 ID。 */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalLogFileEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalLogFileEntity.java new file mode 100644 index 000000000..859371fcf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalLogFileEntity.java @@ -0,0 +1,191 @@ +package com.bsd.pjcloud.hard.api.entity.manager; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import com.bsd.pjcloud.common.core.util.TenantTable; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 终端日志文件 + */ +@Data +@TenantTable +@TableName("hard_terminal_log_file") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "终端日志文件") +public class HardTerminalLogFileEntity extends Model { + + /** + * 编号 + */ + @TableId(type = IdType.AUTO) + @Schema(description = "编号") + private Long id; + + /** + * 设备ID(hard_manager.id) + */ + @Schema(description = "设备ID(hard_manager.id)") + private Long hardId; + + /** + * 上传时设备名称快照 + */ + @Schema(description = "上传时设备名称快照") + private String hardName; + + /** + * 上传时设备唯一编号快照 + */ + @Schema(description = "上传时设备唯一编号快照") + private String uniqueCode; + + /** + * 上传时设备类型快照 + */ + @Schema(description = "上传时设备类型快照") + private String hardType; + + /** + * 上传时商户ID快照 + */ + @Schema(description = "上传时商户ID快照") + private Long merchantId; + + /** + * 上传时部门ID快照 + */ + @Schema(description = "上传时部门ID快照") + private Long deptId; + + /** + * 日志所属日期 + */ + @Schema(description = "日志所属日期") + private LocalDate logDate; + + /** + * 终端日志根目录下的相对路径 + */ + @Schema(description = "终端日志根目录下的相对路径") + private String relativePath; + + /** + * 原日志文件名 + */ + @Schema(description = "原日志文件名") + private String originalName; + + /** + * 文件大小(字节) + */ + @Schema(description = "文件大小(字节)") + private Long fileSize; + + /** + * 文件内容哈希(当前使用MD5) + */ + @Schema(description = "文件内容哈希(当前使用MD5)") + private String fileHash; + + /** + * 终端本地文件最后修改时间 + */ + @Schema(description = "终端本地文件最后修改时间") + private LocalDateTime fileModifiedTime; + + /** + * 文件中心sys_file记录ID + */ + @Schema(description = "文件中心sys_file记录ID") + private Long sysFileId; + + /** + * OSS对象文件名 + */ + @Schema(description = "OSS对象文件名") + private String storageFileName; + + /** + * 文件访问相对地址 + */ + @Schema(description = "文件访问相对地址") + private String storageUrl; + + /** + * 上传时应用版本 + */ + @Schema(description = "上传时应用版本") + private String appVersion; + + /** + * 日志脱敏规则版本 + */ + @Schema(description = "日志脱敏规则版本") + private String sanitizeVersion; + + /** + * 最近成功上传时间 + */ + @Schema(description = "最近成功上传时间") + private LocalDateTime uploadTime; + + /** + * 成功上传次数 + */ + @Schema(description = "成功上传次数") + private Integer uploadCount; + + /** + * 备注 + */ + @Schema(description = "备注") + private String remark; + + /** + * 创建人 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 修改时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 删除标记 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户ID + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalStatusEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalStatusEntity.java new file mode 100644 index 000000000..daf7620c3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/manager/HardTerminalStatusEntity.java @@ -0,0 +1,146 @@ +package com.bsd.pjcloud.hard.api.entity.manager; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 食堂、超市 Android 终端的最新心跳状态。 + * + *

每台设备只保留一条最新记录,在线布尔值由服务端按时间阈值实时计算。

+ */ +@Data +@TenantTable +@TableName("hard_terminal_status") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "终端最新心跳状态") +public class HardTerminalStatusEntity extends Model { + + /** + * 设备 ID,同时作为主键,对应 {@code hard_manager.id}。 + */ + @TableId(type = IdType.INPUT) + @Schema(description = "设备ID") + private Long hardId; + + /** + * 服务端最近收到心跳的时间,在线计算以此时间为准。 + */ + @Schema(description = "最后在线时间") + private LocalDateTime lastOnlineTime; + + /** + * 最近一次心跳场景,保存 {@code ONLINE} 或 {@code AUTO_UPDATE} 接口编码。 + */ + @Schema(description = "最近心跳场景") + private String heartbeatScene; + + /** + * 计划内断开原因;普通在线心跳会清空,当前仅保留“自动更新重启”。 + */ + @Schema(description = "计划内断开原因") + private String disconnectReason; + + /** + * 终端上报的 APK 展示版本号,无法读取时为空。 + */ + @Schema(description = "APK展示版本号") + private String apkVersionName; + + /** + * 终端上报的 APK 内部版本码,无法读取时为空。 + */ + @Schema(description = "APK内部版本码") + private Long apkVersionCode; + + /** + * Android 系统本次启动后的运行时长,单位为秒;终端无法读取时为空。 + */ + @Schema(description = "Android系统运行秒数") + private Long systemUptimeSeconds; + + /** + * 当前应用进程运行时长,单位为秒;终端无法读取时为空。 + */ + @Schema(description = "应用进程运行秒数") + private Long appUptimeSeconds; + + /** + * 最近一次心跳的网络类型,例如 ethernet、wifi、4g 或 offline。 + */ + @Schema(description = "网络类型") + private String networkType; + + /** + * 应用私有目录所在存储卷的可用空间,单位为字节。 + */ + @Schema(description = "可用存储空间,单位字节") + private Long availableStorageBytes; + + /** + * Root 最近检测状态,固定为 unknown、available 或 unavailable。 + */ + @Schema(description = "Root状态") + private String rootStatus; + + /** + * 最近一次心跳时当前应用是否为 Device Owner;无法读取时为空。 + */ + @Schema(description = "是否为Device Owner") + private Boolean deviceOwner; + + /** + * 最近一次心跳时是否处于 Kiosk 锁定任务模式;无法读取时为空。 + */ + @Schema(description = "是否处于Kiosk模式") + private Boolean kioskActive; + + /** + * 打印机、电子秤、读卡器和副屏等可选硬件状态 JSON 快照。 + */ + @Schema(description = "可选外围硬件状态JSON") + private String deviceStatusJson; + + /** + * 创建人;设备心跳没有后台用户上下文时为空。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 首次收到该设备心跳的服务端时间。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人;设备心跳没有后台用户上下文时为空。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最近一次心跳写入的服务端时间。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 数据所属租户 ID,取自已通过当前租户校验的设备记录。 + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index aed8e7a88..0309233f8 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -183,10 +183,16 @@ public class HardOrderEntity extends Model { @Schema(description = "退款状态") private String refundStatus; + /** + * 订单实际核销完成时间,后续订单更新不得覆盖 + */ + @Schema(description = "订单实际核销完成时间") + private LocalDateTime verificationTime; + /** * 订单取消时间(退款订单) */ - @Schema(description = "订单取消时间") + @Schema(description = "订单取消时间(退款订单)") private LocalDateTime applyCancelTime; /** diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java new file mode 100644 index 000000000..e7e6c59be --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java @@ -0,0 +1,208 @@ +package com.bsd.pjcloud.hard.api.entity.order; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单识别证据图片,从属于 hard_order 并长期保存争议处理所需的文件快照和识别元数据。 + */ +@Data +@TenantTable +@TableName("hard_order_evidence_image") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片") +public class HardOrderEvidenceImageEntity extends Model { + + /** + * 证据记录自增主键。 + */ + @TableId(type = IdType.AUTO) + @Schema(description = "证据记录主键") + private Long id; + + /** + * 服务端订单主键,来源于 hard_order.id。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 证据订单引用快照;现场订单取终端订单号,预约等服务端订单取订单号或服务端主键内部引用。 + */ + @Schema(description = "证据订单引用快照") + private String androidOrderCode; + + /** + * 证据业务类型,保存 HardOrderEvidenceTypeEnum.code。 + */ + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,保存 HardOrderEvidenceRoleEnum.code。 + */ + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同一订单、类型和角色下的图片序号,从0开始。 + */ + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键快照;无法识别到平台用户时允许为空。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软人脸样本编号快照,来源于 sys_user_face.auto_code。 + */ + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端命中的后台参考图片地址快照,仅用于与 UPMS 权威记录比对,不直接作为展示地址。 + */ + @Schema(description = "参考人脸来源地址快照") + private String referenceImageUrl; + + /** + * 终端识别相似度快照,取值0到1。 + */ + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 终端识别相似度阈值快照,取值0到1。 + */ + @Schema(description = "识别相似度阈值") + private BigDecimal similarityThreshold; + + /** + * 产生证据的服务端设备主键,来源于 hard_order.hard_id。 + */ + @Schema(description = "设备主键") + private Long hardId; + + /** + * 终端实际完成识别的本地时间,用于与订单时间交叉核验。 + */ + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 文件中心 sys_file 主键;状态非 AVAILABLE 时允许为空。 + */ + @Schema(description = "文件中心记录主键") + private Long sysFileId; + + /** + * 文件中心对象名,仅供服务端读取或下载,不由终端提交。 + */ + @Schema(description = "文件中心对象名") + private String storageFileName; + + /** + * PC 可使用统一请求层访问的文件相对地址。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 实际保存文件的 SHA-256,用于幂等和防止证据被静默替换。 + */ + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 实际保存文件大小,单位字节。 + */ + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 实际解码图片宽度,单位像素。 + */ + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 实际解码图片高度,单位像素。 + */ + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; + + /** + * 证据状态,保存 HardOrderEvidenceStatusEnum.code。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失或服务端复制失败的摘要,不保存终端完整文件路径。 + */ + @Schema(description = "失败原因") + private String failureReason; + + /** + * 产生证据的终端应用版本快照。 + */ + @Schema(description = "终端应用版本") + private String appVersion; + + /** + * 创建人,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 证据占位记录创建时间,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人,由 MyBatis 填充器在状态变化时写入。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最近一次状态、文件或错误信息更新时间。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 逻辑删除标记;订单退款、取消和删除不会联动改变该值。 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户主键,由当前请求租户上下文写入并由数据层自动隔离。 + */ + @Schema(description = "租户主键") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/xfj/HardXfjLogEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/xfj/HardXfjLogEntity.java new file mode 100755 index 000000000..0b135b336 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/xfj/HardXfjLogEntity.java @@ -0,0 +1,130 @@ +package com.bsd.pjcloud.hard.api.entity.xfj; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import com.bsd.pjcloud.common.core.util.TenantTable; + +import java.time.LocalDateTime; + +/** + * 消费机日志 + */ +@Data +@TenantTable +@TableName("hard_xfj_log") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "消费机日志") +public class HardXfjLogEntity extends Model { + + /** + * 编号 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "编号") + private Long id; + + /** + * 设备唯一编号 + */ + @Schema(description = "设备唯一编号") + private String uniqueCode; + + /** + * 设备类型 + */ + @Schema(description = "设备类型") + private String hardType; + + /** + * 用户id + */ + @Schema(description = "用户id") + private String title; + + /** + * headers + */ + @Schema(description = "headers") + private String headers; + + /** + * res + */ + @Schema(description = "res") + private String res; + + /** + * req + */ + @Schema(description = "req") + private String req; + + /** + * error + */ + @Schema(description = "error") + private String error; + + /** + * 开始时间 + */ + @Schema(description = "开始时间") + private LocalDateTime startTime; + + /** + * 结束时间 + */ + @Schema(description = "结束时间") + private LocalDateTime endTime; + + /** + * 耗时(毫秒) + */ + @Schema(description = "耗时(毫秒)") + private Long cost; + + /** + * 创建人 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 修改时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 删除标记 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户ID + */ + @Schema(description = "租户ID") + private Long tenantId; +} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java new file mode 100644 index 000000000..8d149f3de --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java @@ -0,0 +1,565 @@ +package com.bsd.pjcloud.hard.api.utils; + +import javax.imageio.ImageIO; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。 + * + *

商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。

+ */ +public class ThermalReceiptImage { + + /** 80mm 打印头常见有效像素宽度。 */ + public static final int PAPER_WIDTH_PIXELS = 576; + + /** 图片左右安全留白。 */ + private static final int HORIZONTAL_PADDING = 24; + + /** 图片顶部和底部安全留白。 */ + private static final int VERTICAL_PADDING = 24; + + /** 每次扩展画布的最小高度。 */ + private static final int HEIGHT_GROWTH = 512; + + /** 数量列保留宽度。 */ + private static final int QUANTITY_COLUMN_WIDTH = 96; + + /** 套餐组成项左侧缩进。 */ + private static final int CHILD_INDENT = 32; + + /** 兑换券商品卡片的内容内边距。 */ + private static final int PRODUCT_CARD_PADDING = 14; + + /** 相邻兑换券商品卡片的垂直间距。 */ + private static final int PRODUCT_CARD_GAP = 14; + + /** 中文优先逻辑字体名称。 */ + private static final String FONT_FAMILY = "SansSerif"; + + /** 当前可扩展画布。 */ + private BufferedImage canvas; + + /** 当前绘制上下文。 */ + private Graphics2D graphics; + + /** 下一块内容的顶部 Y 坐标。 */ + private int cursorY = VERTICAL_PADDING; + + /** + * 创建 576px 宽的白底收据画布。 + */ + public ThermalReceiptImage() { + canvas = new BufferedImage(PAPER_WIDTH_PIXELS, HEIGHT_GROWTH, BufferedImage.TYPE_INT_RGB); + graphics = canvas.createGraphics(); + configureGraphics(graphics); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); + graphics.setColor(Color.BLACK); + } + + /** + * 绘制居中粗体标题。 + * + * @param text 标题文本 + * @return 当前绘制器 + */ + public ThermalReceiptImage title(String text) { + setFont(Font.BOLD, 42); + drawCentered(text, 16); + return this; + } + + /** + * 绘制带黑色边框的大号打印次数提示,文字超过可用纸宽时自动换行。 + * + * @param text 提示文本,例如“第 1 次打印” + * @return 当前绘制器 + */ + public ThermalReceiptImage banner(String text) { + setFont(Font.BOLD, 44); + FontMetrics metrics = graphics.getFontMetrics(); + List lines = wrap(text == null ? "" : text, contentWidth() - 32, metrics); + int lineHeight = metrics.getHeight() + 4; + int height = lines.size() * lineHeight + 26; + ensureCapacity(height + 20); + graphics.setStroke(new BasicStroke(3F)); + graphics.drawRect(HORIZONTAL_PADDING, cursorY, contentWidth(), height); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(line)) / 2); + graphics.drawString(line, x, cursorY + 13 + index * lineHeight + metrics.getAscent()); + } + cursorY += height + 20; + return this; + } + + /** + * 绘制醒目的核销短码。 + * + * @param shortCode 核销短码 + * @return 当前绘制器 + */ + public ThermalReceiptImage verificationCode(String shortCode) { + setFont(Font.BOLD, 64); + drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, 18); + return this; + } + + /** + * 绘制一行左右键值信息,值无法完整同行显示时整段从下一行开始并继续换行。 + * + * @param label 左侧字段名 + * @param value 右侧字段值 + * @return 当前绘制器 + */ + public ThermalReceiptImage keyValue(String label, String value) { + setFont(Font.PLAIN, 33); + String safeLabel = label == null ? "" : label; + String safeValue = value == null || value.isBlank() ? "--" : value; + FontMetrics metrics = graphics.getFontMetrics(); + int labelWidth = metrics.stringWidth(safeLabel + ":"); + int inlineValueWidth = contentWidth() - labelWidth - 12; + boolean stackValue = metrics.stringWidth(safeValue) > inlineValueWidth; + int valueWidth = stackValue ? contentWidth() : Math.max(120, inlineValueWidth); + List lines = wrap(safeValue, valueWidth, metrics); + int valueLineOffset = stackValue ? 1 : 0; + int lineHeight = metrics.getHeight() + 6; + ensureCapacity((lines.size() + valueLineOffset) * lineHeight + 10); + graphics.drawString(safeLabel + ":", HORIZONTAL_PADDING, cursorY + metrics.getAscent()); + for (int index = 0; index < lines.size(); index++) { + int x = stackValue ? HORIZONTAL_PADDING : HORIZONTAL_PADDING + labelWidth + 12; + graphics.drawString(lines.get(index), x, + cursorY + (index + valueLineOffset) * lineHeight + metrics.getAscent()); + } + cursorY += (lines.size() + valueLineOffset) * lineHeight + 6; + return this; + } + + /** + * 绘制商品明细区标题。 + * + * @param text 标题文本 + * @return 当前绘制器 + */ + public ThermalReceiptImage sectionTitle(String text) { + setFont(Font.BOLD, 36); + ensureCapacity(60); + graphics.drawString(text == null ? "" : text, HORIZONTAL_PADDING, cursorY + graphics.getFontMetrics().getAscent()); + cursorY += graphics.getFontMetrics().getHeight() + 10; + return this; + } + + /** + * 绘制多订单合并兑换券中的紧凑订单摘要。 + * + *

订单序号使用黑底标签形成扫描锚点,完整订单号及日期餐段均按实际像素宽度换行;相邻订单仅以 + * 细线分隔,避免重复商品版式中的大段标题和留白。

+ * + * @param index 当前订单在合并批次中的一号起始序号 + * @param total 合并批次订单总数 + * @param orderNo 对外展示的完整订单号 + * @param orderDate 预约订餐日期 + * @param mealName 餐段名称 + * @return 当前绘制器 + */ + public ThermalReceiptImage voucherOrderSummary(int index, int total, String orderNo, + String orderDate, String mealName) { + int safeTotal = Math.max(1, total); + int safeIndex = Math.min(Math.max(1, index), safeTotal); + String tag = "订单 " + safeIndex + "/" + safeTotal; + String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo; + String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate) + + " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName); + + setFont(Font.BOLD, 25); + FontMetrics tagMetrics = graphics.getFontMetrics(); + int tagWidth = tagMetrics.stringWidth(tag) + 20; + int tagHeight = tagMetrics.getHeight() + 6; + + setFont(Font.BOLD, 31); + FontMetrics orderMetrics = graphics.getFontMetrics(); + int orderX = HORIZONTAL_PADDING + tagWidth + 14; + int orderWidth = PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - orderX; + List orderLines = wrap(safeOrderNo, Math.max(120, orderWidth), orderMetrics); + int orderLineHeight = orderMetrics.getHeight() + 4; + int headerHeight = Math.max(tagHeight, orderLines.size() * orderLineHeight); + + setFont(Font.PLAIN, 29); + FontMetrics summaryMetrics = graphics.getFontMetrics(); + List summaryLines = wrap(summary, contentWidth(), summaryMetrics); + int summaryLineHeight = summaryMetrics.getHeight() + 4; + int separatorHeight = safeIndex < safeTotal ? 18 : 8; + int blockHeight = headerHeight + 10 + summaryLines.size() * summaryLineHeight + separatorHeight; + ensureCapacity(blockHeight); + + int blockTop = cursorY; + graphics.setColor(Color.BLACK); + setFont(Font.BOLD, 25); + graphics.fillRect(HORIZONTAL_PADDING, blockTop, tagWidth, tagHeight); + graphics.setColor(Color.WHITE); + graphics.drawString(tag, HORIZONTAL_PADDING + 10, blockTop + 3 + tagMetrics.getAscent()); + graphics.setColor(Color.BLACK); + + setFont(Font.BOLD, 31); + for (int lineIndex = 0; lineIndex < orderLines.size(); lineIndex++) { + graphics.drawString(orderLines.get(lineIndex), orderX, + blockTop + lineIndex * orderLineHeight + orderMetrics.getAscent()); + } + + int summaryTop = blockTop + headerHeight + 10; + setFont(Font.PLAIN, 29); + for (int lineIndex = 0; lineIndex < summaryLines.size(); lineIndex++) { + graphics.drawString(summaryLines.get(lineIndex), HORIZONTAL_PADDING, + summaryTop + lineIndex * summaryLineHeight + summaryMetrics.getAscent()); + } + if (safeIndex < safeTotal) { + int separatorY = summaryTop + summaryLines.size() * summaryLineHeight + 7; + graphics.setStroke(new BasicStroke(1F)); + graphics.drawLine(HORIZONTAL_PADDING, separatorY, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, separatorY); + } + cursorY += blockHeight; + return this; + } + + /** + * 绘制商品或套餐组成项,商品名换行且数量始终在首行右对齐。 + * + * @param name 商品名称 + * @param quantity 数量文本 + * @param child 是否为套餐组成项 + * @param emphasized 是否使用粗体标记套餐主项 + * @return 当前绘制器 + */ + public ThermalReceiptImage item(String name, String quantity, boolean child, boolean emphasized) { + return item(name, quantity, child, emphasized, QUANTITY_COLUMN_WIDTH); + } + + /** + * 绘制带数量和金额摘要的发票商品行。 + * + * @param name 商品名称 + * @param quantity 数量或重量文本 + * @param amount 含货币符号的商品总价 + * @return 当前绘制器 + */ + public ThermalReceiptImage invoiceItem(String name, String quantity, String amount) { + return item(name, String.format("%s %s", quantity, amount), false, false, 190); + } + + /** + * 绘制兑换券商品卡片,使用文字标签和边框粗细同时区分组合商品与单品。 + * + *

商品名称和组合内容均按真实像素宽度完整换行;价格区固定展示单价、数量和小计。 + * 组合内容只解释套餐构成,不参与当前卡片金额计算。

+ * + * @param name 商品快照名称;空值显示“未命名商品” + * @param quantity 部分退款后的剩余可履约数量 + * @param unitPrice 商品快照单价,单位元且不含货币符号 + * @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号 + * @param combo 是否为组合商品 + * @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空 + * @return 当前绘制器 + */ + public ThermalReceiptImage voucherProduct(String name, int quantity, String unitPrice, String subtotal, + boolean combo, List componentItems) { + String typeLabel = combo ? "组合商品" : "单品"; + String safeName = name == null || name.isBlank() ? "未命名商品" : name; + String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice; + String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal; + List safeComponents = componentItems == null ? List.of() : componentItems.stream() + .filter(item -> item != null && !item.isBlank()).toList(); + int innerWidth = contentWidth() - PRODUCT_CARD_PADDING * 2; + + setFont(Font.BOLD, 28); + FontMetrics tagMetrics = graphics.getFontMetrics(); + int tagHeight = tagMetrics.getHeight() + 6; + int tagWidth = tagMetrics.stringWidth(typeLabel) + 20; + + setFont(Font.BOLD, 40); + FontMetrics nameMetrics = graphics.getFontMetrics(); + List nameLines = wrap(safeName, innerWidth, nameMetrics); + int nameLineHeight = nameMetrics.getHeight() + 4; + + setFont(Font.PLAIN, 29); + FontMetrics priceMetrics = graphics.getFontMetrics(); + String priceAndQuantity = "单价 ¥" + safeUnitPrice + " x" + Math.max(0, quantity); + String subtotalText = "小计 ¥" + safeSubtotal; + boolean splitPriceLine = priceMetrics.stringWidth(priceAndQuantity) + + priceMetrics.stringWidth(subtotalText) + 24 > innerWidth; + int priceLineHeight = priceMetrics.getHeight() + 4; + + setFont(Font.PLAIN, 29); + FontMetrics componentMetrics = graphics.getFontMetrics(); + int componentLineHeight = componentMetrics.getHeight() + 3; + List> componentLines = new ArrayList<>(); + for (String component : safeComponents) { + componentLines.add(wrap("- " + component, innerWidth - CHILD_INDENT, componentMetrics)); + } + int componentHeight = componentLines.stream().mapToInt(List::size).sum() * componentLineHeight; + int comboSectionHeight = combo && !componentLines.isEmpty() + ? 18 + componentLineHeight + componentHeight : 0; + int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight + 8 + + (splitPriceLine ? priceLineHeight * 2 : priceLineHeight) + comboSectionHeight + + PRODUCT_CARD_PADDING; + ensureCapacity(cardHeight + PRODUCT_CARD_GAP); + + int cardTop = cursorY; + int contentX = HORIZONTAL_PADDING + PRODUCT_CARD_PADDING; + int contentY = cardTop + PRODUCT_CARD_PADDING; + graphics.setColor(Color.BLACK); + graphics.setStroke(new BasicStroke(combo ? 4F : 2F)); + graphics.drawRect(HORIZONTAL_PADDING, cardTop, contentWidth(), cardHeight); + + setFont(Font.BOLD, 28); + if (combo) { + graphics.fillRect(contentX, contentY, tagWidth, tagHeight); + graphics.setColor(Color.WHITE); + graphics.drawString(typeLabel, contentX + 10, contentY + 3 + tagMetrics.getAscent()); + graphics.setColor(Color.BLACK); + } else { + graphics.setStroke(new BasicStroke(2F)); + graphics.drawRect(contentX, contentY, tagWidth, tagHeight); + graphics.drawString(typeLabel, contentX + 10, contentY + 3 + tagMetrics.getAscent()); + } + contentY += tagHeight + 10; + + setFont(Font.BOLD, 40); + for (String line : nameLines) { + graphics.drawString(line, contentX, contentY + nameMetrics.getAscent()); + contentY += nameLineHeight; + } + contentY += 8; + + setFont(Font.PLAIN, 29); + graphics.drawString(priceAndQuantity, contentX, contentY + priceMetrics.getAscent()); + if (splitPriceLine) { + contentY += priceLineHeight; + } + graphics.drawString(subtotalText, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING + - priceMetrics.stringWidth(subtotalText), + contentY + priceMetrics.getAscent()); + contentY += priceLineHeight; + + if (combo && !componentLines.isEmpty()) { + contentY += 8; + graphics.setStroke(new BasicStroke(1F)); + graphics.drawLine(contentX, contentY, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING, contentY); + contentY += 10; + setFont(Font.BOLD, 29); + graphics.drawString("组合内容", contentX, contentY + componentMetrics.getAscent()); + contentY += componentLineHeight; + setFont(Font.PLAIN, 29); + for (List lines : componentLines) { + for (String line : lines) { + graphics.drawString(line, contentX + CHILD_INDENT, + contentY + componentMetrics.getAscent()); + contentY += componentLineHeight; + } + } + } + cursorY += cardHeight + PRODUCT_CARD_GAP; + return this; + } + + /** + * 绘制兑换券商品合计,使用粗分隔线和大号金额形成票据金额层级。 + * + * @param totalAmount 剩余可履约商品合计,单位元且不含货币符号 + * @return 当前绘制器 + */ + public ThermalReceiptImage voucherTotal(String totalAmount) { + String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount; + String label = "商品合计"; + String amount = "¥" + safeAmount; + setFont(Font.BOLD, 42); + FontMetrics metrics = graphics.getFontMetrics(); + int blockHeight = metrics.getHeight() + 30; + ensureCapacity(blockHeight + 14); + graphics.setColor(Color.BLACK); + graphics.setStroke(new BasicStroke(3F)); + graphics.drawLine(HORIZONTAL_PADDING, cursorY, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY); + int baseline = cursorY + 15 + metrics.getAscent(); + graphics.drawString(label, HORIZONTAL_PADDING, baseline); + graphics.drawString(amount, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(amount), baseline); + graphics.drawLine(HORIZONTAL_PADDING, cursorY + blockHeight, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + blockHeight); + cursorY += blockHeight + 14; + return this; + } + + /** + * 使用指定尾列宽度绘制一条可换行明细。 + * + * @param name 左侧名称 + * @param trailing 首行右对齐摘要 + * @param child 是否为套餐组成项 + * @param emphasized 是否使用粗体 + * @param trailingWidth 尾列预留像素宽度 + * @return 当前绘制器 + */ + private ThermalReceiptImage item(String name, String trailing, boolean child, boolean emphasized, + int trailingWidth) { + setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 30 : 34); + FontMetrics metrics = graphics.getFontMetrics(); + int indent = child ? CHILD_INDENT : 0; + String prefix = child ? "- " : ""; + String safeName = prefix + (name == null || name.isBlank() ? "未命名商品" : name); + List lines = wrap(safeName, contentWidth() - indent - trailingWidth, metrics); + int lineHeight = metrics.getHeight() + 5; + ensureCapacity(lines.size() * lineHeight + 8); + for (int index = 0; index < lines.size(); index++) { + graphics.drawString(lines.get(index), HORIZONTAL_PADDING + indent, + cursorY + index * lineHeight + metrics.getAscent()); + } + String safeTrailing = trailing == null || trailing.isBlank() ? "x0" : trailing; + graphics.drawString(safeTrailing, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(safeTrailing), + cursorY + metrics.getAscent()); + cursorY += lines.size() * lineHeight + 6; + return this; + } + + /** + * 绘制横向分隔线。 + * + * @return 当前绘制器 + */ + public ThermalReceiptImage divider() { + ensureCapacity(28); + graphics.setStroke(new BasicStroke(2F)); + graphics.drawLine(HORIZONTAL_PADDING, cursorY + 10, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + 10); + cursorY += 28; + return this; + } + + /** + * 输出裁剪到实际高度的 PNG 字节。 + * + * @return PNG 字节 + * @throws IOException PNG 编码失败 + */ + public byte[] toPngBytes() throws IOException { + int finalHeight = cursorY + VERTICAL_PADDING; + BufferedImage result = new BufferedImage(PAPER_WIDTH_PIXELS, finalHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D resultGraphics = result.createGraphics(); + resultGraphics.setColor(Color.WHITE); + resultGraphics.fillRect(0, 0, result.getWidth(), result.getHeight()); + resultGraphics.drawImage(canvas, 0, 0, null); + resultGraphics.dispose(); + graphics.dispose(); + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + ImageIO.write(result, "png", output); + return output.toByteArray(); + } + } + + /** + * 设置当前字体。 + * + * @param style 字体样式 + * @param size 字体像素大小 + */ + private void setFont(int style, int size) { + graphics.setFont(new Font(FONT_FAMILY, style, size)); + } + + /** + * 绘制一行居中文本并推进游标。 + * + * @param text 文本 + * @param bottomSpace 底部留白 + */ + private void drawCentered(String text, int bottomSpace) { + String safeText = text == null ? "" : text; + FontMetrics metrics = graphics.getFontMetrics(); + ensureCapacity(metrics.getHeight() + bottomSpace); + int x = Math.max(HORIZONTAL_PADDING, + (PAPER_WIDTH_PIXELS - metrics.stringWidth(safeText)) / 2); + graphics.drawString(safeText, x, cursorY + metrics.getAscent()); + cursorY += metrics.getHeight() + bottomSpace; + } + + /** + * 按实际字体宽度拆分字符串,单个超宽字符仍至少独占一行。 + * + * @param text 原文本 + * @param maxWidth 最大像素宽度 + * @param metrics 当前字体度量 + * @return 非空行列表 + */ + private List wrap(String text, int maxWidth, FontMetrics metrics) { + List lines = new ArrayList<>(); + StringBuilder line = new StringBuilder(); + String safeText = text == null ? "" : text; + for (int offset = 0; offset < safeText.length();) { + int codePoint = safeText.codePointAt(offset); + String character = new String(Character.toChars(codePoint)); + if (line.length() > 0 && metrics.stringWidth(line + character) > maxWidth) { + lines.add(line.toString()); + line.setLength(0); + } + line.append(character); + offset += Character.charCount(codePoint); + } + if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString()); + return lines; + } + + /** + * 确保画布能够容纳下一块内容,并在扩容时保留字体、画笔和已绘制像素。 + * + * @param requiredHeight 下一块内容需要的高度 + */ + private void ensureCapacity(int requiredHeight) { + if (cursorY + requiredHeight <= canvas.getHeight()) return; + int newHeight = canvas.getHeight(); + while (cursorY + requiredHeight > newHeight) newHeight += HEIGHT_GROWTH; + Font font = graphics.getFont(); + Color color = graphics.getColor(); + BufferedImage expanded = new BufferedImage(PAPER_WIDTH_PIXELS, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D expandedGraphics = expanded.createGraphics(); + configureGraphics(expandedGraphics); + expandedGraphics.setColor(Color.WHITE); + expandedGraphics.fillRect(0, 0, expanded.getWidth(), expanded.getHeight()); + expandedGraphics.drawImage(canvas, 0, 0, null); + expandedGraphics.setFont(font); + expandedGraphics.setColor(color); + graphics.dispose(); + canvas = expanded; + graphics = expandedGraphics; + } + + /** + * 配置文字抗锯齿和高质量图片渲染参数。 + * + * @param target 待配置绘制上下文 + */ + private static void configureGraphics(Graphics2D target) { + target.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + target.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + } + + /** + * 计算扣除左右安全留白后的有效宽度。 + * + * @return 内容宽度 + */ + private static int contentWidth() { + return PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING * 2; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java index 0cd57feb6..bcfa973b3 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java @@ -63,6 +63,11 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterfac private String hardType; + /** + * 任务绑定的尔印 USB 打印机资产名称。 + */ + private String printerHardName; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java new file mode 100644 index 000000000..f73b35e60 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo; + +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 业务终端与无屏 USB 打印机绑定关系及展示信息。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "USB 打印机绑定详情") +public class HardPrinterBindingVo extends HardPrinterBindingEntity { + + /** 来源业务终端名称。 */ + @Schema(description = "来源业务终端名称") + private String sourceHardName; + + /** 来源业务终端类型。 */ + @Schema(description = "来源业务终端类型") + private String sourceHardType; + + /** 逻辑打印机资产名称。 */ + @Schema(description = "逻辑打印机资产名称") + private String printerHardName; + + /** 目标 USB 打印机当前配置;尚未保存时返回默认配置。 */ + @Schema(description = "USB 打印机配置") + private HardPrinterConfigEntity printerConfig; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java index 92578fee1..a884977ec 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java @@ -6,6 +6,11 @@ import com.bsd.pjcloud.hard.api.vo.base.BaseHardGroupInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import lombok.Data; +import java.time.LocalDateTime; + +/** + * 设备管理视图,包含设备基础资料及仅用于接口返回的关联、运行状态信息。 + */ @Data public class HardManagerVo extends HardManagerEntity implements BaseDeptInterface, BaseMchInterface, BaseHardGroupInterface { @@ -29,4 +34,59 @@ public class HardManagerVo extends HardManagerEntity implements BaseDeptInterfac private String hardGroupName; + /** + * 终端是否在线;超市、食堂设备返回布尔值,其他设备类型返回 {@code null} 表示暂不支持。 + */ + private Boolean online; + + /** + * 服务端最近收到终端心跳的时间;尚未上报或设备类型不支持时为空。 + */ + private LocalDateTime lastOnlineTime; + + /** + * 当前 APK 展示版本号,来源于终端最近一次心跳;未上报时为空。 + */ + private String apkVersionName; + + /** + * 当前 APK 内部版本码,来源于终端最近一次心跳;未上报时为空。 + */ + private Long apkVersionCode; + + /** + * 断开连接的可解释原因;除主动自动更新外均为基于心跳超时的推断结果。 + */ + private String disconnectReason; + + /** 当前来源业务终端所绑定的 EY_PRINT 打印机资产 ID;未绑定或当前行为打印机时为空。 */ + private Long boundPrinterHardId; + + /** 当前来源业务终端所绑定的 EY_PRINT 设备名称。 */ + private String boundPrinterHardName; + + /** 当前 EY_PRINT 打印机资产反向绑定的来源业务终端 ID;未绑定或当前行为来源设备时为空。 */ + private Long boundSourceHardId; + + /** 当前 EY_PRINT 打印机资产反向绑定的来源业务终端名称。 */ + private String boundSourceHardName; + + /** EY_PRINT 型号;未维护配置或非打印设备时为空。 */ + private String printerModel; + + /** EY_PRINT 的 USB 厂商编号 VID;尚未真机回填时为空。 */ + private Integer usbVendorId; + + /** EY_PRINT 的 USB 产品编号 PID;尚未真机回填时为空。 */ + private Integer usbProductId; + + /** 热敏纸宽度,首期固定为 80mm。 */ + private Integer paperWidth; + + /** 自动切刀开关,1 开启、0 关闭。 */ + private String autoCut; + + /** 图片指令模式,首选 GS_V0,兼容 ESC_STAR。 */ + private String imageMode; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogDeviceVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogDeviceVo.java new file mode 100644 index 000000000..e56f60518 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogDeviceVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 终端日志设备聚合摘要。 + */ +@Data +@Schema(description = "终端日志设备摘要") +public class HardTerminalLogDeviceVo { + + private Long hardId; + private String hardName; + private String uniqueCode; + private String hardType; + private Long merchantId; + private Long deptId; + private Long fileCount; + private LocalDateTime latestUploadTime; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogFileTreeVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogFileTreeVo.java new file mode 100644 index 000000000..7869baa73 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogFileTreeVo.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 终端日志文件树节点。 + */ +@Data +@Schema(description = "终端日志文件树节点") +public class HardTerminalLogFileTreeVo { + + private String key; + private String label; + private String relativePath; + private Boolean directory; + private Long fileId; + private Long fileSize; + private LocalDate logDate; + private LocalDateTime uploadTime; + private List children = new ArrayList<>(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreflightVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreflightVo.java new file mode 100644 index 000000000..b13456e4f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreflightVo.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端日志上传预检结果。 + */ +@Data +@Builder +@Schema(description = "终端日志上传预检结果") +public class HardTerminalLogPreflightVo { + + private Long id; + private Boolean needUpload; + private String normalizedPath; + private String message; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreviewVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreviewVo.java new file mode 100644 index 000000000..563fc4e80 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogPreviewVo.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端日志尾部预览。 + */ +@Data +@Builder +@Schema(description = "终端日志预览") +public class HardTerminalLogPreviewVo { + + private Long id; + private String originalName; + private String relativePath; + private String content; + private Integer lineCount; + private Long fileSize; + private Boolean truncated; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogUploadResultVo.java new file mode 100644 index 000000000..5b7dde157 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalLogUploadResultVo.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端日志上传结果。 + */ +@Data +@Builder +@Schema(description = "终端日志上传结果") +public class HardTerminalLogUploadResultVo { + + private Long id; + private Boolean uploaded; + private Boolean changed; + private String relativePath; + private Integer uploadCount; + private String message; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java index 59dabb76d..64ede7419 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java @@ -21,4 +21,14 @@ public class HardCarVo extends HardCarEntity { // 购买次数【统计使用】 private Long payCount; + /** + * 已完成退款份数,只统计售后状态为完成的商品明细;空响应按零处理。 + */ + private Integer refundedProductCount; + + /** + * 当前剩余可履约份数,计算规则为原购份数减已完成退款份数,最小为零。 + */ + private Integer effectiveProductCount; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java new file mode 100644 index 000000000..ee1649579 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * PC 订单详情使用的订单证据图片,附带稳定编码对应的中文名称。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片详情") +public class HardOrderEvidenceImageVo extends HardOrderEvidenceImageEntity { + + /** + * 证据类型中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据类型名称") + private String evidenceTypeName; + + /** + * 图片角色中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据图片角色名称") + private String evidenceRoleName; + + /** + * 证据状态中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据状态名称") + private String statusName; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java new file mode 100644 index 000000000..4c0220890 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 终端按本地订单号对账时返回的服务端订单与证据快照。 + */ +@Data +@Builder +@Schema(description = "订单证据对账结果") +public class HardOrderEvidenceResolveVo { + + /** + * 服务端订单主键。 + */ + @Schema(description = "服务端订单主键") + private Long orderId; + + /** + * 服务端订单状态,终端仅在现有支付流程确认成功后上传证据。 + */ + @Schema(description = "订单状态") + private String orderStatus; + + /** + * 当前服务端已登记的证据位置和状态,用于终端幂等跳过成功项。 + */ + @Schema(description = "证据列表") + private List evidenceImages; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java new file mode 100644 index 000000000..433881622 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端提交元数据、复制参考图或上传现场图后的幂等处理结果。 + */ +@Data +@Builder +@Schema(description = "订单证据处理结果") +public class HardOrderEvidenceUploadResultVo { + + /** + * 服务端证据记录主键。 + */ + @Schema(description = "证据记录主键") + private Long evidenceId; + + /** + * 服务端订单主键。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 处理后的证据状态。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * AVAILABLE 状态下的图片访问地址,其他状态允许为空。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 是否实际上传或复制了新文件;幂等命中时为 false。 + */ + @Schema(description = "是否新写入文件") + private Boolean uploaded; + + /** + * 面向终端同步日志的简短处理结果。 + */ + @Schema(description = "处理结果说明") + private String message; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 0f4a403bb..6b0335032 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -74,4 +74,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac */ private Long walletTypeId; + /** + * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。 + */ + private List evidenceImages; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/HardXfjLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/HardXfjLogVo.java new file mode 100644 index 000000000..695d217f6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/HardXfjLogVo.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity; +import lombok.Data; + +@Data +public class HardXfjLogVo extends HardXfjLogEntity { + + /** + * 设备名称 + */ + private String hardName; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjBaseVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjBaseVo.java new file mode 100644 index 000000000..0f6a5ab46 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjBaseVo.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjBaseVo { + + public static final Integer STATUS_SUCCESS = 1; + public static final Integer STATUS_FAIL = 0; + + public static final String DEFAULT_SUCCESS_MSG = "成功"; + public static final String DEFAULT_FAIL_MSG = "服务端错误"; + + /** + * 1:成功 + * 0:失败 + */ + private Integer Status = STATUS_SUCCESS; + + /** + * 错误消息(Status为0时屏幕显示此内容,最多不超过8个汉字) + */ + private String Msg = DEFAULT_SUCCESS_MSG; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjConsumTransactionsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjConsumTransactionsVo.java new file mode 100644 index 000000000..e4e24d70e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjConsumTransactionsVo.java @@ -0,0 +1,68 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjConsumTransactionsVo extends XfjBaseVo { + + /** + * 人员姓名 + */ + private String Name; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 现金余额(允许两位小数) + */ + private String Money; + + /** + * 补贴余额(允许两位小数) + */ + private String Subsidy; + + /** + * 剩余次数 + */ + private String Times; + + /** + * 赠送余额(允许两位小数) + */ + private String Integral; + + /** + * 入场时间(固定值:"") + */ + private String InTime; + + /** + * 出场时间(固定值:"") + */ + private String OutTime; + + /** + * 累计使用时间:天时分秒 + */ + private String CumulativeTime; + + /** + * 实际扣费金额(信息查询模式下返回"0") + */ + private String Amount; + + /** + * 语音段(用于安卓系统设备) + */ + private String VoiceID; + + /** + * 自定义显示文本(Status为1时传入,屏幕显示此值,内容内使用\r\n换行,最多支持4行,每行不超过8个汉字) + */ + private String Text; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjCountsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjCountsVo.java new file mode 100644 index 000000000..410e76cb0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjCountsVo.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjCountsVo extends XfjBaseVo { + + /** + * 自定义显示文本(Status为1时传入,屏幕显示此值,内容内使用\n换行,最多支持4行,每行不超过8个汉字) + */ + private String Text; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjKeepAliveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjKeepAliveVo.java new file mode 100644 index 000000000..1b42b883c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjKeepAliveVo.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjKeepAliveVo extends XfjBaseVo { + + /** + * 二维码交易订单号,没定单号时为空 + */ + private String Qrorder; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjMenusVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjMenusVo.java new file mode 100644 index 000000000..720e33a6d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjMenusVo.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjMenusVo extends XfjBaseVo { + + /** + * 菜品名称 + */ + private String Name; + + /** + * 菜品单价 + */ + private String Price; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOffLinesVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOffLinesVo.java new file mode 100644 index 000000000..06a857254 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOffLinesVo.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjOffLinesVo extends XfjBaseVo { + + /** + * Status为1时返回此内容(返回是请求示例中的order信息) + */ + private String Order; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderQueryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderQueryVo.java new file mode 100644 index 000000000..75092bc30 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderQueryVo.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjOrderQueryVo extends XfjBaseVo { + /** + * 人员姓名 + */ + private String Name; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 现金余额(允许两位小数) + */ + private String Money; + + /** + * 补贴余额(允许两位小数) + */ + private String Subsidy; + + /** + * 剩余次数 + */ + private String Times; + + /** + * 赠送余额(允许两位小数) + */ + private String Integral; + + /** + * 入场时间(固定值:"") + */ + private String InTime; + + /** + * 出场时间(固定值:"") + */ + private String OutTime; + + /** + * 累计使用时间:天时分秒 + */ + private String CumulativeTime; + + /** + * 实际扣费金额(信息查询模式下返回"0") + */ + private String Amount; + + /** + * 语音段(用于安卓系统设备) + */ + private String VoiceID; + + /** + * 自定义显示文本(Status为1时传入,屏幕显示此值,内容内使用\r\n换行,最多支持4行,每行不超过8个汉字) + */ + private String Text; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderRefundVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderRefundVo.java new file mode 100644 index 000000000..6c581ae43 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjOrderRefundVo.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjOrderRefundVo extends XfjBaseVo { + + /** + * 人员姓名 + */ + private String Name; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 现金余额(允许两位小数) + */ + private String Money; + + /** + * 补贴余额(允许两位小数) + */ + private String Subsidy; + + /** + * 剩余次数 + */ + private String Times; + + /** + * 赠送余额(允许两位小数) + */ + private String Integral; + + /** + * 退款金额 + */ + private String Amount; + + /** + * 11 (写死 11)用于区分接口 + */ + private Integer CmdType; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjQRCodeTransactionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjQRCodeTransactionVo.java new file mode 100644 index 000000000..316583539 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjQRCodeTransactionVo.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjQRCodeTransactionVo extends XfjBaseVo { + + /** + * 订单号 + */ + private String Qrorder; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjServerTimeVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjServerTimeVo.java new file mode 100644 index 000000000..2f4363a16 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjServerTimeVo.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjServerTimeVo extends XfjBaseVo { + + /** + * 服务器时间,固定格式为yyyyMMddHHmmssd最后一位d代表星期,星期一为1,星期六为6,星期天为0 + */ + private String Time; + + /** + * 1:有白名单更新 + * 0:无 + */ + private Integer WLUptate; + + /** + * 起始页数(当WLUptate为1却WLPage为0时,清除所有白名单) + */ + private Integer WLPage; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionInquiryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionInquiryVo.java new file mode 100644 index 000000000..438d42959 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionInquiryVo.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjTransactionInquiryVo extends XfjBaseVo { + /** + * 人员姓名 + */ + private String Name; + + /** + * 10进制卡序列号(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 现金余额(允许两位小数) + */ + private String Money; + + /** + * 补贴余额(允许两位小数) + */ + private String Subsidy; + + /** + * 剩余次数 + */ + private String Times; + + /** + * 赠送余额(允许两位小数) + */ + private String Integral; + + /** + * 入场时间(固定值:"") + */ + private String InTime; + + /** + * 出场时间(固定值:"") + */ + private String OutTime; + + /** + * 累计使用时间:天时分秒 + */ + private String CumulativeTime; + + /** + * 实际扣费金额(信息查询模式下返回"0") + */ + private String Amount; + + /** + * 语音段(用于安卓系统设备) + */ + private String VoiceID; + + /** + * 自定义显示文本(Status为1时传入,屏幕显示此值,内容内使用\r\n换行,最多支持4行,每行不超过8个汉字) + */ + private String Text; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionVo.java new file mode 100644 index 000000000..27302746d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjTransactionVo.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class XfjTransactionVo extends XfjBaseVo { + + /** + * 编号 从最后一笔消费往前算的位置 + * 1:就是最后一笔 + * 2:就是倒数第二笔 + */ + private String Number; + + /** + * 消费时间(格式:yyyyMMddHHmmss) + */ + private String Time; + + /** + * 卡号10进制(实体卡号或虚拟卡号) + */ + private String CardNo; + + /** + * 姓名最大5个汉字 + */ + private String Name; + + /** + * 消费额 + * - 10.00元 + */ + private String Consume; + + /** + * 余额 + * - 10.00元 + */ + private String Balance; + + + /** + * 消费订单号 + */ + private String OrderSeralid; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjWhiteListVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjWhiteListVo.java new file mode 100644 index 000000000..31b798b42 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/xfj/XfjWhiteListVo.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.hard.api.vo.xfj; + +import lombok.Data; + +@Data +public class XfjWhiteListVo extends XfjBaseVo { + + /** + * 返回设备提交的序号 + */ + private Integer CommID; + + /** + * 页数(获取那一页的数据) + */ + private Integer Page; + + /** + * 名单个数 + */ + private Integer PageLen; + + /** + * 白名单数据(1|0000000001|1,序号|卡号|操作) + * 1:添加 + * 0:删除 + */ + private String WLData; + + /** + * 1:继续获取 + * 0:已经更新完成 + */ + private Integer Uptate; + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml index e704d33a7..a69771160 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml @@ -13,6 +13,10 @@ pjcloud-hardware-biz + + com.bsd + pjcloud-common-encrypt-api + com.baomidou lock4j-redis-template-spring-boot-starter diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java index 324e9f6e7..ce83ad1cf 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.scheduling.annotation.EnableScheduling; /** * @author archetype auto @@ -17,6 +18,7 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @EnableDiscoveryClient @EnablePjcloudResourceServer @SpringBootApplication +@EnableScheduling public class HardApp { public static void main(String[] args) { SpringApplication.run(HardApp.class, args); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java index 8f48b633f..71fbfaab9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java @@ -9,6 +9,8 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRetryDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.service.HardInvoiceService; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +24,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; +import jakarta.validation.Valid; /** * 发票记录 @@ -48,6 +51,49 @@ public class HardInvoiceController { return R.ok(hardInvoiceService.print(orderId)); } + /** + * 创建发票或补建兑换券打印任务;已打印兑换券必须使用带专用权限的重打接口。 + * + * @param request 订单、打印类型和可选来源终端 + * @return 创建、复用或失败重试后的打印任务 + */ + @Operation(summary = "创建打印任务", description = "创建发票或补建兑换券打印任务") + @SysLog("创建打印任务") + @Idempotent(expireTime = 5, key = "'/hard-invoice/print:' + #request.getOrderId() + ':' + #request.getPrintType()", info = "正在创建打印任务,请勿重复操作") + @PostMapping("/print") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R createPrintTask(@Valid @RequestBody HardPrintRequestDto request) { + return R.ok(hardInvoiceService.createPrintTask(request)); + } + + /** + * 重试原失败任务,重试版本增加但打印序号不变。 + * + * @param request 失败任务 ID,以及原任务缺少来源时可补录的来源终端 ID + * @return 重试后的任务 + */ + @Operation(summary = "重试失败打印任务") + @SysLog("重试失败打印任务") + @PostMapping("/retry") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R retry(@Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardInvoiceService.retryTask(request.getHistoryId(), request.getSourceHardId())); + } + + /** + * 对已打印凭证创建下一打印序号并记录人工原因。 + * + * @param request 已打印任务 ID 和重打原因 + * @return 新打印任务 + */ + @Operation(summary = "人工重打兑换券") + @SysLog("人工重打兑换券") + @PostMapping("/reprint") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_voucher_reprint')") + public R reprint(@Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardInvoiceService.reprintTask(request.getHistoryId(), request.getReprintReason())); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardInvoice_view')") @@ -100,4 +146,4 @@ public class HardInvoiceController { public List export(HardInvoiceEntity hardInvoice, Long[] ids) { return hardInvoiceService.list(Wrappers.lambdaQuery(hardInvoice).in(ArrayUtil.isNotEmpty(ids), HardInvoiceEntity::getId, ids)); } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java new file mode 100644 index 000000000..82d125488 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java @@ -0,0 +1,132 @@ +package com.bsd.pjcloud.hard.controller; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.service.HardPrinterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 业务终端与无屏尔印 USB 打印机的绑定和配置管理接口。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-printer") +@Tag(description = "hard-printer", name = "尔印 USB 打印机管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardPrinterController { + + /** USB 打印机绑定和配置服务。 */ + private final HardPrinterService hardPrinterService; + + /** + * 查询指定设备当前绑定详情。 + * + * @param hardId 来源或打印设备 ID + * @return 绑定详情 + */ + @GetMapping("/binding/{hardId}") + @Operation(summary = "查询打印机绑定") + @PreAuthorize("@pms.hasPermission('hard_hardManager_view')") + public R getBinding(@PathVariable Long hardId) { + return R.ok(hardPrinterService.getBinding(hardId)); + } + + /** + * 查询来源设备可绑定的 EY_PRINT 打印机资产。 + * + * @param sourceHardId 来源业务终端 ID + * @return 可绑定设备列表 + */ + @GetMapping("/available") + @Operation(summary = "查询可绑定打印机") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R listAvailable(@RequestParam Long sourceHardId) { + return R.ok(hardPrinterService.listAvailablePrinters(sourceHardId)); + } + + /** + * 查询商户下已经绑定打印机的来源终端。 + * + * @param merchantId 商户 ID + * @return 来源终端列表 + */ + @GetMapping("/sources") + @Operation(summary = "查询已绑定打印机的业务终端") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R listBoundSources(@RequestParam Long merchantId) { + return R.ok(hardPrinterService.listBoundSources(merchantId)); + } + + /** + * 创建来源业务终端与 USB 打印机的一对一绑定。 + * + * @param dto 绑定请求 + * @return 绑定详情 + */ + @PostMapping("/binding") + @Operation(summary = "绑定 USB 打印机") + @SysLog("绑定 USB 打印机") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R bind(@Valid @RequestBody HardPrinterBindingDto dto) { + return R.ok(hardPrinterService.bind(dto)); + } + + /** + * 解除来源终端的打印机绑定。 + * + * @param sourceHardId 来源业务终端 ID + * @return 空成功响应 + */ + @DeleteMapping("/binding/{sourceHardId}") + @Operation(summary = "解绑 USB 打印机") + @SysLog("解绑 USB 打印机") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R unbind(@PathVariable Long sourceHardId) { + hardPrinterService.unbind(sourceHardId); + return R.ok(); + } + + /** + * 查询 USB 打印机当前参数。 + * + * @param printerHardId EY_PRINT 逻辑打印机资产 ID + * @return 当前或默认配置 + */ + @GetMapping("/config/{printerHardId}") + @Operation(summary = "查询 USB 打印机配置") + @PreAuthorize("@pms.hasPermission('hard_hardManager_view')") + public R getConfig(@PathVariable Long printerHardId) { + return R.ok(hardPrinterService.getConfig(printerHardId)); + } + + /** + * 保存 USB 打印机参数。 + * + * @param dto 配置请求 + * @return 保存后的配置 + */ + @PutMapping("/config") + @Operation(summary = "保存 USB 打印机配置") + @SysLog("保存 USB 打印机配置") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R saveConfig(@Valid @RequestBody HardPrinterConfigDto dto) { + return R.ok(hardPrinterService.saveConfig(dto)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index c323c3ace..23d29eac2 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -8,6 +8,10 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRetryDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; import com.bsd.pjcloud.hard.api.dto.async.SyncOfflineReqVo; import com.bsd.pjcloud.hard.api.dto.hard.*; import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; @@ -40,11 +44,18 @@ public class HardApiController { private final HardManagerService hardManagerService; private final HardInvoiceService hardInvoiceService; + private final HardPrinterService hardPrinterService; private final HardCarService hardCarService; private final HardOrderService hardwareOrderService; private final HardOrderSyncService hardOrderSyncService; private final HardApiService hardApiService; private final HardOrderTotalService hardOrderTotalService; + private final HardTerminalPrintService hardTerminalPrintService; + + /** + * 食堂、超市 Android 终端心跳状态服务。 + */ + private final HardTerminalStatusService hardTerminalStatusService; @PostMapping("/bind") @Operation(summary = "绑定服务端设备") @@ -52,6 +63,20 @@ public class HardApiController { return R.ok(hardManagerService.bind(dto)); } + /** + * 接收食堂、超市 Android 终端的在线心跳,设备身份由请求头中的设备 ID 确认。 + * + * @param hardId 当前绑定设备 ID + * @param dto APK 版本与心跳场景 + * @return 无业务数据的成功响应 + */ + @PostMapping("/terminal-heartbeat") + @Operation(summary = "上报终端心跳") + public R terminalHeartbeat(@RequestHeader("DEVICE-ID") Long hardId, @Valid @RequestBody TerminalHeartbeatDto dto) { + hardTerminalStatusService.heartbeat(hardId, dto); + return R.ok(); + } + @GetMapping("/get") @Operation(summary = "获取硬件配置信息") public R get(@RequestParam Long id) { @@ -146,6 +171,102 @@ public class HardApiController { return R.ok(hardInvoiceService.printInvoice(orderId, historyId)); } + /** + * 查询当前食堂或超市终端所绑定的无屏 USB 打印机及打印参数。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @return 当前绑定和打印机配置;尚未绑定时数据为空 + */ + @GetMapping("/printer/bootstrap") + @Operation(summary = "获取业务终端打印机绑定") + public R printerBootstrap(@RequestHeader("DEVICE-ID") Long hardId) { + return R.ok(hardPrinterService.getSourceBinding(hardId)); + } + + /** + * 由当前食堂或超市终端上报其实际连接打印机的型号、VID 和 PID。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param dto Android USB Host 枚举出的打印机信息 + * @return 保留纸宽、切刀和图片模式后的完整打印机配置 + */ + @PostMapping("/printer/info") + @Operation(summary = "业务终端上报 USB 打印机信息") + public R reportPrinterInfo(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrinterInfoReportDto dto) { + return R.ok(hardPrinterService.reportDeviceInfo(hardId, dto)); + } + + /** + * 拉取当前食堂或超市终端所绑定打印机尚未完成的任务,补偿离线期间遗漏的 MQTT 消息。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @return 待打印任务列表 + */ + @GetMapping("/printer/tasks") + @Operation(summary = "拉取业务终端代理打印待办") + public R printerTasks(@RequestHeader("DEVICE-ID") Long hardId) { + return R.ok(hardInvoiceService.listPendingTasks(hardId)); + } + + /** + * 由当前食堂或超市终端代理提交 USB 打印结果。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param result 任务、订单、版本和结果 + * @return 接受或幂等忽略时返回 {@code true} + */ + @PostMapping("/printer/result") + @Operation(summary = "提交业务终端代理打印结果") + public R printerResult(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintResultDto result) { + return R.ok(hardInvoiceService.reportPrintResult(hardId, result)); + } + + /** + * 业务终端按可信 DEVICE-ID 创建打印任务,禁止请求体跨商户指定来源设备。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param request 订单和打印类型 + * @return 创建、复用或重试后的任务 + */ + @PostMapping("/printer/task") + @Operation(summary = "业务终端创建打印任务") + public R createPrinterTask(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintRequestDto request) { + request.setSourceHardId(hardId); + return R.ok(hardInvoiceService.createPrintTask(request)); + } + + /** + * 查询当前业务终端为指定订单生成的发票、兑换券及最终打印结果。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param orderId 核销记录中的服务端订单 ID + * @return 当前设备范围内的服务端打印记录,结果不会读取终端本地数据库 + */ + @GetMapping("/printer/records") + @Operation(summary = "查询业务终端订单打印记录") + public R> printerRecords(@RequestHeader("DEVICE-ID") Long hardId, + @RequestParam Long orderId) { + return R.ok(hardTerminalPrintService.listRecords(hardId, orderId)); + } + + /** + * 由当前业务终端对本机已打印的核销兑换券创建下一打印序号。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param request 已打印任务 ID 和操作员填写的重打原因 + * @return 新建或并发场景下复用的兑换券打印任务 + */ + @PostMapping("/printer/reprint") + @Operation(summary = "业务终端重新打印核销兑换券") + public R reprintVoucher(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardTerminalPrintService.reprintVoucher( + hardId, request.getHistoryId(), request.getReprintReason())); + } + @PostMapping("/sync-offline-order") @Operation(summary = "同步离线订单") @Idempotent(expireTime = 1, info = "正在处理中,请勿重复操作") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java new file mode 100644 index 000000000..4ec314d7f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java @@ -0,0 +1,87 @@ +package com.bsd.pjcloud.hard.controller.hard; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 收银终端订单识别证据接口,使用统一 Token、租户头和 DEVICE-ID 校验调用方身份。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-order-evidence") +@Tag(description = "hard-order-evidence", name = "订单识别证据") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardOrderEvidenceImageController { + + /** + * 订单识别证据业务服务。 + */ + private final HardOrderEvidenceImageService hardOrderEvidenceImageService; + + /** + * 对账终端订单并返回服务端证据状态。 + * + * @param hardId 当前绑定设备主键 + * @param androidOrderCode 终端本地订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 服务端订单和证据列表 + */ + @GetMapping("/ad/resolve") + @Operation(summary = "按终端订单号或服务端订单ID对账识别证据") + public R resolve(@RequestHeader("DEVICE-ID") Long hardId, + @RequestParam(required = false) String androidOrderCode, + @RequestParam(required = false) Long orderId) { + return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode, orderId)); + } + + /** + * 登记证据元数据、复制参考图或报告终端图片永久缺失。 + * + * @param hardId 当前绑定设备主键 + * @param dto 证据元数据和目标状态 + * @return 处理后的证据状态 + */ + @PostMapping("/ad/submit") + @Operation(summary = "提交订单识别证据元数据") + public R submit(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardOrderEvidenceSubmitDto dto) { + return R.ok(hardOrderEvidenceImageService.submit(hardId, dto)); + } + + /** + * 上传现场识别图片,服务端校验图片内容后写入订单专属文件目录。 + * + * @param hardId 当前绑定设备主键 + * @param dto 订单与识别元数据 + * @param file JPEG 或 PNG 现场图片 + * @return 幂等上传结果 + */ + @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传订单现场识别图片") + public R upload(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @ModelAttribute HardOrderEvidenceUploadDto dto, + @RequestPart("file") MultipartFile file) { + return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardTerminalLogFileController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardTerminalLogFileController.java new file mode 100644 index 000000000..87dc4748b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardTerminalLogFileController.java @@ -0,0 +1,112 @@ +package com.bsd.pjcloud.hard.controller.hard; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogFileQueryDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogPreflightDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogUploadDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalLogFileEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogDeviceVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogFileTreeVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreflightVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreviewVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogUploadResultVo; +import com.bsd.pjcloud.hard.service.HardTerminalLogFileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 终端日志文件。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hardTerminalLogFile") +@Tag(description = "hardTerminalLogFile", name = "终端日志文件管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardTerminalLogFileController { + + private final HardTerminalLogFileService hardTerminalLogFileService; + + @Operation(summary = "终端日志上传预检") + @PostMapping("/ad/preflight") + public R preflight(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardTerminalLogPreflightDto dto) { + return R.ok(hardTerminalLogFileService.preflight(hardId, dto)); + } + + @Operation(summary = "上传脱敏后的终端日志") + @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R upload(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @ModelAttribute HardTerminalLogUploadDto dto, + @RequestPart("file") MultipartFile file) { + return R.ok(hardTerminalLogFileService.upload(hardId, dto, file)); + } + + @Operation(summary = "分页查询终端日志文件") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_view')") + public R> page(@ParameterObject Page page, + @ParameterObject HardTerminalLogFileQueryDto dto) { + return R.ok(hardTerminalLogFileService.scopePage(page, dto)); + } + + @Operation(summary = "按设备聚合终端日志") + @GetMapping("/device-page") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_view')") + public R> devicePage(@ParameterObject Page page, + @ParameterObject HardTerminalLogFileQueryDto dto) { + return R.ok(hardTerminalLogFileService.devicePage(page, dto)); + } + + @Operation(summary = "查询指定设备的日志文件树") + @GetMapping("/file-tree") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_view')") + public R> fileTree(@RequestParam Long hardId, + @ParameterObject HardTerminalLogFileQueryDto dto) { + return R.ok(hardTerminalLogFileService.fileTree(hardId, dto)); + } + + @Operation(summary = "预览终端日志尾部") + @GetMapping("/{id}/preview") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_preview')") + public R preview(@PathVariable Long id) { + return R.ok(hardTerminalLogFileService.preview(id)); + } + + @Operation(summary = "下载完整终端日志") + @GetMapping("/{id}/download") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_down')") + public void download(@PathVariable Long id, HttpServletResponse response) { + hardTerminalLogFileService.download(id, response); + } + + @Operation(summary = "立即拉取指定终端当前日志") + @SysLog("立即拉取终端当前日志") + @PostMapping("/pull/{hardId}") + @PreAuthorize("@pms.hasPermission('hard_hardTerminalLogFile_pull')") + public R pull(@PathVariable Long hardId) { + return R.ok(hardTerminalLogFileService.pull(hardId)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java index f7ea48a63..d8dc731ce 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java @@ -90,7 +90,7 @@ public class HardManagerController { @DeleteMapping @PreAuthorize("@pms.hasPermission('hard_hardManager_del')") public R removeById(@RequestBody Long[] ids) { - return R.ok(hardManagerService.removeBatchByIds(CollUtil.toList(ids))); + return R.ok(hardManagerService.removeWithPrinterCheck(CollUtil.toList(ids))); } @ResponseExcel diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index e9f4770e1..2cb12191f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -313,7 +313,7 @@ public class HardOrderController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(hardwareOrderService.join(id)); + return R.ok(hardwareOrderService.joinWithEvidence(id)); } @Operation(summary = "离线订单手动支付", description = "离线订单手动支付") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjController.java new file mode 100644 index 000000000..05731c66c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjController.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.hard.controller.xfj; + +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.hard.api.vo.xfj.XfjCountsVo; +import com.bsd.pjcloud.hard.api.vo.xfj.XfjServerTimeVo; +import com.bsd.pjcloud.hard.api.vo.xfj.XfjTransactionInquiryVo; +import com.bsd.pjcloud.hard.api.vo.xfj.XfjOrderRefundVo; +import com.bsd.pjcloud.hard.service.HardXfjService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.websocket.server.PathParam; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 设备预约管理 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hxz") +@Tag(description = "hxz", name = "消费机接口") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardXfjController { + + private final HardXfjService hardXfjService; + + @Operation(summary = "获取服务器时间接口") + @PostMapping("/v1/ServerTime") + @Inner(value = false) + public XfjServerTimeVo serverTime(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.serverTime(httpEntity.getBody(), headers); + } + + @Operation(summary = "用户刷卡综合接口") + @PostMapping("/v1/ConsumTransactions") + @Inner(value = false) + public Object consumTransactions(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.consumTransactions(httpEntity.getBody(), headers); + } + + @Operation(summary = "用户刷卡消费结果查询接口") + @PostMapping("/v1/orderQuery") + @Inner(value = false) + public Object orderQuery(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.orderQuery(httpEntity.getBody(), headers); + } + + @Operation(summary = "终端查询日统计接口") + @PostMapping("/v1/Counts") + @Inner(value = false) + public XfjCountsVo counts(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.counts(httpEntity.getBody(), headers); + } + + @Operation(summary = "终端设备待查状态,按上翻/下翻键向服务器请求,服务器返回一条消费明细") + @PostMapping("/v1/transaction") + @Inner(value = false) + public Object transaction(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.transaction(httpEntity.getBody(), headers); + } + + @Operation(summary = "订单退款接口") + @PostMapping("/v1/OrderRefund") + @Inner(value = false) + public XfjOrderRefundVo orderRefund(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.orderRefund(httpEntity.getBody(), headers); + } + + @Operation(summary = "KeepAlive") + @PostMapping("/v1/KeepAlive") + @Inner(value = false) + public Object keepAlive(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.keepAlive(httpEntity.getBody(), headers); + } + + @Operation(summary = "ConCode") + @PostMapping("/v1/ConCode/:deviceCode/:price") + @Inner(value = false) + public Object conCode(@PathParam("deviceCode") String deviceCode, @PathParam("price") String price, HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.conCode(deviceCode, price, httpEntity.getBody(), headers); + } + + @Operation(summary = "付款码(二维码)支付接口") + @PostMapping("/v1/QRCodeTransaction") + @Inner(value = false) + public Object qrCodeTransaction(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.qrCodeTransaction(httpEntity.getBody(), headers); + } + + @Operation(summary = "二维码支付结果查询接口") + @PostMapping("/v1/TransactionInquiry") + @Inner(value = false) + public XfjTransactionInquiryVo transactionInquiry(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.transactionInquiry(httpEntity.getBody(), headers); + } + + @Operation(summary = "菜品查询接口") + @PostMapping("/v1/menus") + @Inner(value = false) + public Object menus(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.menus(httpEntity.getBody(), headers); + } + + @Operation(summary = "记账扣费流水上传接口") + @PostMapping("/v1/OffLines") + @Inner(value = false) + public Object offLines(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.offLines(httpEntity.getBody(), headers); + } + + @Operation(summary = "获取卡号白名单接口") + @PostMapping("/v1/WhiteList") + @Inner(value = false) + public Object whiteList(HttpEntity httpEntity, @RequestHeader Map headers) { + return hardXfjService.whiteList(httpEntity.getBody(), headers); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjLogController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjLogController.java new file mode 100755 index 000000000..7c2453740 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/xfj/HardXfjLogController.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.hard.controller.xfj; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.xfj.HardXfjLogDto; +import com.bsd.pjcloud.hard.service.HardXfjLogService; +import org.springframework.security.access.prepost.PreAuthorize; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpHeaders; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 消费机日志 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/xfj-log") +@Tag(description = "xfj-log", name = "消费机日志管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardXfjLogController { + + private final HardXfjLogService hardXfjLogService; + + /** + * 分页查询 + */ + @Operation(summary = "分页查询", description = "分页查询") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('hard_hardXfjLog_view')") + public R getHardXfjLogPage(@ParameterObject Page page, @ParameterObject HardXfjLogDto dto) { + return R.ok(hardXfjLogService.pageList(page, dto)); + } + + /** + * 通过id查询消费机日志 + */ + @Operation(summary = "通过id查询", description = "通过id查询") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('hard_hardXfjLog_view')") + public R getById(@PathVariable("id") Long id) { + return R.ok(hardXfjLogService.getById(id)); + } + +} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java new file mode 100644 index 000000000..449a6f65a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.hard.service.HardInvoiceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 持久化打印任务补偿调度器,恢复进程退出或 MQTT 离线期间遗留的任务。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HardPrintTaskJob { + + /** 打印任务服务。 */ + private final HardInvoiceService hardInvoiceService; + + /** + * 每十秒扫描待生成任务,并按固定间隔补偿下发待打印任务。 + */ + @Scheduled(fixedDelay = 10000L, initialDelay = 20000L) + public void processDueTasks() { + try { + int processed = hardInvoiceService.processDueTasks(); + if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed); + } catch (Exception exception) { + log.error("打印任务补偿扫描异常", exception); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java new file mode 100644 index 000000000..50b67b3c4 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.hard.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 业务终端与无屏 USB 打印机资产一对一绑定关系数据访问接口。 + */ +@Mapper +public interface HardPrinterBindingMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java new file mode 100644 index 000000000..8734046e9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.hard.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 尔印 USB 打印机资产参数数据访问接口。 + */ +@Mapper +public interface HardPrinterConfigMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalLogFileMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalLogFileMapper.java new file mode 100644 index 000000000..152bb2995 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalLogFileMapper.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.mapper.manager; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogFileQueryDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalLogFileEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogDeviceVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface HardTerminalLogFileMapper extends PjcloudBaseMapper { + + default LambdaQueryWrapper queryWrapper(HardTerminalLogFileQueryDto dto) { + HardTerminalLogFileQueryDto query = dto == null ? new HardTerminalLogFileQueryDto() : dto; + return new LambdaQueryWrapper() + .eq(ObjectUtil.isNotEmpty(query.getHardId()), HardTerminalLogFileEntity::getHardId, query.getHardId()) + .like(StrUtil.isNotBlank(query.getHardName()), HardTerminalLogFileEntity::getHardName, query.getHardName()) + .like(StrUtil.isNotBlank(query.getUniqueCode()), HardTerminalLogFileEntity::getUniqueCode, query.getUniqueCode()) + .eq(ObjectUtil.isNotEmpty(query.getMerchantId()), HardTerminalLogFileEntity::getMerchantId, query.getMerchantId()) + .eq(ObjectUtil.isNotEmpty(query.getDeptId()), HardTerminalLogFileEntity::getDeptId, query.getDeptId()) + .ge(ObjectUtil.isNotEmpty(query.getLogDateStart()), HardTerminalLogFileEntity::getLogDate, query.getLogDateStart()) + .le(ObjectUtil.isNotEmpty(query.getLogDateEnd()), HardTerminalLogFileEntity::getLogDate, query.getLogDateEnd()) + .ge(ObjectUtil.isNotEmpty(query.getUploadTimeStart()), HardTerminalLogFileEntity::getUploadTime, query.getUploadTimeStart()) + .le(ObjectUtil.isNotEmpty(query.getUploadTimeEnd()), HardTerminalLogFileEntity::getUploadTime, query.getUploadTimeEnd()) + .orderByDesc(HardTerminalLogFileEntity::getUploadTime) + .orderByDesc(HardTerminalLogFileEntity::getId); + } + + default Page scopePage(Page page, HardTerminalLogFileQueryDto dto) { + return selectPageByScope(page, queryWrapper(dto), DataScope.of()); + } + + default List scopeList(HardTerminalLogFileQueryDto dto) { + return selectListByScope(queryWrapper(dto), DataScope.of()); + } + + List deviceGroups(@Param("dto") HardTerminalLogFileQueryDto dto, + @Param("dataScope") DataScope dataScope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalStatusMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalStatusMapper.java new file mode 100644 index 000000000..0f7861766 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardTerminalStatusMapper.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.mapper.manager; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalStatusEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 终端最新心跳状态数据访问层。 + */ +@Mapper +public interface HardTerminalStatusMapper extends PjcloudBaseMapper { + + /** + * 以设备 ID 为唯一键写入最新心跳;冲突时仅更新最新状态和审计修改时间。 + * + * @param status 已经完成设备与租户校验的心跳状态 + * @return MySQL 受影响行数;新增通常为 1,更新通常为 2 + */ + int upsert(@Param("status") HardTerminalStatusEntity status); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java new file mode 100644 index 000000000..c00bf28fe --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 订单识别证据 Mapper,所有查询由租户拦截器自动限定当前租户。 + */ +@Mapper +public interface HardOrderEvidenceImageMapper extends PjcloudBaseMapper { + + /** + * 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。 + * + * @param orderId 服务端订单主键 + * @return 当前租户下的订单证据列表 + */ + default List listByOrderId(Long orderId) { + return selectList(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceType) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceRole) + .orderByAsc(HardOrderEvidenceImageEntity::getSequenceNo) + .orderByAsc(HardOrderEvidenceImageEntity::getId)); + } + + /** + * 查询订单中的唯一证据位置,用于上传幂等和内容冲突检查。 + * + * @param orderId 服务端订单主键 + * @param evidenceType 证据类型稳定编码 + * @param evidenceRole 图片角色稳定编码 + * @param sequenceNo 同角色图片序号 + * @return 已存在的证据;不存在时返回 {@code null} + */ + default HardOrderEvidenceImageEntity findPosition(Long orderId, String evidenceType, String evidenceRole, Integer sequenceNo) { + return selectOne(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType) + .eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole) + .eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index 0c9e6cbe8..efc66a9b8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -26,6 +26,14 @@ import java.util.List; @Mapper public interface HardOrderMapper extends PjcloudBaseMapper { + /** + * 按主键查询并锁定订单行,供后台打印任务创建、重打序号分配等串行化同一订单。 + * + * @param id 订单主键 + * @return 锁定后的订单;不存在时返回 {@code null} + */ + HardOrderEntity selectByIdForUpdate(@Param("id") Long id); + default LambdaQueryWrapper commonWrapper(HardOrderDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getOrderType()), HardOrderEntity::getOrderType, dto.getOrderType()) @@ -135,8 +143,12 @@ public interface HardOrderMapper extends PjcloudBaseMapper { List listSb(@Param("dto") HardOrderDto dto, DataScope dataScope); + HardOrderSbVo sbTotal(@Param("dto") HardOrderDto totalDto, DataScope totalDataScope); + List listRefundSb(@Param("dto") HardOrderDto refundDto, @Param("refundDataScope") DataScope refundDataScope); + HardOrderSbVo refundSbTotal(@Param("dto") HardOrderDto refundDto, @Param("refundDataScope") DataScope refundDataScope); + List listCs(@Param("dto") HardOrderDto dto, DataScope dataScope); List listRefundCs(@Param("dto") HardOrderDto refundDto, DataScope refundDataScope); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/xfj/HardXfjLogMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/xfj/HardXfjLogMapper.java new file mode 100755 index 000000000..63c855966 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/xfj/HardXfjLogMapper.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.hard.mapper.xfj; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.dto.xfj.HardXfjLogDto; +import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity; +import com.bsd.pjcloud.hard.api.vo.xfj.HardXfjLogVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface HardXfjLogMapper extends PjcloudBaseMapper { + + Page pageList(Page page, @Param("dto") HardXfjLogDto dto); + +} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 6ab612301..ffcc1a610 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -79,4 +79,9 @@ public interface HardAfterSaleService extends IService { * 消费机限时退款 */ Boolean adTimeLintRefund(Long orderId, String reason, PjcloudUser user); + + /** + * 消费机直退(不限支付时间) + */ + Boolean adRefund(Long orderId, String reason); } \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java index 624b7f6bc..faca006f8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java @@ -3,12 +3,26 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; +import java.util.List; + +/** + * 打印凭证图片、持久化任务及终端回执服务。 + */ public interface HardInvoiceService extends IService { + /** + * 按数据权限分页查询打印任务。 + * + * @param page 分页参数 + * @param dto 查询条件 + * @return 打印任务分页 + */ Page scopeJoinPageList(Page page, HardInvoiceDto dto); /** @@ -37,4 +51,79 @@ public interface HardInvoiceService extends IService { * 根据订单信息打印 */ Boolean printByOrder(HardOrderEntity order); -} \ No newline at end of file + + /** + * 在预约核销事务内幂等创建兑换券任务,不执行图片或网络操作。 + * + * @param order 已持有行锁的预约订单快照 + * @param sourceHardId 实际核销终端 ID;缺少请求头时为非正数 + * @return 创建或已存在的兑换券任务 + */ + HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId); + + /** + * 为同一批已核销预约订单创建一个兑换券打印任务,订单内容在异步生成图片时按核销批次恢复。 + * + * @param orders 已在同一事务中完成核销的订单,第一笔作为打印任务锚点 + * @param sourceHardId 实际核销终端 ID + * @return 合并批次的唯一兑换券任务 + */ + HardInvoiceEntity createRedemptionVoucherBatchTask(List orders, Long sourceHardId); + + /** + * 管理端创建首张发票或补建兑换券任务。 + * + * @param request 打印请求 + * @return 创建、复用或重试后的任务 + */ + HardInvoiceEntity createPrintTask(HardPrintRequestDto request); + + /** + * 重试失败任务并增加 retryVersion,打印序号保持不变。 + * + * @param historyId 打印任务 ID + * @return 重试后的任务 + */ + HardInvoiceEntity retryTask(Long historyId, Long sourceHardId); + + /** + * 对已打印任务创建下一打印序号,原因必须填写。 + * + * @param historyId 已打印任务 ID + * @param reason 人工重打原因 + * @return 新打印任务 + */ + HardInvoiceEntity reprintTask(Long historyId, String reason); + + /** + * 核销回退时取消该订单所有未打印兑换券任务。 + * + * @param orderId 订单 ID + * @return 被取消任务数量 + */ + int cancelPendingVoucherTasks(Long orderId); + + /** + * 查询指定来源业务终端所绑定打印机的待打印任务,供启动、重连和定时补拉。 + * + * @param sourceHardId DEVICE-ID 请求头中的来源业务终端 ID + * @return 待打印任务列表 + */ + List listPendingTasks(Long sourceHardId); + + /** + * 校验终端身份、订单和版本后记录打印结果。 + * + * @param sourceHardId DEVICE-ID 请求头解析出的来源业务终端 + * @param result 终端回执 + * @return 接受或幂等忽略回执时返回 {@code true} + */ + Boolean reportPrintResult(Long sourceHardId, HardPrintResultDto result); + + /** + * 跨租户扫描并处理待生成和待打印任务。 + * + * @return 本轮处理任务数量 + */ + int processDueTasks(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java index 2025476c8..39f22d354 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java @@ -21,10 +21,20 @@ public interface HardManagerService extends IService { void edit(HardManagerDto dto); + /** + * 删除设备前校验其打印绑定和待处理任务。 + * + * @param ids 待删除设备 ID 列表 + * @return 实际删除结果 + */ + boolean removeWithPrinterCheck(List ids); + HardManagerVo bind(HardBindDto dto); HardManagerVo getByHardId(Long hardId); + HardManagerVo getByUniqueCode(String uniqueCode); + /** * 判断当前用户是否有食堂或者超市管理元权限 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java new file mode 100644 index 000000000..2312cabb1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 订单识别证据服务,负责设备与订单校验、幂等占位、现场图上传和参考图复制。 + */ +public interface HardOrderEvidenceImageService extends IService { + + /** + * 按终端订单号或服务端订单 ID 对账订单和已登记证据。 + * + * @param hardId 请求头中的当前设备主键 + * @param androidOrderCode 终端本地稳定订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 服务端订单及证据状态 + */ + HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId); + + /** + * 提交证据元数据、触发参考图复制或报告永久缺失。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 证据位置、识别快照和目标状态 + * @return 幂等处理结果 + */ + HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto); + + /** + * 上传终端现场证据图片,实际文件内容会与终端哈希和尺寸交叉校验。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 订单与证据元数据 + * @param file JPEG 或 PNG 图片 + * @return 幂等上传结果 + */ + HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file); + + /** + * 为已成功创建的订单登记证据占位;该操作失败不得回滚订单事务。 + * + * @param order 已创建订单快照 + * @param metadataList 终端随订单提交的可选证据元数据 + * @param appVersion 终端应用版本 + */ + void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion); + + /** + * 查询订单详情所需的证据列表并补齐中文展示名称。 + * + * @param orderId 服务端订单主键 + * @return 排序后的证据列表 + */ + List listByOrderId(Long orderId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index ec057d19f..e56753472 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; @@ -50,6 +51,14 @@ public interface HardOrderService extends IService { HardOrderVo join(Long id); + /** + * 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。 + * + * @param id 订单主键 + * @return 订单详情及识别证据;订单不存在时返回 {@code null} + */ + HardOrderVo joinWithEvidence(Long id); + /** * 创建订单 */ @@ -85,6 +94,11 @@ public interface HardOrderService extends IService { */ Long appCreateOrderScancode(@Valid HardAppCreateOrderDto dto); + /** + * 消费机快速支付 + */ + Long xfjFastCreateOrder(HardXfjFastCreateOrderDto dto); + /** * 创建活动订单 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalService.java index c0653c1b1..56007f9b4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalService.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto; @@ -17,6 +18,11 @@ public interface HardOrderTotalService { */ List listSb(HardOrderDto dto); + /** + * 设备交易记录总数 + */ + HardOrderSbVo totalSb(HardOrderDto dto, DataScope dataScope); + /** * 商户交易记录 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java new file mode 100644 index 000000000..17f3b79de --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java @@ -0,0 +1,120 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.HardPrinterBindingVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; + +import java.util.List; + +/** + * 业务终端与无屏尔印 USB 打印机的绑定和配置服务。 + */ +public interface HardPrinterService { + + /** + * 查询指定来源终端或打印机资产当前绑定详情。 + * + * @param hardId 来源业务终端或打印机资产 ID + * @return 绑定详情;未绑定时返回 {@code null} + */ + HardPrinterBindingVo getBinding(Long hardId); + + /** + * 按当前来源业务终端身份读取其绑定打印机和打印参数。 + * + * @param sourceHardId DEVICE-ID 声明的 SUPERMARKET/CANTEEN 设备 ID + * @return 当前绑定及打印参数;尚未绑定时返回 {@code null} + */ + HardPrinterBindingVo getSourceBinding(Long sourceHardId); + + /** + * 批量补充设备分页中的绑定关系和打印参数,避免管理端逐行请求。 + * + * @param devices 已完成数据权限过滤的当前分页设备 + */ + void enrichDevices(List devices); + + /** + * 查询来源设备同商户下尚未绑定的 EY_PRINT 打印机资产。 + * + * @param sourceHardId 来源业务终端 ID + * @return 可绑定打印机资产列表 + */ + List listAvailablePrinters(Long sourceHardId); + + /** + * 在当前操作人数据权限内查询商户下已经绑定打印机的业务终端,供无来源订单人工打印时选择。 + * + * @param merchantId 商户 ID + * @return 已绑定来源业务终端列表 + */ + List listBoundSources(Long merchantId); + + /** + * 创建严格一对一绑定并初始化打印机默认配置。 + * + * @param dto 来源和目标设备 + * @return 新绑定详情 + */ + HardPrinterBindingVo bind(HardPrinterBindingDto dto); + + /** + * 在没有待处理任务时解除指定来源终端绑定。 + * + * @param sourceHardId 来源业务终端 ID + */ + void unbind(Long sourceHardId); + + /** + * 查询打印机配置,不存在时返回尚未持久化的默认配置。 + * + * @param printerHardId EY_PRINT 设备 ID + * @return 当前或默认配置 + */ + HardPrinterConfigEntity getConfig(Long printerHardId); + + /** + * 保存打印机型号、USB 和 ESC/POS 参数。 + * + * @param dto 打印机配置 + * @return 保存后的配置 + */ + HardPrinterConfigEntity saveConfig(HardPrinterConfigDto dto); + + /** + * 由当前来源业务终端代理上报实际 USB 打印机型号和标识,保留后台维护的 ESC/POS 参数。 + * + * @param sourceHardId DEVICE-ID 请求头中的 SUPERMARKET/CANTEEN 设备 ID + * @param dto Android USB Host 读取到的型号、VID 和 PID + * @return 更新后的打印机配置 + */ + HardPrinterConfigEntity reportDeviceInfo(Long sourceHardId, HardPrinterInfoReportDto dto); + + /** + * 按来源业务终端解析当前有效打印机资产。 + * + * @param sourceHardId 来源终端 ID + * @return 当前绑定;未绑定时返回 {@code null} + */ + HardPrinterBindingEntity findBySourceHardId(Long sourceHardId); + + /** + * 校验已绑定设备不能直接变更类型、商户或部门。 + * + * @param before 修改前设备 + * @param after 修改请求 + */ + void validateDeviceChange(HardManagerEntity before, HardManagerEntity after); + + /** + * 校验设备删除前已经解除打印绑定且没有待处理任务。 + * + * @param hardId 待删除设备 ID + */ + void validateDeviceRemoval(Long hardId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalLogFileService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalLogFileService.java new file mode 100644 index 000000000..7c6124680 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalLogFileService.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogFileQueryDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogPreflightDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogUploadDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalLogFileEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogDeviceVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogFileTreeVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreflightVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreviewVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogUploadResultVo; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface HardTerminalLogFileService extends IService { + + HardTerminalLogPreflightVo preflight(Long hardId, HardTerminalLogPreflightDto dto); + + HardTerminalLogUploadResultVo upload(Long hardId, HardTerminalLogUploadDto dto, MultipartFile file); + + Page scopePage(Page page, HardTerminalLogFileQueryDto dto); + + Page devicePage(Page page, HardTerminalLogFileQueryDto dto); + + List fileTree(Long hardId, HardTerminalLogFileQueryDto dto); + + HardTerminalLogPreviewVo preview(Long id); + + void download(Long id, HttpServletResponse response); + + Boolean pull(Long hardId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java new file mode 100644 index 000000000..c29da1b1b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; + +import java.util.List; + +/** + * 食堂、超市 Android 终端查询本机打印凭证并发起兑换券重打的设备边界服务。 + */ +public interface HardTerminalPrintService { + + /** + * 查询当前来源终端为指定订单生成的打印凭证,最多返回最近一百条。 + * + * @param sourceHardId DEVICE-ID 请求头中的食堂或超市终端 ID + * @param orderId 核销记录中的服务端订单 ID + * @return 按创建时间和任务 ID 倒序排列的服务端打印任务,不读取终端本地数据库 + */ + List listRecords(Long sourceHardId, Long orderId); + + /** + * 校验任务类型和来源终端后,为已经打印的兑换券创建下一打印序号。 + * + * @param sourceHardId DEVICE-ID 请求头中的食堂或超市终端 ID + * @param historyId 已打印兑换券任务 ID + * @param reason 操作员填写的人工重打原因 + * @return 新建或并发场景下复用的兑换券打印任务 + */ + HardInvoiceEntity reprintVoucher(Long sourceHardId, Long historyId, String reason); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalStatusService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalStatusService.java new file mode 100644 index 000000000..e94f961eb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalStatusService.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.hard.TerminalHeartbeatDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalStatusEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; + +import java.util.List; + +/** + * 食堂、超市 Android 终端心跳状态服务。 + */ +public interface HardTerminalStatusService extends IService { + + /** + * 校验设备类型并以服务端时间写入最新心跳。 + * + * @param hardId 请求头中的设备 ID + * @param dto APK 版本与心跳场景 + */ + void heartbeat(Long hardId, TerminalHeartbeatDto dto); + + /** + * 按当前页设备 ID 批量补充在线状态与 APK 信息;不改变上游数据权限筛选结果。 + * + * @param records 已完成数据权限查询的设备视图列表 + */ + void enrich(List records); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java new file mode 100755 index 000000000..d613c4e7f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.xfj.HardXfjLogDto; +import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity; +import com.bsd.pjcloud.hard.api.vo.xfj.HardXfjLogVo; + +public interface HardXfjLogService extends IService { + + Page pageList(Page page, HardXfjLogDto dto); + +} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjService.java new file mode 100644 index 000000000..15be954de --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjService.java @@ -0,0 +1,74 @@ +package com.bsd.pjcloud.hard.service; + + +import com.bsd.pjcloud.hard.api.vo.xfj.*; + +import java.util.Map; + +public interface HardXfjService { + + /** + * 获取服务器时间接口 + */ + XfjServerTimeVo serverTime(String body, Map headers); + + /** + * 用户刷卡综合接口 + */ + XfjConsumTransactionsVo consumTransactions(String body, Map headers); + + /** + * 用户刷卡消费结果查询接口 + */ + XfjOrderQueryVo orderQuery(String body, Map headers); + + /** + * 终端查询日统计接口 + */ + XfjCountsVo counts(String body, Map headers); + + /** + * 终端设备待查状态,按上翻/下翻键向服务器请求,服务器返回一条消费明细 + */ + Object transaction(String body, Map headers); + + /** + * 订单退款接口 + */ + XfjOrderRefundVo orderRefund(String body, Map headers); + + /** + * KeepAlive + */ + XfjKeepAliveVo keepAlive(String body, Map headers); + + /** + * ConCode + */ + XfjKeepAliveVo conCode(String deviceCode, String price, String body, Map headers); + + /** + * 付款码(二维码)支付接口 + */ + XfjQRCodeTransactionVo qrCodeTransaction(String body, Map headers); + + /** + * 二维码支付结果查询接口 + */ + XfjTransactionInquiryVo transactionInquiry(String body, Map headers); + + /** + * 菜品查询接口 + */ + XfjMenusVo menus(String body, Map headers); + + /** + * 记账扣费流水上传接口 + */ + XfjOffLinesVo offLines(String body, Map headers); + + /** + * 获取白名单接口 + */ + XfjWhiteListVo whiteList(String body, Map headers); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java index ec0d4bcd9..40bdf7761 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java @@ -3,9 +3,9 @@ package com.bsd.pjcloud.hard.service.impl; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; -import cn.hutool.extra.spring.SpringUtil; -import com.alibaba.fastjson.JSON; +import cn.hutool.core.util.StrUtil; 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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -13,30 +13,42 @@ import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteParamService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.HardPrintFailureStageEnum; import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum; +import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; -import com.bsd.pjcloud.hard.api.utils.Drawing2D; +import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; -import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper; -import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardInvoiceService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.HardPrinterService; import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; @@ -47,260 +59,1237 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.validation.annotation.Validated; import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; /** - * 发票记录 + * 打印凭证记录与持久化任务服务实现。 * - * @author dzc - * @date 2025-01-05 14:17:32 + *

业务事务只创建任务;图片上传、MQTT 下发和终端打印均在事务提交后异步执行。

+ * + *

目标项目说明:本项目暂无用户身份(学生/教师)资料体系与食堂套餐(spuType/组成项)数据源, + * 兑换券用户区仅打印姓名基础资料,商品统一按普通单品处理,其余任务化流程与参考实现一致。

*/ @Validated @Slf4j @RequiredArgsConstructor @Service -public class HardInvoiceServiceImpl extends ServiceImpl implements HardInvoiceService { +public class HardInvoiceServiceImpl extends ServiceImpl + implements HardInvoiceService { - static private final String TAG = "发票记录"; + /** + * 核销回退后再次核销时自动记录的重打原因。 + */ + private static final String REVERIFY_REASON = "核销回退后再次核销"; + /** + * MQTT 待打印任务允许补偿重投的最小间隔,单位秒。 + */ + private static final long REDISPATCH_INTERVAL_SECONDS = 30L; + + /** + * 单轮跨租户扫描任务上限。 + */ + private static final int SCAN_LIMIT = 100; + + /** + * 错误摘要数据库最大长度。 + */ + private static final int ERROR_MAX_LENGTH = 2000; + + /** + * 是否显示兑换券套餐组合内容的租户公共参数键;缺失或异常时必须默认关闭。 + */ + private static final String VOUCHER_SHOW_COMBO_ITEMS_PARAM_KEY = "HARD_VOUCHER_SHOW_COMBO_ITEMS"; + + /** + * 多订单兑换券是否合并商品明细的租户公共参数键;缺失或异常时保持原有按订单展示版式。 + */ + private static final String VOUCHER_MERGE_MULTI_ORDER_PRODUCTS_PARAM_KEY = + "HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"; + + /** + * 设备管理服务。 + */ private final HardManagerService hardManagerService; + + /** + * 订单服务。 + */ private final HardOrderService orderService; + + /** + * 订单 Mapper,用于串行化同一订单的人工创建与重打序号分配。 + */ + private final HardOrderMapper hardOrderMapper; + + /** + * 订单商品服务。 + */ private final HardCarService carService; - private final HardOrderMapper hardOrderMapper; - private final HardManagerMapper hardManagerMapper; + /** + * 打印机绑定服务。 + */ + private final HardPrinterService printerService; + /** + * 用户远程服务。 + */ private final RemoteUserService userService; + + /** + * 部门远程服务。 + */ private final RemoteDeptService deptService; + + /** + * 商户远程服务。 + */ private final RemoteMchService remoteMchService; + + /** + * 钱包远程服务。 + */ private final RemoteWalletService walletService; + + /** + * 文件上传远程服务。 + */ private final RemoteFileService fileService; + + /** + * MQTT 消息远程服务。 + */ private final RemoteMqttEventService mqttEventService; + /** + * 租户公共参数远程服务,用于读取兑换券版式开关。 + */ + private final RemoteParamService remoteParamService; + + /** + * 硬件业务异步线程池。 + */ + private final ThreadPoolTaskExecutor hardTaskExecutor; + + /** + * {@inheritDoc} + */ @Override public Page scopeJoinPageList(Page page, HardInvoiceDto dto) { Page list = baseMapper.scopePageList(page, dto, DataScope.of()); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 部门 - List deptIds = list.getRecords().stream().map(HardInvoiceVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + List deptIds = list.getRecords().stream().map(HardInvoiceVo::getDeptId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { R> deptList = deptService.getDeptByDeptIds(deptIds); - if (ObjectUtil.isNotEmpty(deptList.getData())) { + if (deptList != null && ObjectUtil.isNotEmpty(deptList.getData())) { DataUtil.convertDeptList(list.getRecords(), deptList.getData()); } } - // 用户 - List userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + // 用户基础信息(姓名/手机号/头像),本项目不扩展学生、教师身份资料。 + List userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(userIds)) { R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); + if (userList != null && ObjectUtil.isNotEmpty(userList.getData())) { + DataUtil.convertUserList(list.getRecords(), + CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); } } - // 商户 - List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList(); + List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { R> mchList = remoteMchService.getByIds(mchIds); - if (ObjectUtil.isNotEmpty(mchList.getData())) { - WalletDataUtil.convertMchList(list.getRecords(), CollectionUtils.convertMap(mchList.getData(), MchManageVo::getId)); + if (mchList != null && ObjectUtil.isNotEmpty(mchList.getData())) { + WalletDataUtil.convertMchList(list.getRecords(), + CollectionUtils.convertMap(mchList.getData(), MchManageVo::getId)); } } } return list; } + /** + * {@inheritDoc} + */ @Override public String generateInvoice(HardOrderEntity order) { return generateInvoice(order, null); } + /** + * {@inheritDoc} + */ @Override public String generateInvoice(HardOrderEntity order, String code) { List carList = carService.list(new LambdaQueryWrapper() - .eq(HardCarEntity::getParentCarId, order.getParentCarId()) - ); - // 查询用户 + .eq(HardCarEntity::getParentCarId, order.getParentCarId())); String username = "外部用户-未知用户"; String userPhone = "外部用户-未知手机号"; - UserInfo user = userService.getInnerByUserId(order.getUserId()).getData(); - if (ObjectUtil.isNotEmpty(user)) { - if (ObjectUtil.isNotEmpty(user.getSysUser())) { - username = user.getSysUser().getName(); - userPhone = user.getSysUser().getPhone(); - } - } - // 查询商户 - MchManageVo mch = Optional.of(remoteMchService.getById(order.getMerchantId())).map(R::getData).orElseThrow(() -> new RuntimeException("商户不存在")); - // 订单类型 - String orderType = "- -"; - HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); - if (ObjectUtil.isNotEmpty(orderTypeEnum)) { - orderType = orderTypeEnum.getDescription(); - } - // 硬件类型 - String hardwareName = HardTypeEnum.CANTEEN.getType(); - HardTypeEnum sysTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); - if (ObjectUtil.isNotEmpty(sysTypeEnum)) { - hardwareName = sysTypeEnum.getDescription(); - } - // 钱包类型 - String walletTypeName = ""; - WalletManageVo wallet = walletService.getWalletById(order.getWalletId()).getData(); - if (ObjectUtil.isNotEmpty(wallet) && ObjectUtil.isNotEmpty(wallet.getWalletTypeUniqueCode())) { - try { - WalletTypeEnum walletTypeEnum = WalletTypeEnum.valueOfByCode(wallet.getWalletTypeUniqueCode()); - if (ObjectUtil.isNotEmpty(walletTypeEnum)) { - walletTypeName = walletTypeEnum.getDesc(); - } - } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|钱包类型错误", TAG, e); - } - } - Drawing2D drawing = new Drawing2D(); - drawing.setTitleTextStyle() - .drawingCenterText(String.format("智慧%s", hardwareName)) - .drawingDottedLine(); - String createTime = LocalDateTimeUtil.format(order.getCreateTime(), DatePattern.NORM_DATETIME_PATTERN); - String payTime = LocalDateTimeUtil.format(order.getPayTime(), DatePattern.NORM_DATETIME_PATTERN); - drawing.setDefaultStyle() - .drawingRowText("订单号", order.getId().toString()) - .drawingRowText("订单类型", orderType) - .drawingRowText("用户", username) - .drawingRowText("手机号", userPhone); - if (ObjectUtil.isNotEmpty(walletTypeName)) { - drawing.drawingRowText("钱包类型", walletTypeName); - } - drawing.drawingRowText("商户", mch.getName()) - .drawingRowText("部门", mch.getDeptName()) - .drawingRowText("下单时间", createTime); - if (ObjectUtil.isNotEmpty(payTime)) { - drawing.drawingRowText("支付时间", payTime); + R userResult = userService.getInnerByUserId(order.getUserId()); + UserInfo user = userResult == null ? null : userResult.getData(); + if (user != null && user.getSysUser() != null) { + username = user.getSysUser().getName(); + userPhone = user.getSysUser().getPhone(); } + MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在"); + String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType())) + .map(HardOrderTypeEnum::getDescription).orElse("--"); + String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType())) + .map(HardTypeEnum::getDescription).orElse("设备"); + String walletTypeName = resolveWalletTypeName(order.getWalletId()); + + ThermalReceiptImage image = new ThermalReceiptImage().title(String.format("智慧%s发票", hardwareName)) + .divider().keyValue("订单号", displayOrderNo(order)).keyValue("订单类型", orderType) + .keyValue("用户", StrUtil.blankToDefault(username, "--")) + .keyValue("手机号", StrUtil.blankToDefault(userPhone, "--")); + if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName); + image.keyValue("商户", mch.getName()).keyValue("部门", mch.getDeptName()) + .keyValue("下单时间", formatDateTime(order.getCreateTime())); + if (order.getPayTime() != null) image.keyValue("支付时间", formatDateTime(order.getPayTime())); if (ObjectUtil.isNotEmpty(carList)) { - drawing.drawingDottedLine(); - drawing.drawingRowText("商品", "数量", "总价"); - carList.forEach(v -> { - String name = v.getProductName(); - if (name.length() > 5) name = name.substring(0, 5) + "..."; - // 判断是重量还是数量 - String count = "0"; - if (ObjectUtil.isNotEmpty(v.getProductCount()) && !ObjectUtil.equals(v.getProductCount(), 0)) { - // 按数量 - count = v.getProductCount().toString(); - } else if (ObjectUtil.isNotEmpty(v.getProductWeight()) && v.getProductWeight().compareTo(BigDecimal.valueOf(0)) != 0) { - // 按重量 - count = v.getProductWeight() + "/kg"; - } - BigDecimal totalPrice = BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - drawing.drawingRowText(name, count, String.valueOf(totalPrice.doubleValue())); - }); - - - BigDecimal totalPrice = BigDecimal.valueOf(order.getTotalPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - BigDecimal amountMealPrice = BigDecimal.valueOf(order.getExtraPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - if (ObjectUtil.isNotEmpty(order.getExtraPrice()) && ObjectUtil.notEqual(order.getExtraPrice(), 0)) { - drawing.drawingRowText("额外支付金额", String.valueOf(amountMealPrice.doubleValue())); + image.divider().sectionTitle("商品明细"); + carList.forEach(car -> image.invoiceItem(car.getProductName(), productQuantity(car), + "¥" + money(car.getTotalPrice()))); + if (order.getExtraPrice() != null && order.getExtraPrice() != 0) { + image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue())); } - drawing.drawingRowText("小记", String.valueOf(totalPrice.doubleValue())).drawingDottedLine(); - } - String currentTime = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), DatePattern.NORM_DATETIME_PATTERN); - drawing.drawingRowText("打印时间", currentTime).drawingDottedLine(); - if (ObjectUtil.isNotEmpty(code)) { - drawing.drawingDottedLine().drawingRowText("取件码", code).drawingDottedLine(); + image.keyValue("小计", "¥" + money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue())) + .divider(); } + image.keyValue("打印时间", formatDateTime(LocalDateTime.now())); + if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code); try { - String fileName = String.format("invoice_%s_%s.png", order.getId(), LocalDateTimeUtil.now()); - byte[] bytes = drawing.getBytes(); - MultipartFile multipartFile = new MockMultipartFile("file", fileName + ".png", "image/png", new ByteArrayInputStream(bytes)); - Map uploadMap = (Map) fileService.innerUpload(multipartFile, null, null, null).getData(); - return (String) uploadMap.get("url"); - } catch (IOException e) { - e.printStackTrace(); - log.error("|{}|生成发票意外的错误dto:|{}|error:|{}|", TAG, JSON.toJSONString(order), e.getMessage()); - return ""; + return uploadPng("invoice_" + order.getId(), image.toPngBytes()); + } catch (IOException exception) { + throw new RuntimeException("生成发票图片失败", exception); } } + /** + * {@inheritDoc} + */ @Override @Transactional(rollbackFor = Exception.class) public HardInvoiceVo generateInvoice(Long orderId) { - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备订单不存在"); - HardManagerEntity hardManager = hardManagerService.getByHardId(order.getHardId()); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备不存在"); - String image = generateInvoice(order); - if (ObjectUtil.isEmpty(image)) return null; - HardInvoiceEntity insertObj = HardInvoiceEntity.builder().deptId(order.getDeptId()).hardId(hardManager.getId()).orderId(orderId).pic(image).status(HardPrintStatusEnum.GENERATE.getType()).build(); - baseMapper.insert(insertObj); - HardInvoiceVo result = BeanUtils.toBean(insertObj, HardInvoiceVo.class); + HardPrintRequestDto request = new HardPrintRequestDto(); + request.setOrderId(orderId); + request.setPrintType(HardPrintTypeEnum.INVOICE.getType()); + HardInvoiceEntity task = createPrintTask(request); + HardInvoiceVo result = BeanUtils.toBean(task, HardInvoiceVo.class); result.setHistoryId(result.getId()); return result; } + /** + * {@inheritDoc} + */ @Override public Boolean printInvoice(Long orderId, Long historyId) { - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备订单不存在"); HardInvoiceEntity detail = baseMapper.selectById(historyId); - if (ObjectUtil.isEmpty(detail)) throw new RuntimeException("开票记录不存在"); - if (!ObjectUtil.equals(detail.getStatus(), HardPrintStatusEnum.GENERATE.getType())) return false; - HardInvoiceEntity updateObj = HardInvoiceEntity.builder().id(historyId).status(HardPrintStatusEnum.PRINT.getType()).build(); - return baseMapper.updateById(updateObj) > 0; + if (detail == null || !Objects.equals(detail.getOrderId(), orderId)) { + throw new RuntimeException("打印任务与订单不匹配"); + } + if (detail.getPrinterHardId() != null) { + throw new RuntimeException("尔印打印任务必须由绑定业务终端使用带设备身份的结果接口"); + } + if (Objects.equals(detail.getStatus(), HardPrintStatusEnum.PRINT.getType())) return true; + if (!Objects.equals(detail.getStatus(), HardPrintStatusEnum.GENERATE.getType())) return false; + HardInvoiceEntity update = HardInvoiceEntity.builder().id(historyId) + .status(HardPrintStatusEnum.PRINT.getType()).printedAt(LocalDateTime.now()).build(); + return baseMapper.updateById(update) > 0; } + /** + * {@inheritDoc} + */ @Override @Transactional(rollbackFor = Exception.class) public Boolean print(Long orderId) { - // 查询订单 - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("订单不存在"); - getSelf().printByOrder(order); + HardPrintRequestDto request = new HardPrintRequestDto(); + request.setOrderId(orderId); + request.setPrintType(HardPrintTypeEnum.INVOICE.getType()); + createPrintTask(request); return true; } + /** + * {@inheritDoc} + */ @Override - // @Transactional(rollbackFor = Exception.class) + @Transactional(rollbackFor = Exception.class) public Boolean printByOrder(HardOrderEntity order) { - // 查询商户 - MchManageVo mch = remoteMchService.getById(order.getMerchantId()).getData(); - if (ObjectUtil.isEmpty(mch)) throw new RuntimeException("设备不存在"); - // 查询设备 - HardManagerVo hard = hardManagerService.getByHardId(order.getHardId()); - if (ObjectUtil.isEmpty(hard)) throw new RuntimeException("设备不存在"); - String image = generateInvoice(order); - HardInvoiceEntity insertObj = HardInvoiceEntity.builder().deptId(order.getDeptId()).hardId(hard.getId()).orderId(order.getId()).pic(image).status(HardPrintStatusEnum.GENERATE.getType()).build(); - baseMapper.insert(insertObj); - HardPrintMsg hardPrintMsg = new HardPrintMsg(); - hardPrintMsg.setUserId(order.getUserId()); - hardPrintMsg.setHardId(order.getHardId()); - hardPrintMsg.setMchDeptId(mch.getDeptId()); - hardPrintMsg.setHistoryId(insertObj.getId()); - hardPrintMsg.setOrderId(order.getId()); - hardPrintMsg.setAndroidOrderCode(order.getAndroidOrderCode()); - hardPrintMsg.setPic(image); - R result = mqttEventService.sendPrint(hardPrintMsg); - if (result.getCode() != 0 || !result.getData()) { - log.error("|{}|根据订单信息打印错误order:|{}|error:|{}|", TAG, JSON.toJSONString(order), result.getMsg()); - throw new RuntimeException("打印错误"); - } + createInitialTask(order, HardPrintTypeEnum.INVOICE, order.getHardId(), null); return true; } - private HardInvoiceServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId) { + return createRedemptionVoucherBatchTask(List.of(order), sourceHardId); } -} \ No newline at end of file + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity createRedemptionVoucherBatchTask(List orders, Long sourceHardId) { + if (orders == null || orders.isEmpty() || orders.get(0) == null) { + throw new RuntimeException("预约订单不能为空,无法创建兑换券任务"); + } + // 打印表以首笔订单作为任务唯一锚点;异步图片生成通过首订单核销快照恢复整批订单。 + return createInitialTask(orders.get(0), HardPrintTypeEnum.REDEMPTION_VOUCHER, sourceHardId, null); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity createPrintTask(HardPrintRequestDto request) { + HardPrintTypeEnum printType = HardPrintTypeEnum.valueOfByType(request.getPrintType()); + if (printType == null) throw new RuntimeException("打印类型不正确"); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(request.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) validateVoucherOrder(order); + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && isOpenStatus(latest.getStatus())) { + return Objects.equals(latest.getStatus(), HardPrintStatusEnum.FAILED.getType()) + ? retryTask(latest.getId(), request.getSourceHardId()) : latest; + } + if (latest != null && Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) { + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) { + throw new RuntimeException("已打印兑换券请使用重打接口并填写原因"); + } + if (StrUtil.isBlank(request.getReprintReason())) throw new RuntimeException("再次打印必须填写原因"); + } + return createNewTask(order, printType, resolveManualSource(order, request.getSourceHardId()), + request.getReprintReason()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity retryTask(Long historyId, Long sourceHardId) { + HardInvoiceEntity task = baseMapper.selectById(historyId); + if (task == null) throw new RuntimeException("打印任务不存在"); + if (!Objects.equals(task.getStatus(), HardPrintStatusEnum.FAILED.getType())) { + throw new RuntimeException("只有打印失败任务可以重试"); + } + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + if (task.getHardId() != null && sourceHardId != null && sourceHardId > 0 + && !Objects.equals(task.getHardId(), sourceHardId)) { + throw new RuntimeException("已有来源终端的任务不能在重试时更换来源"); + } + Long resolvedSourceHardId = resolveManualSource(order, + task.getHardId() == null ? sourceHardId : task.getHardId()); + HardPrinterBindingEntity binding = printerService.findBySourceHardId(resolvedSourceHardId); + boolean regenerateImage = StrUtil.isBlank(task.getPic()) + || Objects.equals(task.getFailureStage(), HardPrintFailureStageEnum.IMAGE_GENERATION.getStage()); + int currentVersion = ObjectUtil.defaultIfNull(task.getRetryVersion(), 0); + var update = Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .eq(HardInvoiceEntity::getRetryVersion, currentVersion) + .set(HardInvoiceEntity::getHardId, resolvedSourceHardId) + .set(HardInvoiceEntity::getRetryVersion, currentVersion + 1) + .set(HardInvoiceEntity::getAttemptCount, 0); + if (binding == null) { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .set(HardInvoiceEntity::getPrinterHardId, null) + .set(HardInvoiceEntity::getFailureStage, HardPrintFailureStageEnum.BINDING.getStage()) + .set(HardInvoiceEntity::getLastError, "来源终端未绑定尔印打印机"); + } else { + update.set(HardInvoiceEntity::getPrinterHardId, binding.getPrinterHardId()) + .set(HardInvoiceEntity::getStatus, regenerateImage + ? HardPrintStatusEnum.PENDING_GENERATION.getType() : HardPrintStatusEnum.GENERATE.getType()) + .set(regenerateImage, HardInvoiceEntity::getPic, null) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null); + } + int updated = baseMapper.update(null, update); + HardInvoiceEntity result = baseMapper.selectById(task.getId()); + if (updated > 0 && binding != null) scheduleAfterCommit(result); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity reprintTask(Long historyId, String reason) { + if (StrUtil.isBlank(reason)) throw new RuntimeException("人工重打必须填写原因"); + HardInvoiceEntity history = baseMapper.selectById(historyId); + if (history == null) throw new RuntimeException("打印任务不存在"); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(history.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + history = baseMapper.selectById(historyId); + if (history == null) throw new RuntimeException("打印任务不存在"); + HardPrintTypeEnum printType = normalizePrintType(history.getPrintType()); + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && !Objects.equals(latest.getId(), history.getId())) { + if (isOpenStatus(latest.getStatus())) return latest; + throw new RuntimeException("请基于最新已打印记录发起重打"); + } + if (!Objects.equals(history.getStatus(), HardPrintStatusEnum.PRINT.getType())) { + throw new RuntimeException("只有已打印任务可以创建下一次重打"); + } + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) validateVoucherOrder(order); + return createNewTask(order, printType, history.getHardId(), reason.trim()); + } + + /** + * {@inheritDoc} + */ + @Override + public int cancelPendingVoucherTasks(Long orderId) { + return baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getOrderId, orderId) + .eq(HardInvoiceEntity::getPrintType, HardPrintTypeEnum.REDEMPTION_VOUCHER.getType()) + .in(HardInvoiceEntity::getStatus, List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), + HardPrintStatusEnum.GENERATE.getType(), HardPrintStatusEnum.FAILED.getType())) + .set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, "预约核销已回退,任务取消")); + } + + /** + * {@inheritDoc} + */ + @Override + public List listPendingTasks(Long sourceHardId) { + HardPrinterBindingEntity binding = requireSourceBinding(sourceHardId); + return baseMapper.selectList(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getHardId, sourceHardId) + .eq(HardInvoiceEntity::getPrinterHardId, binding.getPrinterHardId()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .isNotNull(HardInvoiceEntity::getPic) + .orderByAsc(HardInvoiceEntity::getCreateTime) + .last("limit 100")); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean reportPrintResult(Long sourceHardId, HardPrintResultDto result) { + HardPrinterBindingEntity binding = requireSourceBinding(sourceHardId); + HardInvoiceEntity task = baseMapper.selectById(result.getTaskId()); + if (task == null || !Objects.equals(task.getOrderId(), result.getOrderId())) { + throw new RuntimeException("打印任务与订单不匹配"); + } + if (!Objects.equals(task.getHardId(), sourceHardId) + || !Objects.equals(task.getPrinterHardId(), binding.getPrinterHardId())) { + throw new RuntimeException("当前业务终端不是该打印任务的绑定代理"); + } + int currentVersion = ObjectUtil.defaultIfNull(task.getRetryVersion(), 0); + if (result.getRetryVersion() < currentVersion) return true; + if (result.getRetryVersion() > currentVersion) throw new RuntimeException("打印回执版本超前"); + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PRINT.getType())) return true; + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.CANCELLED.getType())) return false; + var update = Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getRetryVersion, currentVersion) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType()) + .set(HardInvoiceEntity::getAttemptCount, result.getAttemptCount()); + if (Boolean.TRUE.equals(result.getSuccess())) { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .set(HardInvoiceEntity::getPrintedAt, LocalDateTime.now()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null); + } else { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .set(HardInvoiceEntity::getFailureStage, HardPrintFailureStageEnum.TERMINAL_PRINT.getStage()) + .set(HardInvoiceEntity::getLastError, + truncateError(StrUtil.blankToDefault(result.getErrorMessage(), "终端打印失败"))); + } + return baseMapper.update(null, update) > 0; + } + + /** + * {@inheritDoc} + */ + @Override + public int processDueTasks() { + LocalDateTime redispatchBefore = LocalDateTime.now().minusSeconds(REDISPATCH_INTERVAL_SECONDS); + List candidates = baseMapper.selectList(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType()) + .or(pending -> pending.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .and(age -> age.isNull(HardInvoiceEntity::getUpdateTime) + .or().le(HardInvoiceEntity::getUpdateTime, redispatchBefore)))) + .orderByAsc(HardInvoiceEntity::getCreateTime).last("limit " + SCAN_LIMIT)); + int processed = 0; + for (HardInvoiceEntity task : candidates) { + if (task.getTenantId() == null) { + log.warn("打印任务缺少租户ID,taskId:{}", task.getId()); + continue; + } + try { + TenantBroker.runAs(task.getTenantId(), tenantId -> processTask(task.getId())); + processed++; + } catch (Exception exception) { + log.error("打印任务补偿处理异常,taskId:{},tenantId:{}", task.getId(), task.getTenantId(), exception); + } + } + return processed; + } + + /** + * 幂等创建某次业务动作的首个任务;非终态任务存在时直接复用。 + */ + private HardInvoiceEntity createInitialTask(HardOrderEntity order, HardPrintTypeEnum printType, + Long sourceHardId, String reason) { + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && isOpenStatus(latest.getStatus())) return latest; + if (latest != null && printType == HardPrintTypeEnum.INVOICE + && Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) return latest; + String actualReason = printType == HardPrintTypeEnum.REDEMPTION_VOUCHER && latest != null + && (Objects.equals(latest.getStatus(), HardPrintStatusEnum.CANCELLED.getType()) + || Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) ? REVERIFY_REASON : reason; + return createNewTask(order, printType, sourceHardId, actualReason); + } + + /** + * 分配下一打印序号并创建新任务。 + */ + private HardInvoiceEntity createNewTask(HardOrderEntity order, HardPrintTypeEnum printType, + Long sourceHardId, String reason) { + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + int printNo = latest == null ? 1 : ObjectUtil.defaultIfNull(latest.getPrintNo(), 0) + 1; + Long normalizedSource = sourceHardId != null && sourceHardId > 0 ? sourceHardId : null; + HardPrinterBindingEntity binding = printerService.findBySourceHardId(normalizedSource); + boolean canDispatch = binding != null; + HardInvoiceEntity task = HardInvoiceEntity.builder().deptId(resolveTaskDeptId(order)).hardId(normalizedSource) + .printerHardId(canDispatch ? binding.getPrinterHardId() : null).orderId(order.getId()) + .printType(printType.getType()).printNo(printNo).retryVersion(0).attemptCount(0) + .status(canDispatch ? HardPrintStatusEnum.PENDING_GENERATION.getType() : HardPrintStatusEnum.FAILED.getType()) + .failureStage(canDispatch ? null : HardPrintFailureStageEnum.BINDING.getStage()) + .lastError(canDispatch ? null : normalizedSource == null ? "缺少来源终端" : "来源终端未绑定尔印打印机") + .reprintReason(StrUtil.trim(reason)) + .tenantId(order.getTenantId() == null ? TenantContextHolder.getTenantId() : order.getTenantId()).build(); + try { + baseMapper.insert(task); + } catch (DuplicateKeyException exception) { + HardInvoiceEntity concurrent = findTaskByPrintNo(order.getId(), printType, printNo); + if (concurrent != null) return concurrent; + throw exception; + } + if (canDispatch) scheduleAfterCommit(task); + return task; + } + + /** + * 解析打印任务的数据权限归属部门。设备订单优先沿用下单设备部门,App 预约订单没有设备上下文时 + * 使用下单时固化的商户部门快照,避免把后续核销设备的部门误写成订单归属。 + * + * @param order 创建打印任务的订单快照 + * @return 可持久化到打印任务的部门 ID + */ + private Long resolveTaskDeptId(HardOrderEntity order) { + Long deptId = order.getDeptId() != null ? order.getDeptId() : order.getMerchantDeptId(); + if (deptId == null) throw new RuntimeException("订单缺少归属部门,无法创建打印任务"); + return deptId; + } + + /** + * 在当前事务提交后把任务投递到硬件异步线程池;无事务时立即投递。 + */ + private void scheduleAfterCommit(HardInvoiceEntity task) { + Runnable submit = () -> { + try { + hardTaskExecutor.execute(() -> { + try { + TenantBroker.runAs(task.getTenantId(), tenantId -> processTask(task.getId())); + } catch (Exception exception) { + log.error("打印任务提交后处理异常,taskId:{},tenantId:{}", task.getId(), task.getTenantId(), exception); + } + }); + } catch (Exception exception) { + log.error("打印任务异步唤醒失败,周期任务将补偿,taskId:{}", task.getId(), exception); + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务提交成功后才允许执行任何外部服务调用。 */ + @Override + public void afterCommit() { + submit.run(); + } + }); + } else { + submit.run(); + } + } + + /** + * 处理单个待生成或待打印任务。 + */ + private void processTask(Long taskId) { + HardInvoiceEntity task = baseMapper.selectById(taskId); + if (task == null || task.getPrinterHardId() == null) return; + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PENDING_GENERATION.getType())) + task = generateTaskImage(task); + if (task != null && Objects.equals(task.getStatus(), HardPrintStatusEnum.GENERATE.getType())) + dispatchTask(task); + } + + /** + * 生成并上传任务图片,失败时将原任务标记为图片生成失败。 + */ + private HardInvoiceEntity generateTaskImage(HardInvoiceEntity task) { + try { + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + String image = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType()) + ? generateVoucher(order, task.getPrintNo()) : generateInvoice(order); + if (StrUtil.isBlank(image)) throw new RuntimeException("图片上传地址为空"); + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType()) + .eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion()) + .set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .set(HardInvoiceEntity::getPic, image) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null)); + return baseMapper.selectById(task.getId()); + } catch (Exception exception) { + markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.IMAGE_GENERATION, exception); + return null; + } + } + + /** + * 向绑定来源业务终端发布 MQTT 任务,代理接受后仍保持待打印状态。 + */ + private void dispatchTask(HardInvoiceEntity task) { + try { + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + HardPrintMsg message = new HardPrintMsg(); + message.setUserId(order.getUserId()); + message.setHardId(task.getHardId()); + message.setSourceHardId(task.getHardId()); + message.setTargetHardId(task.getPrinterHardId()); + message.setMchDeptId(order.getMerchantDeptId()); + message.setHistoryId(task.getId()); + message.setOrderId(order.getId()); + message.setPrintType(normalizePrintType(task.getPrintType()).getType()); + message.setPrintNo(ObjectUtil.defaultIfNull(task.getPrintNo(), 1)); + message.setRetryVersion(ObjectUtil.defaultIfNull(task.getRetryVersion(), 0)); + message.setAndroidOrderCode(order.getAndroidOrderCode()); + message.setPic(task.getPic()); + R result = mqttEventService.sendPrint(message); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null ? "MQTT服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "MQTT服务未接受打印任务")); + } + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null)); + } catch (Exception exception) { + markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.MQTT_DISPATCH, exception); + } + } + + /** + * 生成不含核销短码的兑换券 PNG 并上传文件服务。 + * + *

用户资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按 + * 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。

+ * + * @param order 已完成核销的预约订单 + * @param printNo 当前订单打印序号 + * @return 文件服务返回的兑换券 PNG 地址 + */ + private String generateVoucher(HardOrderEntity order, Integer printNo) { + List voucherOrders = loadVoucherBatchOrders(order); + MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在"); + UserInfo user = loadVoucherUser(order.getUserId()); + ThermalReceiptImage image = new ThermalReceiptImage().title("预约核销兑换券") + .banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size())) + .keyValue("商户", merchant.getName()); + appendVoucherUser(image, user); + boolean showComboItems = shouldShowVoucherComboItems(); + List orderProducts = loadVoucherOrderProducts(voucherOrders, showComboItems); + boolean mergeMultiOrderProducts = voucherOrders.size() > 1 && shouldMergeMultiOrderVoucherProducts(); + if (mergeMultiOrderProducts) appendMergedVoucherLayout(image, orderProducts); + else appendOrderGroupedVoucherLayout(image, orderProducts); + + boolean hasProduct = orderProducts.stream().anyMatch(item -> !item.products().isEmpty()); + long totalAmount = orderProducts.stream().flatMap(item -> item.products().stream()) + .mapToLong(VoucherProductLine::subtotal).sum(); + if (!hasProduct) image.item("无可履约商品", "x0", false, false); + image.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now())); + try { + return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes()); + } catch (IOException exception) { + throw new RuntimeException("生成兑换券图片失败", exception); + } + } + + /** + * 查询当前租户是否显示兑换券套餐的组合内容。 + * + *

公共参数不存在、返回失败或远程服务异常时均按关闭处理,避免配置服务波动阻断已经完成核销的 + * 订单出票。仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启。

+ * + * @return {@code true} 显示组合内容;{@code false} 隐藏横线、标题和组成商品列表 + */ + private boolean shouldShowVoucherComboItems() { + return isPublicParamEnabled(VOUCHER_SHOW_COMBO_ITEMS_PARAM_KEY, "兑换券套餐组合内容显示"); + } + + /** + * 查询当前租户是否对多订单兑换券合并商品明细。 + * + *

该开关只由多订单生成流程调用;单订单始终保持现有版式。关闭或读取失败时,每笔订单继续展示 + * 自己的商品列表,确保上线后不改变未配置租户的打印结果。

+ * + * @return {@code true} 集中展示订单摘要并合并商品;{@code false} 按订单分别展示商品 + */ + private boolean shouldMergeMultiOrderVoucherProducts() { + return isPublicParamEnabled(VOUCHER_MERGE_MULTI_ORDER_PRODUCTS_PARAM_KEY, "多订单兑换券商品合并"); + } + + /** + * 读取布尔型租户公共参数,统一兑换券版式开关的容错语义。 + * + *

仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启;参数不存在、 + * 返回失败或远程服务异常均按关闭处理,不能因配置服务波动阻断已完成核销订单出票。

+ * + * @param paramKey 公共参数键 + * @param settingName 日志中使用的开关名称 + * @return 公共参数是否明确开启 + */ + private boolean isPublicParamEnabled(String paramKey, String settingName) { + try { + R result = remoteParamService.getByKey(paramKey); + if (result == null || !result.isOk()) { + log.warn("读取{}开关失败,按关闭处理,paramKey:{}", settingName, paramKey); + return false; + } + String value = StrUtil.trim(result.getData()); + return "true".equalsIgnoreCase(value) || "1".equals(value); + } catch (Exception exception) { + log.warn("读取{}开关异常,按关闭处理,paramKey:{}", settingName, paramKey, exception); + return false; + } + } + + /** + * 加载批量兑换券的订单商品快照,供两种票据版式复用。 + * + * @param voucherOrders 同一次核销的有序订单 + * @param showComboItems 是否加载并显示套餐组成内容 + * @return 与订单顺序一致的商品快照分组 + */ + private List loadVoucherOrderProducts(List voucherOrders, + boolean showComboItems) { + List result = new ArrayList<>(); + for (HardOrderEntity voucherOrder : voucherOrders) { + List cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId())) + .orElseGet(List::of); + Map metadata = loadProductMetadata(cars, showComboItems); + List products = new ArrayList<>(); + for (HardCarVo car : cars) { + int count = effectiveCount(car); + if (count <= 0) continue; + // 历史购物车允许商品 ID 为空,Map.of() 不接受空键查询,因此空 ID 直接按单品降级。 + ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId()); + boolean combo = product != null && product.combo(); + products.add(new VoucherProductLine(car.getProductId(), car.getProductName(), count, + voucherUnitPrice(car), voucherSubtotal(car), combo, + combo && showComboItems ? product.items() : List.of())); + } + result.add(new VoucherOrderProducts(voucherOrder, List.copyOf(products))); + } + return List.copyOf(result); + } + + /** + * 绘制原有按订单分组的兑换券版式。 + * + * @param image 当前兑换券图片绘制器 + * @param orderProducts 按订单分组的商品快照 + */ + private static void appendOrderGroupedVoucherLayout(ThermalReceiptImage image, + List orderProducts) { + for (VoucherOrderProducts group : orderProducts) { + HardOrderEntity order = group.order(); + image.divider().sectionTitle("订单 " + displayOrderNo(order)) + .keyValue("餐段", resolveMealName(order.getHourType())) + .sectionTitle("剩余可履约商品"); + appendVoucherProducts(image, group.products()); + } + } + + /** + * 绘制多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。 + * + * @param image 当前兑换券图片绘制器 + * @param orderProducts 按订单分组的商品快照 + */ + private static void appendMergedVoucherLayout(ThermalReceiptImage image, + List orderProducts) { + image.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)"); + for (int index = 0; index < orderProducts.size(); index++) { + HardOrderEntity order = orderProducts.get(index).order(); + image.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order), + "--", resolveMealName(order.getHourType())); + } + image.divider().sectionTitle("合并商品明细"); + appendVoucherProducts(image, mergeVoucherProducts(orderProducts)); + } + + /** + * 绘制一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。 + * + * @param image 当前兑换券图片绘制器 + * @param products 待绘制商品;可以是单订单明细或多订单合并结果 + */ + private static void appendVoucherProducts(ThermalReceiptImage image, List products) { + for (VoucherProductLine product : products) { + image.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()), + money(product.subtotal()), product.combo(), voucherComponentItems(product)); + } + } + + /** + * 将套餐组成快照转换为票据说明,并按套餐商品的剩余总份数放大组成数量。 + * + * @param product 当前兑换券商品 + * @return 已包含总数量且按组成排序稳定排列的说明文本 + */ + private static List voucherComponentItems(VoucherProductLine product) { + if (!product.combo() || product.comboItems().isEmpty()) return List.of(); + return product.comboItems().stream().filter(Objects::nonNull) + .sorted(Comparator.comparing(item -> + ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE))) + .map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x" + + (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * product.quantity()) + .toList(); + } + + /** + * 合并多订单中业务快照完全一致的商品,并保持商品第一次出现的顺序。 + * + *

商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并; + * 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。

+ * + * @param orderProducts 按订单分组的商品快照 + * @return 可直接绘制的有序合并商品列表 + */ + static List mergeVoucherProducts(List orderProducts) { + Map merged = new LinkedHashMap<>(); + for (VoucherOrderProducts group : orderProducts) { + for (VoucherProductLine product : group.products()) { + VoucherProductKey key = new VoucherProductKey(product.productId(), product.productName(), + product.unitPrice(), product.combo()); + VoucherProductLine current = merged.get(key); + if (current == null) { + merged.put(key, product); + continue; + } + List comboItems = current.comboItems().isEmpty() + ? product.comboItems() : current.comboItems(); + merged.put(key, new VoucherProductLine(current.productId(), current.productName(), + current.quantity() + product.quantity(), current.unitPrice(), + current.subtotal() + product.subtotal(), current.combo(), comboItems)); + } + } + return List.copyOf(merged.values()); + } + + /** + * 按首订单核销快照恢复同一次批量核销的全部订单;历史没有独立核销时间的任务退化为单订单。 + * + *

目标项目订单快照没有固化预约用餐日期(apptUseDate),批量恢复改用同一预约时间段(apptId) + * 作为分组条件,其余条件与参考实现一致。

+ * + * @param anchor 首笔打印任务锚定订单 + * @return 按订单主键稳定排序的批量订单 + */ + private List loadVoucherBatchOrders(HardOrderEntity anchor) { + if (anchor.getVerificationTime() == null) return List.of(anchor); + var query = Wrappers.lambdaQuery() + .eq(HardOrderEntity::getUserId, anchor.getUserId()) + .eq(HardOrderEntity::getMerchantId, anchor.getMerchantId()) + .eq(HardOrderEntity::getOrderType, HardOrderTypeEnum.RESERVE.getType()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CANCEL.getType()) + .eq(HardOrderEntity::getHourType, anchor.getHourType()) + .eq(HardOrderEntity::getSysType, anchor.getSysType()) + .eq(HardOrderEntity::getVerificationTime, anchor.getVerificationTime()) + .orderByAsc(HardOrderEntity::getId); + if (anchor.getHardId() == null) query.isNull(HardOrderEntity::getHardId); + else query.eq(HardOrderEntity::getHardId, anchor.getHardId()); + if (anchor.getApptId() == null) query.isNull(HardOrderEntity::getApptId); + else query.eq(HardOrderEntity::getApptId, anchor.getApptId()); + List orders = orderService.list(query); + return orders == null || orders.isEmpty() ? List.of(anchor) : orders; + } + + /** + * 查询兑换券用户的基础资料;查询失败时记录告警并返回空值降级。 + * + *

目标项目没有学生、教师身份扩展资料,仅通过内部接口读取姓名等用户基础信息。

+ * + * @param userId 订单发起用户主键 + * @return 包含姓名基础资料的用户;用户不存在或远程服务异常时返回 {@code null} + */ + private UserInfo loadVoucherUser(Long userId) { + if (userId == null) return null; + try { + R result = userService.getInnerByUserId(userId); + UserInfo user = result == null ? null : result.getData(); + if (user != null && user.getSysUser() != null) return user; + } catch (Exception exception) { + log.warn("兑换券用户资料查询异常,跳过用户信息打印,userId:{}", userId, exception); + } + log.warn("兑换券用户资料查询失败,跳过用户信息打印,userId:{}", userId); + return null; + } + + /** + * 按非空字段绘制兑换券用户资料。 + * + * @param image 当前兑换券图片绘制器 + * @param user UPMS 返回的用户基础资料;为空时不绘制用户区 + */ + private static void appendVoucherUser(ThermalReceiptImage image, UserInfo user) { + Map fields = voucherUserFields(user); + if (fields.isEmpty()) return; + image.divider().sectionTitle("用户信息"); + fields.forEach(image::keyValue); + } + + /** + * 提取兑换券允许打印的非空用户基础字段。 + * + *

目标项目没有学号、班级等身份扩展字段,仅保留姓名。

+ * + * @param user UPMS 返回的用户基础资料;为空表示没有可打印用户信息 + * @return 有序的非空用户字段 + */ + static Map voucherUserFields(UserInfo user) { + if (user == null || user.getSysUser() == null) return Map.of(); + String name = user.getSysUser().getName(); + Map fields = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(name)) fields.put("姓名", name.trim()); + return fields; + } + + /** + * 批量加载套餐类型及组成项元数据。 + * + *

目标项目食堂模块暂无套餐数据源(菜品实体没有 spuType 字段,也没有组成项查询接口), + * 统一返回空映射按普通单品降级处理,保证兑换券主流程可以正常出票;后续接入套餐数据源时 + * 再恢复按商品 ID 索引的套餐元数据装配。

+ * + * @param cars 订单中的剩余可履约商品快照 + * @param includeComboItems 是否加载套餐组成商品明细 + * @return 按商品 ID 索引的套餐类型和组成项;无套餐数据源时返回空 Map + */ + private Map loadProductMetadata(List cars, boolean includeComboItems) { + return Map.of(); + } + + /** + * 校验订单当前允许补建或重打兑换券。 + */ + private void validateVoucherOrder(HardOrderEntity order) { + boolean reservation = Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + && order.getApptId() != null; + if (!reservation || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("只有已经核销且未回退的预约订单可以打印兑换券"); + } + if (Objects.equals(order.getRefundStatus(), HardRefundStatusEnum.APPLY.getStatus())) { + throw new RuntimeException("退款中的预约订单不能打印兑换券"); + } + List cars = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of); + if (cars.stream().noneMatch(car -> effectiveCount(car) > 0)) { + throw new RuntimeException("已全额退款的预约订单不能打印兑换券"); + } + } + + /** + * 为没有可靠来源终端的后台订单选择同商户绑定来源。 + */ + private Long resolveManualSource(HardOrderEntity order, Long requestedSourceHardId) { + Long sourceHardId = requestedSourceHardId; + if (sourceHardId == null || sourceHardId <= 0) sourceHardId = order.getHardId(); + if (sourceHardId == null || sourceHardId <= 0) { + List sources = printerService.listBoundSources(order.getMerchantId()); + if (sources.isEmpty()) throw new RuntimeException("该商户没有已绑定尔印打印机的业务终端"); + if (sources.size() > 1) throw new RuntimeException("该商户存在多个打印来源,请选择业务终端"); + sourceHardId = sources.get(0).getId(); + } + HardManagerEntity source = hardManagerService.getById(sourceHardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("打印来源必须是超市或食堂终端"); + } + if (!Objects.equals(source.getMerchantId(), order.getMerchantId())) { + throw new RuntimeException("打印来源与订单不属于同一商户"); + } + return sourceHardId; + } + + /** + * 校验 DEVICE-ID 属于食堂或超市 Android 终端,并返回其当前打印机绑定。 + * + * @param sourceHardId DEVICE-ID 中的来源业务终端 ID + * @return 当前来源终端的一对一打印机绑定 + */ + private HardPrinterBindingEntity requireSourceBinding(Long sourceHardId) { + if (sourceHardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + HardManagerEntity source = hardManagerService.getById(sourceHardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("当前设备不是超市或食堂业务终端"); + } + HardPrinterBindingEntity binding = printerService.findBySourceHardId(sourceHardId); + if (binding == null) throw new RuntimeException("当前业务终端尚未绑定尔印打印机"); + return binding; + } + + /** + * 将异常写入指定任务,条件版本防止旧处理覆盖人工新重试。 + */ + private void markFailure(Long taskId, Integer retryVersion, HardPrintFailureStageEnum stage, Exception exception) { + HardInvoiceEntity failure = new HardInvoiceEntity(); + failure.setStatus(HardPrintStatusEnum.FAILED.getType()); + failure.setFailureStage(stage.getStage()); + failure.setLastError(truncateError(StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName()))); + baseMapper.update(failure, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, taskId) + .eq(HardInvoiceEntity::getRetryVersion, ObjectUtil.defaultIfNull(retryVersion, 0)) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType())); + log.error("打印任务处理失败,taskId:{},stage:{}", taskId, stage.getStage(), exception); + } + + /** + * 查询订单指定类型的最新任务。 + */ + private HardInvoiceEntity findLatestTask(Long orderId, HardPrintTypeEnum printType) { + return baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType()) + .orderByDesc(HardInvoiceEntity::getPrintNo).orderByDesc(HardInvoiceEntity::getId).last("limit 1")); + } + + /** + * 按打印序号查询并发创建结果。 + */ + private HardInvoiceEntity findTaskByPrintNo(Long orderId, HardPrintTypeEnum printType, Integer printNo) { + return baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType()) + .eq(HardInvoiceEntity::getPrintNo, printNo).last("limit 1")); + } + + /** + * 判断任务是否仍应复用而不能创建新打印序号。 + */ + private static boolean isOpenStatus(String status) { + return List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), HardPrintStatusEnum.GENERATE.getType(), + HardPrintStatusEnum.FAILED.getType()).contains(status); + } + + /** + * 兼容历史空打印类型。 + */ + private static HardPrintTypeEnum normalizePrintType(String printType) { + return Optional.ofNullable(HardPrintTypeEnum.valueOfByType(printType)).orElse(HardPrintTypeEnum.INVOICE); + } + + /** + * 计算部分退款后的剩余可履约数量。 + */ + private static int effectiveCount(HardCarVo car) { + if (car.getEffectiveProductCount() != null) return Math.max(0, car.getEffectiveProductCount()); + return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0) + - ObjectUtil.defaultIfNull(car.getRefundedProductCount(), 0)); + } + + /** + * 读取兑换券商品的订单快照单价。 + * + * @param car 订单商品快照 + * @return 商品单价,单位分;历史空值按零处理 + */ + static long voucherUnitPrice(HardCarVo car) { + return ObjectUtil.defaultIfNull(car.getProductPrice(), 0).longValue(); + } + + /** + * 按剩余可履约数量计算兑换券商品小计,避免把已完成退款商品计入金额。 + * + * @param car 已补充退款统计的订单商品快照 + * @return 剩余商品小计,单位分 + */ + static long voucherSubtotal(HardCarVo car) { + return voucherUnitPrice(car) * effectiveCount(car); + } + + /** + * 解析订单餐段显示名。 + */ + private static String resolveMealName(String hourType) { + HardHourTypeEnum type = HardHourTypeEnum.valueOfByType(hourType); + return type == null ? StrUtil.blankToDefault(hourType, "--") : type.getDescription(); + } + + /** + * 上传 PNG 并校验文件服务返回地址。 + */ + private String uploadPng(String filePrefix, byte[] bytes) { + String fileName = filePrefix + "_" + System.currentTimeMillis() + ".png"; + MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", bytes); + R result = fileService.innerUpload(multipartFile, null, null, null); + if (result == null || !result.isOk() || !(result.getData() instanceof Map uploadMap)) { + throw new RuntimeException(result == null ? "文件服务无响应" : StrUtil.blankToDefault(result.getMsg(), "文件服务上传失败")); + } + Object url = uploadMap.get("url"); + if (!(url instanceof String) || StrUtil.isBlank((String) url)) throw new RuntimeException("文件地址为空"); + return (String) url; + } + + /** + * 解析钱包类型中文名称,远程数据缺失时返回空字符串。 + */ + private String resolveWalletTypeName(Long walletId) { + if (walletId == null) return ""; + try { + R result = walletService.getWalletById(walletId); + WalletManageVo wallet = result == null ? null : result.getData(); + if (wallet == null || StrUtil.isBlank(wallet.getWalletTypeUniqueCode())) return ""; + WalletTypeEnum type = WalletTypeEnum.valueOfByCode(wallet.getWalletTypeUniqueCode()); + return type == null ? "" : type.getDesc(); + } catch (Exception exception) { + log.warn("解析钱包类型失败,walletId:{}", walletId, exception); + return ""; + } + } + + /** + * 返回订单对外展示编号,业务编号为空时使用主键。 + */ + private static String displayOrderNo(HardOrderEntity order) { + return StrUtil.blankToDefault(order.getOrderNo(), String.valueOf(order.getId())); + } + + /** + * 格式化本地日期时间。 + */ + private static String formatDateTime(LocalDateTime value) { + return value == null ? "--" : LocalDateTimeUtil.format(value, DatePattern.NORM_DATETIME_PATTERN); + } + + /** + * 计算发票显示数量或重量。 + */ + private static String productQuantity(HardCarEntity car) { + if (car.getProductCount() != null && car.getProductCount() != 0) return car.getProductCount().toString(); + if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) { + return car.getProductWeight() + "/kg"; + } + return "0"; + } + + /** + * 将分转换为两位小数金额文本。 + */ + private static String money(Long cents) { + if (cents == null) return "0.00"; + return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString(); + } + + /** + * 校验远程响应并返回数据。 + */ + private static T requireData(R result, String message) { + if (result == null || !result.isOk() || result.getData() == null) throw new RuntimeException(message); + return result.getData(); + } + + /** + * 截断错误摘要以适配数据库字段。 + */ + private static String truncateError(String error) { + return StrUtil.sub(StrUtil.blankToDefault(error, "未知错误"), 0, ERROR_MAX_LENGTH); + } + + /** + * 菜品是否为套餐及其组成项。 + */ + private record ProductMetadata(boolean combo, List items) { + } + + /** + * 一笔订单及其剩余可履约商品快照。 + * + * @param order 订单识别信息和预约信息 + * @param products 已扣除退款成功数量的商品行 + */ + record VoucherOrderProducts(HardOrderEntity order, List products) { + } + + /** + * 可直接绘制或参与多订单聚合的兑换券商品行。 + * + * @param productId 商品主键;历史快照允许为空 + * @param productName 下单时商品名称快照 + * @param quantity 剩余可履约数量 + * @param unitPrice 下单时单价快照,单位分 + * @param subtotal 当前商品行小计,单位分 + * @param combo 是否为组合商品 + * @param comboItems 套餐组成快照;显示开关关闭时为空 + */ + record VoucherProductLine(Long productId, String productName, int quantity, long unitPrice, long subtotal, + boolean combo, List comboItems) { + } + + /** + * 多订单商品合并键。 + * + * @param productId 商品主键;为空时依靠其余快照字段区分 + * @param productName 商品名称快照 + * @param unitPrice 商品单价快照 + * @param combo 是否为组合商品 + */ + private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java new file mode 100644 index 000000000..25de1cc85 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java @@ -0,0 +1,498 @@ +package com.bsd.pjcloud.hard.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; +import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.HardPrinterBindingVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper; +import com.bsd.pjcloud.hard.mapper.HardPrinterBindingMapper; +import com.bsd.pjcloud.hard.mapper.HardPrinterConfigMapper; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.service.HardPrinterService; +import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 业务终端与无屏尔印 USB 打印机的绑定和配置服务实现。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class HardPrinterServiceImpl implements HardPrinterService { + + /** 默认纸张宽度,单位毫米。 */ + private static final int DEFAULT_PAPER_WIDTH = 80; + + /** 默认 GS v 0 图片光栅指令模式。 */ + private static final String IMAGE_MODE_GS_V0 = "GS_V0"; + + /** ESC * 图片指令兼容回退模式。 */ + private static final String IMAGE_MODE_ESC_STAR = "ESC_STAR"; + + /** 开关开启编码。 */ + private static final String ENABLED = "1"; + + /** 开关关闭编码。 */ + private static final String DISABLED = "0"; + + /** 绑定关系 Mapper。 */ + private final HardPrinterBindingMapper bindingMapper; + + /** 打印机配置 Mapper。 */ + private final HardPrinterConfigMapper configMapper; + + /** 设备 Mapper。 */ + private final HardManagerMapper hardManagerMapper; + + /** 打印任务 Mapper。 */ + private final HardInvoiceMapper invoiceMapper; + + /** MQTT 配置变更通知服务,用于让业务终端立即刷新打印机绑定。 */ + private final RemoteMqttEventService mqttEventService; + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingVo getBinding(Long hardId) { + if (hardId == null) return null; + HardPrinterBindingEntity binding = bindingMapper.selectOne(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(HardPrinterBindingEntity::getSourceHardId, hardId) + .or().eq(HardPrinterBindingEntity::getPrinterHardId, hardId)) + .last("limit 1")); + return binding == null ? null : assemble(binding); + } + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingVo getSourceBinding(Long sourceHardId) { + HardManagerEntity source = requireSource(sourceHardId); + HardPrinterBindingEntity binding = findBySourceHardId(source.getId()); + return binding == null ? null : assemble(binding); + } + + /** {@inheritDoc} */ + @Override + public void enrichDevices(List devices) { + if (devices == null || devices.isEmpty()) return; + List deviceIds = devices.stream().map(HardManagerVo::getId).filter(Objects::nonNull).toList(); + if (deviceIds.isEmpty()) return; + List bindings = bindingMapper.selectList( + Wrappers.lambdaQuery() + .in(HardPrinterBindingEntity::getSourceHardId, deviceIds) + .or().in(HardPrinterBindingEntity::getPrinterHardId, deviceIds)); + List relatedIds = java.util.stream.Stream.concat(deviceIds.stream(), bindings.stream() + .flatMap(binding -> java.util.stream.Stream.of(binding.getSourceHardId(), binding.getPrinterHardId())) + .filter(Objects::nonNull)).distinct().toList(); + Map managerMap = hardManagerMapper.selectBatchIds(relatedIds).stream() + .collect(Collectors.toMap(HardManagerEntity::getId, Function.identity(), (left, right) -> left)); + List printerIds = java.util.stream.Stream.concat( + bindings.stream().map(HardPrinterBindingEntity::getPrinterHardId), + devices.stream().filter(device -> HardTypeEnum.EY_PRINT.getType().equals(device.getType())) + .map(HardManagerVo::getId)) + .filter(Objects::nonNull).distinct().toList(); + Map configMap = printerIds.isEmpty() ? Map.of() : configMapper.selectList( + Wrappers.lambdaQuery() + .in(HardPrinterConfigEntity::getPrinterHardId, printerIds)) + .stream().collect(Collectors.toMap(HardPrinterConfigEntity::getPrinterHardId, + Function.identity(), (left, right) -> left)); + Map sourceBindingMap = bindings.stream() + .collect(Collectors.toMap(HardPrinterBindingEntity::getSourceHardId, + Function.identity(), (left, right) -> left)); + Map printerBindingMap = bindings.stream() + .collect(Collectors.toMap(HardPrinterBindingEntity::getPrinterHardId, + Function.identity(), (left, right) -> left)); + for (HardManagerVo device : devices) { + HardPrinterBindingEntity sourceBinding = sourceBindingMap.get(device.getId()); + HardPrinterBindingEntity printerBinding = printerBindingMap.get(device.getId()); + if (sourceBinding != null) { + device.setBoundPrinterHardId(sourceBinding.getPrinterHardId()); + device.setBoundPrinterHardName(resolveName(managerMap.get(sourceBinding.getPrinterHardId()))); + } + if (printerBinding != null) { + device.setBoundSourceHardId(printerBinding.getSourceHardId()); + device.setBoundSourceHardName(resolveName(managerMap.get(printerBinding.getSourceHardId()))); + } + Long printerId = HardTypeEnum.EY_PRINT.getType().equals(device.getType()) + ? device.getId() : device.getBoundPrinterHardId(); + // 未绑定的普通设备没有目标打印机,不能使用空键访问 Map.of() 返回的不可变空映射。 + HardPrinterConfigEntity config = printerId == null ? null : configMap.get(printerId); + if (config != null) fillPrinterConfig(device, config); + } + } + + /** {@inheritDoc} */ + @Override + public List listAvailablePrinters(Long sourceHardId) { + HardManagerEntity source = requireSource(sourceHardId); + List boundPrinterIds = bindingMapper.selectList(Wrappers.lambdaQuery() + .select(HardPrinterBindingEntity::getPrinterHardId)) + .stream().map(HardPrinterBindingEntity::getPrinterHardId).filter(Objects::nonNull).toList(); + return hardManagerMapper.selectList(Wrappers.lambdaQuery() + .eq(HardManagerEntity::getType, HardTypeEnum.EY_PRINT.getType()) + .eq(HardManagerEntity::getMerchantId, source.getMerchantId()) + .eq(HardManagerEntity::getDeptId, source.getDeptId()) + .notIn(!boundPrinterIds.isEmpty(), HardManagerEntity::getId, boundPrinterIds) + .orderByAsc(HardManagerEntity::getName)); + } + + /** {@inheritDoc} */ + @Override + public List listBoundSources(Long merchantId) { + if (merchantId == null) throw new RuntimeException("商户不能为空"); + List sourceIds = bindingMapper.selectList(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getMerchantId, merchantId) + .select(HardPrinterBindingEntity::getSourceHardId)) + .stream().map(HardPrinterBindingEntity::getSourceHardId).filter(Objects::nonNull).toList(); + if (sourceIds.isEmpty()) return List.of(); + return hardManagerMapper.selectListByScope(Wrappers.lambdaQuery() + .in(HardManagerEntity::getId, sourceIds) + .orderByAsc(HardManagerEntity::getName), DataScope.of()); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterBindingVo bind(HardPrinterBindingDto dto) { + HardManagerEntity source = requireSource(dto.getSourceHardId()); + HardManagerEntity printer = requirePrinter(dto.getPrinterHardId()); + if (!Objects.equals(source.getTenantId(), printer.getTenantId())) { + throw new RuntimeException("来源终端和打印机资产必须属于同一租户"); + } + if (!Objects.equals(source.getMerchantId(), printer.getMerchantId())) { + throw new RuntimeException("来源终端和打印机资产必须属于同一商户"); + } + if (!Objects.equals(source.getDeptId(), printer.getDeptId())) { + throw new RuntimeException("来源终端和打印机资产必须属于同一商户部门"); + } + HardPrinterBindingEntity binding = HardPrinterBindingEntity.builder() + .sourceHardId(source.getId()) + .printerHardId(printer.getId()) + .merchantId(source.getMerchantId()) + .deptId(source.getDeptId()) + .tenantId(source.getTenantId()) + .build(); + try { + bindingMapper.insert(binding); + } catch (DuplicateKeyException exception) { + throw new RuntimeException("来源终端或打印机资产已经绑定,请先解除原绑定", exception); + } + if (configMapper.selectCount(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId())) == 0) { + configMapper.insert(defaultConfig(printer)); + } + notifyBindingChangeAfterCommit(source.getDeptId()); + return assemble(binding); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public void unbind(Long sourceHardId) { + HardPrinterBindingEntity binding = findBySourceHardId(sourceHardId); + if (binding == null) return; + if (hasPendingTask(binding.getSourceHardId(), binding.getPrinterHardId())) { + throw new RuntimeException("存在待生成、待打印或打印失败任务,处理完成后才能解绑"); + } + bindingMapper.deleteById(binding.getId()); + notifyBindingChangeAfterCommit(binding.getDeptId()); + } + + /** {@inheritDoc} */ + @Override + public HardPrinterConfigEntity getConfig(Long printerHardId) { + HardManagerEntity printer = requirePrinter(printerHardId); + HardPrinterConfigEntity config = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printerHardId) + .last("limit 1")); + return config == null ? defaultConfig(printer) : config; + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterConfigEntity saveConfig(HardPrinterConfigDto dto) { + HardManagerEntity printer = requirePrinter(dto.getPrinterHardId()); + validateConfig(dto); + HardPrinterConfigEntity existing = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId()) + .last("limit 1")); + HardPrinterConfigEntity config = BeanUtils.toBean(dto, HardPrinterConfigEntity.class); + config.setTenantId(printer.getTenantId()); + if (existing == null) { + configMapper.insert(config); + } else { + config.setId(existing.getId()); + configMapper.updateById(config); + } + HardPrinterBindingEntity binding = findByPrinterHardId(printer.getId()); + if (binding != null) notifyBindingChangeAfterCommit(binding.getDeptId()); + return configMapper.selectById(config.getId()); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterConfigEntity reportDeviceInfo(Long sourceHardId, HardPrinterInfoReportDto dto) { + HardPrinterBindingEntity binding = requireSourceBinding(sourceHardId); + HardManagerEntity printer = requirePrinter(binding.getPrinterHardId()); + HardPrinterConfigEntity config = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId()) + .last("limit 1")); + boolean create = config == null; + if (create) config = defaultConfig(printer); + config.setPrinterModel(StrUtil.trim(dto.getPrinterModel())); + config.setUsbVendorId(dto.getUsbVendorId()); + config.setUsbProductId(dto.getUsbProductId()); + if (create) { + configMapper.insert(config); + } else { + configMapper.updateById(config); + } + return config; + } + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingEntity findBySourceHardId(Long sourceHardId) { + if (sourceHardId == null || sourceHardId <= 0) return null; + return bindingMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getSourceHardId, sourceHardId) + .last("limit 1")); + } + + /** {@inheritDoc} */ + @Override + public void validateDeviceChange(HardManagerEntity before, HardManagerEntity after) { + if (before == null || after == null || after.getId() == null) return; + boolean changesBindingIdentity = !Objects.equals(before.getType(), after.getType()) + || !Objects.equals(before.getMerchantId(), after.getMerchantId()) + || !Objects.equals(before.getDeptId(), after.getDeptId()); + if (changesBindingIdentity && getBinding(before.getId()) != null) { + throw new RuntimeException("已绑定打印关系的设备不能直接修改类型、商户或部门,请先处理任务并解绑"); + } + } + + /** {@inheritDoc} */ + @Override + public void validateDeviceRemoval(Long hardId) { + HardPrinterBindingVo binding = getBinding(hardId); + if (binding != null) { + throw new RuntimeException("已绑定打印关系的设备不能删除,请先处理任务并解绑"); + } + if (hasPendingTask(hardId, hardId)) { + throw new RuntimeException("设备存在待生成、待打印或打印失败任务,不能删除"); + } + } + + /** + * 校验并返回来源业务终端。 + * + * @param hardId 来源设备 ID + * @return 有效来源设备 + */ + private HardManagerEntity requireSource(Long hardId) { + HardManagerEntity source = hardManagerMapper.selectById(hardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("来源终端必须是超市或食堂设备"); + } + if (source.getMerchantId() == null) throw new RuntimeException("来源终端未绑定商户"); + return source; + } + + /** + * 校验并返回 EY_PRINT 逻辑打印机资产。 + * + * @param hardId 打印设备 ID + * @return 有效打印机资产 + */ + private HardManagerEntity requirePrinter(Long hardId) { + HardManagerEntity printer = hardManagerMapper.selectById(hardId); + HardTypeEnum type = printer == null ? null : HardTypeEnum.valueOfByType(printer.getType()); + if (printer == null || !HardTypeEnum.isPrintHard(type)) { + throw new RuntimeException("目标设备必须是尔印打印机"); + } + if (printer.getMerchantId() == null) throw new RuntimeException("打印机未绑定商户"); + return printer; + } + + /** + * 校验当前 Android 来源终端并返回其一对一打印机绑定。 + * + * @param sourceHardId DEVICE-ID 中的来源业务终端 ID + * @return 当前来源终端的有效绑定 + */ + private HardPrinterBindingEntity requireSourceBinding(Long sourceHardId) { + HardManagerEntity source = requireSource(sourceHardId); + HardPrinterBindingEntity binding = findBySourceHardId(source.getId()); + if (binding == null) throw new RuntimeException("当前业务终端尚未绑定尔印打印机"); + return binding; + } + + /** + * 查询逻辑打印机资产当前绑定的来源业务终端。 + * + * @param printerHardId EY_PRINT 逻辑打印机资产 ID + * @return 当前一对一绑定;未绑定时为空 + */ + private HardPrinterBindingEntity findByPrinterHardId(Long printerHardId) { + if (printerHardId == null || printerHardId <= 0) return null; + return bindingMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getPrinterHardId, printerHardId) + .last("limit 1")); + } + + /** + * 在本地事务提交后通知业务终端刷新绑定,MQTT 暂时不可用时不回滚已保存的数据。 + * + * @param deptId 来源业务终端所属部门 ID;为空时跳过通知 + */ + private void notifyBindingChangeAfterCommit(Long deptId) { + if (deptId == null) return; + Runnable notifyAction = () -> { + try { + mqttEventService.sendConfigChangeMsg(deptId); + } catch (Exception exception) { + log.warn("打印机绑定配置已保存,但MQTT刷新通知发送失败,deptId:{}", deptId, exception); + } + }; + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + notifyAction.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 本地绑定事务提交后再通知,避免终端读取到尚未提交的旧关系。 */ + @Override + public void afterCommit() { + notifyAction.run(); + } + }); + } + + /** + * 校验受首期协议约束的打印参数。 + * + * @param dto 待保存配置 + */ + private void validateConfig(HardPrinterConfigDto dto) { + if (!Objects.equals(dto.getPaperWidth(), DEFAULT_PAPER_WIDTH)) { + throw new RuntimeException("首期打印纸宽只支持80mm"); + } + if (!ENABLED.equals(dto.getAutoCut()) && !DISABLED.equals(dto.getAutoCut())) { + throw new RuntimeException("自动切刀开关只能为0或1"); + } + if (!IMAGE_MODE_GS_V0.equals(dto.getImageMode()) && !IMAGE_MODE_ESC_STAR.equals(dto.getImageMode())) { + throw new RuntimeException("图片指令模式只支持GS_V0或ESC_STAR"); + } + if ((dto.getUsbVendorId() == null) != (dto.getUsbProductId() == null)) { + throw new RuntimeException("USB VID和PID必须同时填写或同时留空"); + } + if (dto.getUsbVendorId() != null && (dto.getUsbVendorId() < 0 || dto.getUsbVendorId() > 65535)) { + throw new RuntimeException("USB VID必须在0到65535之间"); + } + if (dto.getUsbProductId() != null && (dto.getUsbProductId() < 0 || dto.getUsbProductId() > 65535)) { + throw new RuntimeException("USB PID必须在0到65535之间"); + } + dto.setPrinterModel(StrUtil.trim(dto.getPrinterModel())); + } + + /** + * 判断来源或目标设备是否仍有关联的非终态任务。 + * + * @param sourceHardId 来源设备 ID + * @param printerHardId 目标设备 ID + * @return 存在待生成、待打印或失败任务时返回 {@code true} + */ + private boolean hasPendingTask(Long sourceHardId, Long printerHardId) { + List pendingStatuses = List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), + HardPrintStatusEnum.GENERATE.getType(), HardPrintStatusEnum.FAILED.getType()); + return invoiceMapper.selectCount(Wrappers.lambdaQuery() + .in(HardInvoiceEntity::getStatus, pendingStatuses) + .and(wrapper -> wrapper.eq(HardInvoiceEntity::getHardId, sourceHardId) + .or().eq(HardInvoiceEntity::getPrinterHardId, printerHardId))) > 0; + } + + /** + * 读取关联设备名称,不存在时返回空值以便前端展示未配置状态。 + * + * @param manager 关联设备 + * @return 设备名称或空值 + */ + private static String resolveName(HardManagerEntity manager) { + return manager == null ? null : manager.getName(); + } + + /** + * 把打印参数投影到设备分页行。 + * + * @param target 设备分页行 + * @param config 当前打印配置 + */ + private static void fillPrinterConfig(HardManagerVo target, HardPrinterConfigEntity config) { + target.setPrinterModel(config.getPrinterModel()); + target.setUsbVendorId(config.getUsbVendorId()); + target.setUsbProductId(config.getUsbProductId()); + target.setPaperWidth(config.getPaperWidth()); + target.setAutoCut(config.getAutoCut()); + target.setImageMode(config.getImageMode()); + } + + /** + * 创建尚未持久化的首期默认打印配置。 + * + * @param printer 逻辑打印机资产 + * @return 默认配置 + */ + private HardPrinterConfigEntity defaultConfig(HardManagerEntity printer) { + return HardPrinterConfigEntity.builder() + .printerHardId(printer.getId()) + .paperWidth(DEFAULT_PAPER_WIDTH) + .autoCut(ENABLED) + .imageMode(IMAGE_MODE_GS_V0) + .tenantId(printer.getTenantId()) + .build(); + } + + /** + * 装配绑定双方名称和打印机配置。 + * + * @param binding 绑定实体 + * @return 管理端展示对象 + */ + private HardPrinterBindingVo assemble(HardPrinterBindingEntity binding) { + HardPrinterBindingVo result = BeanUtils.toBean(binding, HardPrinterBindingVo.class); + HardManagerEntity source = hardManagerMapper.selectById(binding.getSourceHardId()); + HardManagerEntity printer = hardManagerMapper.selectById(binding.getPrinterHardId()); + if (source != null) { + result.setSourceHardName(source.getName()); + result.setSourceHardType(source.getType()); + } + if (printer != null) { + result.setPrinterHardName(printer.getName()); + result.setPrinterConfig(getConfig(printer.getId())); + } + return result; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java new file mode 100644 index 000000000..a8cd752fc --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java @@ -0,0 +1,125 @@ +package com.bsd.pjcloud.hard.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; +import com.bsd.pjcloud.hard.service.HardInvoiceService; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardTerminalPrintService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 通过 DEVICE-ID 限定服务端打印凭证读取与兑换券重打范围的实现。 + */ +@Service +@RequiredArgsConstructor +public class HardTerminalPrintServiceImpl implements HardTerminalPrintService { + + /** 单个订单返回给终端的打印记录上限,防止异常历史数据形成无界响应。 */ + private static final int RECORD_LIMIT = 100; + + /** 服务端打印任务服务,继续复用原有序号分配、状态校验和异步下发能力。 */ + private final HardInvoiceService hardInvoiceService; + + /** 设备服务,用于校验 DEVICE-ID 类型并补充来源终端和目标打印机名称。 */ + private final HardManagerService hardManagerService; + + /** {@inheritDoc} */ + @Override + public List listRecords(Long sourceHardId, Long orderId) { + requireSourceTerminal(sourceHardId); + if (orderId == null) throw new RuntimeException("订单ID不能为空"); + List records = hardInvoiceService.list( + Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getHardId, sourceHardId) + .eq(HardInvoiceEntity::getOrderId, orderId) + .orderByDesc(HardInvoiceEntity::getCreateTime) + .orderByDesc(HardInvoiceEntity::getId) + .last("limit " + RECORD_LIMIT)); + Map hardNames = loadHardNames(records); + return records.stream().map(record -> toTerminalRecord(record, hardNames)).toList(); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity reprintVoucher(Long sourceHardId, Long historyId, String reason) { + requireSourceTerminal(sourceHardId); + if (historyId == null) throw new RuntimeException("打印任务ID不能为空"); + if (StrUtil.isBlank(reason)) throw new RuntimeException("人工重打必须填写原因"); + HardInvoiceEntity history = hardInvoiceService.getById(historyId); + if (history == null) throw new RuntimeException("打印任务不存在"); + if (!Objects.equals(history.getHardId(), sourceHardId)) { + throw new RuntimeException("只能重打当前终端产生的兑换券"); + } + if (!Objects.equals(history.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())) { + throw new RuntimeException("当前任务不是核销兑换券"); + } + return hardInvoiceService.reprintTask(historyId, reason.trim()); + } + + /** + * 校验请求头设备存在且属于食堂或超市业务终端。 + * + * @param sourceHardId DEVICE-ID 请求头中的设备 ID + * @return 已通过终端类型校验的设备 + */ + private HardManagerEntity requireSourceTerminal(Long sourceHardId) { + if (sourceHardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + HardManagerEntity source = hardManagerService.getById(sourceHardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("当前设备不是超市或食堂业务终端"); + } + return source; + } + + /** + * 批量读取记录中来源终端和目标打印机名称,已删除的历史设备保留空名称。 + * + * @param records 当前订单的服务端打印记录 + * @return 设备 ID 到当前名称的映射 + */ + private Map loadHardNames(List records) { + Set hardIds = new LinkedHashSet<>(); + for (HardInvoiceEntity record : records) { + if (record.getHardId() != null) hardIds.add(record.getHardId()); + if (record.getPrinterHardId() != null) hardIds.add(record.getPrinterHardId()); + } + if (hardIds.isEmpty()) return Map.of(); + Map names = new LinkedHashMap<>(); + for (HardManagerEntity hard : hardManagerService.listByIds(hardIds)) { + if (hard != null && hard.getId() != null) names.put(hard.getId(), hard.getName()); + } + return names; + } + + /** + * 把持久化任务转换为终端只读视图,并补充 PC 详情页同口径的设备名称。 + * + * @param record 服务端打印任务 + * @param hardNames 已批量读取的设备名称 + * @return 不含额外用户资料的终端打印记录 + */ + private HardInvoiceVo toTerminalRecord(HardInvoiceEntity record, Map hardNames) { + HardInvoiceVo result = BeanUtils.toBean(record, HardInvoiceVo.class); + result.setHistoryId(record.getId()); + result.setHardName(hardNames.get(record.getHardId())); + result.setPrinterHardName(hardNames.get(record.getPrinterHardId())); + return result; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java index 3100fb105..011de91a6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.service.impl.manager; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; @@ -16,6 +17,7 @@ import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.dto.HardGroupDto; import com.bsd.pjcloud.hard.api.dto.manager.HardManagerDto; import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; @@ -28,6 +30,8 @@ import com.bsd.pjcloud.hard.mapper.HardGroupMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.HardDoorManagerService; import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardPrinterService; +import com.bsd.pjcloud.hard.service.HardTerminalStatusService; import com.bsd.pjcloud.hard.mqtt.config.MqttConfig; import com.bsd.pjcloud.hard.utils.MqTopicUtil; import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; @@ -64,6 +68,14 @@ public class HardManagerServiceImpl extends ServiceImpl pageJoin(Page page, HardManagerDto dto) { Page list = baseMapper.pageJoin(page, dto); @@ -118,6 +130,8 @@ public class HardManagerServiceImpl extends ServiceImpl ids) { + if (ids == null || ids.isEmpty()) return false; + ids.forEach(hardPrinterService::validateDeviceRemoval); + return removeBatchByIds(ids); } @Override @@ -196,6 +231,9 @@ public class HardManagerServiceImpl extends ServiceImpl records = List.of(vo); + if (ObjectUtil.isNotEmpty(hard.getDeptId())) { + R> deptResponse = deptService.getDeptByDeptIds(List.of(hard.getDeptId())); + if (ObjectUtil.isNotEmpty(deptResponse.getData())) { + DataUtil.convertDept(vo, deptResponse.getData().get(hard.getDeptId())); + } + } + MchManageVo mch = mchService.getById(hard.getMerchantId()).getData(); + if (ObjectUtil.isNotEmpty(mch)) { + WalletDataUtil.convertMch(vo, mch); + vo.setMerchantDeptId(mch.getDeptId()); + } + if (ObjectUtil.isNotEmpty(hard.getHardGroupId())) { + HardUtil.convertHardGroup(vo, hardGroupMapper.selectById(hard.getHardGroupId())); + } + // 单设备详情与分页列表保持同一运行语义,避免刷新详情页后丢失绑定和心跳状态。 + hardPrinterService.enrichDevices(records); + hardTerminalStatusService.enrich(records); + return vo; + } + + @Override + public HardManagerVo getByUniqueCode(String uniqueCode) { + HardManagerEntity hard = baseMapper.selectOne(new LambdaQueryWrapper().eq(HardManagerEntity::getUniqueCode, uniqueCode)); + if (ObjectUtil.isEmpty(hard)) { + return null; + } HardManagerVo vo = BeanUtils.toBean(hard, HardManagerVo.class); MchManageVo mch = mchService.getById(hard.getMerchantId()).getData(); if (ObjectUtil.isNotEmpty(mch)) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalLogFileServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalLogFileServiceImpl.java new file mode 100644 index 000000000..1153719ca --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalLogFileServiceImpl.java @@ -0,0 +1,461 @@ +package com.bsd.pjcloud.hard.service.impl.manager; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.baomidou.lock.LockInfo; +import com.baomidou.lock.LockTemplate; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogFileQueryDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogPreflightDto; +import com.bsd.pjcloud.hard.api.dto.manager.HardTerminalLogUploadDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalLogFileEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogDeviceVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogFileTreeVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreflightVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogPreviewVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalLogUploadResultVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.manager.HardTerminalLogFileMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardTerminalLogFileService; +import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; +import com.bsd.pjcloud.mqtt.constant.MqttActionEnum; +import com.bsd.pjcloud.mqtt.dto.MqttBaseDto; +import feign.Response; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 终端日志文件服务。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardTerminalLogFileServiceImpl extends ServiceImpl implements HardTerminalLogFileService { + + private static final String TAG = "HardTerminalLogFileService"; + private static final int PREVIEW_MAX_BYTES = 1024 * 1024; + private static final int PREVIEW_MAX_LINES = 5000; + private static final long LOCK_EXPIRE_MILLIS = 180_000L; + private static final long LOCK_WAIT_MILLIS = 5_000L; + + private final HardManagerMapper hardManagerMapper; + private final HardManagerService hardManagerService; + private final RemoteFileService remoteFileService; + private final RemoteMqttEventService remoteMqttEventService; + private final LockTemplate lockTemplate; + + @Override + public HardTerminalLogPreflightVo preflight(Long hardId, HardTerminalLogPreflightDto dto) { + requireTerminal(hardId); + String relativePath = normalizeRelativePath(dto.getRelativePath()); + HardTerminalLogFileEntity existing = findByPath(hardId, relativePath); + boolean unchanged = existing != null + && ObjectUtil.equals(existing.getFileSize(), dto.getFileSize()) + && StrUtil.equalsIgnoreCase(existing.getFileHash(), dto.getFileHash()); + return HardTerminalLogPreflightVo.builder() + .id(existing == null ? null : existing.getId()) + .needUpload(!unchanged) + .normalizedPath(relativePath) + .message(unchanged ? "日志未变化,无需重复上传" : "日志需要上传") + .build(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public HardTerminalLogUploadResultVo upload(Long hardId, HardTerminalLogUploadDto dto, MultipartFile file) { + HardManagerEntity hard = requireTerminal(hardId); + String relativePath = normalizeRelativePath(dto.getRelativePath()); + validateUploadFile(file); + + String fileHash = calculateMd5(file); + long fileSize = file.getSize(); + String lockKey = String.format("hard:terminal-log:%s:%s:%s", TenantContextHolder.getTenantId(), hardId, + SecureUtil.md5(relativePath)); + LockInfo lock = lockTemplate.lock(lockKey, LOCK_EXPIRE_MILLIS, LOCK_WAIT_MILLIS); + if (lock == null) { + throw new RuntimeException("该日志正在上传,请稍后重试"); + } + + try { + HardTerminalLogFileEntity existing = findByPath(hardId, relativePath); + if (existing != null && ObjectUtil.equals(existing.getFileSize(), fileSize) + && StrUtil.equalsIgnoreCase(existing.getFileHash(), fileHash)) { + return uploadResult(existing, false, false, "日志未变化,无需重复上传"); + } + + Map storage = uploadToFileCenter(file); + LocalDateTime now = LocalDateTime.now(); + boolean changed = existing != null; + HardTerminalLogFileEntity entity = existing == null ? new HardTerminalLogFileEntity() : existing; + fillTerminalSnapshot(entity, hard); + entity.setLogDate(dto.getLogDate()); + entity.setRelativePath(relativePath); + entity.setOriginalName(fileName(relativePath)); + entity.setFileSize(fileSize); + entity.setFileHash(fileHash); + entity.setFileModifiedTime(dto.getFileModifiedTime()); + entity.setSysFileId(numberValue(storage.get("id"))); + entity.setStorageFileName(stringValue(storage.get("fileName"), "文件中心未返回存储文件名")); + entity.setStorageUrl(stringValue(storage.get("url"), "文件中心未返回文件地址")); + entity.setAppVersion(dto.getAppVersion()); + entity.setSanitizeVersion(dto.getSanitizeVersion()); + entity.setUploadTime(now); + entity.setUploadCount(existing == null ? 1 : Math.max(0, existing.getUploadCount() == null ? 0 : existing.getUploadCount()) + 1); + entity.setTenantId(TenantContextHolder.getTenantId()); + if (existing == null) { + save(entity); + } else { + updateById(entity); + } + return uploadResult(entity, true, changed, changed ? "日志已更新" : "日志上传成功"); + } finally { + if (!lockTemplate.releaseLock(lock)) { + log.warn("|{}|终端日志锁释放失败,lockKey:|{}|", TAG, lockKey); + } + } + } + + @Override + public Page scopePage(Page page, HardTerminalLogFileQueryDto dto) { + return baseMapper.scopePage(page, dto); + } + + @Override + public Page devicePage(Page page, HardTerminalLogFileQueryDto dto) { + HardTerminalLogFileQueryDto query = dto == null ? new HardTerminalLogFileQueryDto() : dto; + List scopedGroups = baseMapper.deviceGroups(query, DataScope.of()); + Map deviceMap = new LinkedHashMap<>(); + // SQL 先按部门和创建人分组,让数据权限拦截器过滤授权组;此处再合并同一设备,避免加载全部日志明细。 + for (HardTerminalLogDeviceVo group : scopedGroups) { + HardTerminalLogDeviceVo device = deviceMap.get(group.getHardId()); + if (device == null) { + group.setFileCount(group.getFileCount() == null ? 0L : group.getFileCount()); + deviceMap.put(group.getHardId(), group); + continue; + } + device.setFileCount(device.getFileCount() + (group.getFileCount() == null ? 0L : group.getFileCount())); + if (device.getLatestUploadTime() == null || group.getLatestUploadTime() != null + && group.getLatestUploadTime().isAfter(device.getLatestUploadTime())) { + device.setHardName(group.getHardName()); + device.setUniqueCode(group.getUniqueCode()); + device.setHardType(group.getHardType()); + device.setMerchantId(group.getMerchantId()); + device.setDeptId(group.getDeptId()); + device.setLatestUploadTime(group.getLatestUploadTime()); + } + } + + List devices = new ArrayList<>(deviceMap.values()); + devices.sort(Comparator.comparing(HardTerminalLogDeviceVo::getLatestUploadTime, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HardTerminalLogDeviceVo::getHardId, Comparator.nullsLast(Comparator.reverseOrder()))); + page.setTotal(devices.size()); + long size = page.getSize(); + if (size <= 0) { + page.setRecords(devices); + return page; + } + long offset = Math.max(0, page.getCurrent() - 1) * size; + int fromIndex = (int) Math.min(offset, devices.size()); + int toIndex = (int) Math.min(offset + size, devices.size()); + page.setRecords(devices.subList(fromIndex, toIndex)); + return page; + } + + @Override + public List fileTree(Long hardId, HardTerminalLogFileQueryDto dto) { + HardTerminalLogFileQueryDto query = dto == null ? new HardTerminalLogFileQueryDto() : dto; + query.setHardId(hardId); + List files = baseMapper.scopeList(query); + List root = new ArrayList<>(); + for (HardTerminalLogFileEntity file : files) { + appendTreeNode(root, file); + } + return root; + } + + @Override + public HardTerminalLogPreviewVo preview(Long id) { + HardTerminalLogFileEntity file = requireScopedFile(id); + try (Response response = remoteFileService.getFile(file.getStorageFileName())) { + requireFileResponse(response); + TailBytes tail = readTail(response.body().asInputStream(), PREVIEW_MAX_BYTES); + String content = new String(tail.bytes(), StandardCharsets.UTF_8); + if (tail.truncated()) { + int lineBreak = content.indexOf('\n'); + content = lineBreak >= 0 ? content.substring(lineBreak + 1) : ""; + } + List lines = new ArrayList<>(Arrays.asList(content.split("\\R", -1))); + boolean truncated = tail.truncated(); + if (lines.size() > PREVIEW_MAX_LINES) { + lines = lines.subList(lines.size() - PREVIEW_MAX_LINES, lines.size()); + truncated = true; + } + if (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) { + lines = lines.subList(0, lines.size() - 1); + } + return HardTerminalLogPreviewVo.builder() + .id(file.getId()) + .originalName(file.getOriginalName()) + .relativePath(file.getRelativePath()) + .content(String.join("\n", lines)) + .lineCount(lines.size()) + .fileSize(file.getFileSize()) + .truncated(truncated) + .build(); + } catch (IOException exception) { + throw new RuntimeException("读取日志文件失败", exception); + } + } + + @Override + public void download(Long id, HttpServletResponse servletResponse) { + HardTerminalLogFileEntity file = requireScopedFile(id); + try (Response response = remoteFileService.getFile(file.getStorageFileName())) { + requireFileResponse(response); + servletResponse.setContentType("text/plain; charset=UTF-8"); + servletResponse.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + + URLEncoder.encode(file.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20")); + if (file.getFileSize() != null) { + servletResponse.setContentLengthLong(file.getFileSize()); + } + IoUtil.copy(response.body().asInputStream(), servletResponse.getOutputStream()); + } catch (IOException exception) { + throw new RuntimeException("下载日志文件失败", exception); + } + } + + @Override + public Boolean pull(Long hardId) { + HardManagerEntity scopedHard = requireScopedHard(hardId); + HardManagerVo hard = hardManagerService.getByHardId(scopedHard.getId()); + if (hard == null || hard.getHardGroupId() == null || hard.getMerchantDeptId() == null) { + throw new RuntimeException("设备消息主题配置不完整,无法下发日志拉取指令"); + } + MqttBaseDto dto = new MqttBaseDto<>(); + dto.setHardGroupId(hard.getHardGroupId()); + dto.setMerchantDeptId(hard.getMerchantDeptId()); + dto.setHardwareId(hard.getId()); + dto.setAction(MqttActionEnum.PULL_TERMINAL_LOG); + R result = remoteMqttEventService.sendDefaultMsg(dto); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null || StrUtil.isBlank(result.getMsg()) ? "日志拉取指令下发失败" : result.getMsg()); + } + return true; + } + + private HardManagerEntity requireTerminal(Long hardId) { + if (hardId == null) { + throw new RuntimeException("DEVICE-ID不能为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) { + throw new RuntimeException("当前租户下设备不存在"); + } + return hard; + } + + private HardManagerEntity requireScopedHard(Long hardId) { + List list = hardManagerMapper.selectListByScope( + Wrappers.lambdaQuery().eq(HardManagerEntity::getId, hardId), DataScope.of()); + if (list.isEmpty()) { + throw new RuntimeException("设备不存在或无数据权限"); + } + return list.get(0); + } + + private HardTerminalLogFileEntity requireScopedFile(Long id) { + List files = baseMapper.selectListByScope( + Wrappers.lambdaQuery().eq(HardTerminalLogFileEntity::getId, id), DataScope.of()); + if (files.isEmpty()) { + throw new RuntimeException("日志文件不存在或无数据权限"); + } + return files.get(0); + } + + private HardTerminalLogFileEntity findByPath(Long hardId, String relativePath) { + return getOne(Wrappers.lambdaQuery() + .eq(HardTerminalLogFileEntity::getHardId, hardId) + .eq(HardTerminalLogFileEntity::getRelativePath, relativePath), false); + } + + private String normalizeRelativePath(String source) { + String path = StrUtil.trim(source).replace('\\', '/'); + if (StrUtil.isBlank(path) || path.startsWith("/") || path.indexOf('\0') >= 0 || path.matches("^[A-Za-z]:.*")) { + throw new RuntimeException("日志相对路径不合法"); + } + List segments = Arrays.stream(path.split("/")) + .filter(StrUtil::isNotBlank) + .toList(); + if (segments.isEmpty() || segments.stream().anyMatch(segment -> ".".equals(segment) || "..".equals(segment))) { + throw new RuntimeException("日志相对路径不合法"); + } + String normalized = String.join("/", segments); + if (normalized.length() > 255 || !normalized.toLowerCase().endsWith(".log") || fileName(normalized).length() > 128) { + throw new RuntimeException("仅支持名称长度合规的.log日志文件"); + } + return normalized; + } + + private void validateUploadFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new RuntimeException("上传日志文件不能为空"); + } + String originalName = file.getOriginalFilename(); + if (StrUtil.isBlank(originalName) || !originalName.toLowerCase().endsWith(".log")) { + throw new RuntimeException("仅支持上传.log日志文件"); + } + } + + private String calculateMd5(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + return SecureUtil.md5(inputStream); + } catch (IOException exception) { + throw new RuntimeException("计算日志文件哈希失败", exception); + } + } + + @SuppressWarnings("unchecked") + private Map uploadToFileCenter(MultipartFile file) { + R result = remoteFileService.innerUpload(file, "terminal-log", null, "terminal-log"); + if (result == null || !result.isOk() || !(result.getData() instanceof Map map)) { + throw new RuntimeException(result == null || StrUtil.isBlank(result.getMsg()) ? "文件中心上传失败" : result.getMsg()); + } + return (Map) map; + } + + private void fillTerminalSnapshot(HardTerminalLogFileEntity entity, HardManagerEntity hard) { + entity.setHardId(hard.getId()); + entity.setHardName(hard.getName()); + entity.setUniqueCode(hard.getUniqueCode()); + entity.setHardType(hard.getType()); + entity.setMerchantId(hard.getMerchantId()); + entity.setDeptId(hard.getDeptId()); + } + + private HardTerminalLogUploadResultVo uploadResult(HardTerminalLogFileEntity entity, boolean uploaded, boolean changed, + String message) { + return HardTerminalLogUploadResultVo.builder() + .id(entity.getId()) + .uploaded(uploaded) + .changed(changed) + .relativePath(entity.getRelativePath()) + .uploadCount(entity.getUploadCount()) + .message(message) + .build(); + } + + private void appendTreeNode(List root, HardTerminalLogFileEntity file) { + String[] segments = file.getRelativePath().split("/"); + List level = root; + StringBuilder currentPath = new StringBuilder(); + for (int index = 0; index < segments.length; index++) { + String segment = segments[index]; + if (currentPath.length() > 0) { + currentPath.append('/'); + } + currentPath.append(segment); + boolean leaf = index == segments.length - 1; + String nodeKey = currentPath.toString(); + HardTerminalLogFileTreeVo node = level.stream() + .filter(child -> StrUtil.equals(child.getLabel(), segment)) + .findFirst() + .orElse(null); + if (node == null) { + HardTerminalLogFileTreeVo value = new HardTerminalLogFileTreeVo(); + value.setKey(nodeKey); + value.setLabel(segment); + value.setRelativePath(nodeKey); + value.setDirectory(!leaf); + level.add(value); + node = value; + } + if (leaf) { + node.setFileId(file.getId()); + node.setFileSize(file.getFileSize()); + node.setLogDate(file.getLogDate()); + node.setUploadTime(file.getUploadTime()); + } else { + level = node.getChildren(); + } + } + } + + private void requireFileResponse(Response response) { + if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { + throw new RuntimeException("文件中心读取日志失败"); + } + } + + private TailBytes readTail(InputStream inputStream, int maxBytes) throws IOException { + byte[] ring = new byte[maxBytes]; + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + for (int index = 0; index < read; index++) { + ring[(int) (total % maxBytes)] = buffer[index]; + total++; + } + } + int length = (int) Math.min(total, maxBytes); + ByteArrayOutputStream output = new ByteArrayOutputStream(length); + long start = Math.max(0, total - maxBytes); + for (long index = start; index < total; index++) { + output.write(ring[(int) (index % maxBytes)]); + } + return new TailBytes(output.toByteArray(), total > maxBytes); + } + + private String fileName(String path) { + return path.substring(path.lastIndexOf('/') + 1); + } + + private String stringValue(Object value, String message) { + if (value == null || StrUtil.isBlank(value.toString())) { + throw new RuntimeException(message); + } + return value.toString(); + } + + private Long numberValue(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value != null && StrUtil.isNotBlank(value.toString())) { + return Long.valueOf(value.toString()); + } + throw new RuntimeException("文件中心未返回文件记录ID"); + } + + private record TailBytes(byte[] bytes, boolean truncated) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java new file mode 100644 index 000000000..2792a60d0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java @@ -0,0 +1,159 @@ +package com.bsd.pjcloud.hard.service.impl.manager; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.TerminalHeartbeatSceneEnum; +import com.bsd.pjcloud.hard.api.dto.hard.TerminalHeartbeatDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardTerminalStatusEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.manager.HardTerminalStatusMapper; +import com.bsd.pjcloud.hard.service.HardTerminalStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 食堂、超市 Android 终端心跳状态服务实现。 + */ +@Service +@RequiredArgsConstructor +public class HardTerminalStatusServiceImpl extends ServiceImpl implements HardTerminalStatusService { + + /** + * 终端连续 180 秒未上报后判定离线;恰好位于边界时间仍视为在线。 + */ + private static final long ONLINE_TIMEOUT_SECONDS = 180L; + + /** + * 从未上报心跳时返回给管理端的原因。 + */ + private static final String NEVER_ONLINE_REASON = "尚未收到终端心跳"; + + /** + * 非计划更新场景下超时离线的推断原因。 + */ + private static final String GENERIC_DISCONNECT_REASON = "可能断电、断网或应用异常退出"; + + /** + * 自动更新安装前持久化的计划内断开原因。 + */ + private static final String AUTO_UPDATE_REASON = "自动更新重启"; + + /** + * 用于在心跳入口校验设备归属与设备类型的设备 Mapper。 + */ + private final HardManagerMapper hardManagerMapper; + + /** + * 写入终端最新心跳。服务端时间是在线判定的唯一时间源,避免终端时钟偏差影响结果。 + * + * @param hardId 请求头中的设备 ID + * @param dto APK 版本与心跳场景 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void heartbeat(Long hardId, TerminalHeartbeatDto dto) { + if (hardId == null) { + throw new RuntimeException("设备ID不能为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) { + throw new RuntimeException("设备不存在或不属于当前租户"); + } + if (!isSupported(hard.getType())) { + throw new RuntimeException("当前设备类型暂不支持终端心跳"); + } + + LocalDateTime now = LocalDateTime.now(); + HardTerminalStatusEntity status = new HardTerminalStatusEntity(); + status.setHardId(hardId); + status.setLastOnlineTime(now); + status.setHeartbeatScene(dto.getScene().name()); + status.setDisconnectReason(dto.getScene() == TerminalHeartbeatSceneEnum.AUTO_UPDATE ? AUTO_UPDATE_REASON : null); + status.setApkVersionName(dto.getApkVersionName()); + status.setApkVersionCode(dto.getApkVersionCode()); + status.setSystemUptimeSeconds(dto.getSystemUptimeSeconds()); + status.setAppUptimeSeconds(dto.getAppUptimeSeconds()); + status.setNetworkType(dto.getNetworkType()); + status.setAvailableStorageBytes(dto.getAvailableStorageBytes()); + status.setRootStatus(dto.getRootStatus()); + status.setDeviceOwner(dto.getDeviceOwner()); + status.setKioskActive(dto.getKioskActive()); + status.setDeviceStatusJson(dto.getDeviceStatus() == null ? null : JSONUtil.toJsonStr(dto.getDeviceStatus())); + status.setCreateTime(now); + status.setUpdateTime(now); + status.setTenantId(hard.getTenantId()); + baseMapper.upsert(status); + } + + /** + * 为已完成权限过滤的设备记录批量补充运行状态,不支持的设备在线值保持为空。 + * + * @param records 当前页设备视图列表 + */ + @Override + public void enrich(List records) { + if (records == null || records.isEmpty()) { + return; + } + List supportedIds = records.stream() + .filter(record -> isSupported(record.getType())) + .map(HardManagerVo::getId) + .filter(ObjectUtil::isNotEmpty) + .distinct() + .toList(); + Map statusMap = supportedIds.isEmpty() + ? Collections.emptyMap() + : baseMapper.selectList(new LambdaQueryWrapper() + .in(HardTerminalStatusEntity::getHardId, supportedIds)).stream() + .collect(Collectors.toMap(HardTerminalStatusEntity::getHardId, Function.identity())); + LocalDateTime onlineCutoff = LocalDateTime.now().minusSeconds(ONLINE_TIMEOUT_SECONDS); + + for (HardManagerVo record : records) { + if (!isSupported(record.getType())) { + record.setOnline(null); + continue; + } + HardTerminalStatusEntity status = statusMap.get(record.getId()); + if (status == null) { + record.setOnline(false); + record.setDisconnectReason(NEVER_ONLINE_REASON); + continue; + } + boolean online = status.getLastOnlineTime() != null && !status.getLastOnlineTime().isBefore(onlineCutoff); + record.setOnline(online); + record.setLastOnlineTime(status.getLastOnlineTime()); + record.setApkVersionName(status.getApkVersionName()); + record.setApkVersionCode(status.getApkVersionCode()); + if (online) { + record.setDisconnectReason(status.getDisconnectReason()); + } else { + record.setDisconnectReason(ObjectUtil.isNotEmpty(status.getDisconnectReason()) + ? status.getDisconnectReason() : GENERIC_DISCONNECT_REASON); + } + } + } + + /** + * 判断设备是否属于支持在线监控的食堂或超市 Android 终端。 + * + * @param hardType {@code hard_manager.type} 的持久化编码 + * @return 超市或食堂终端返回 {@code true};无屏 EY_PRINT 外设不单独上报心跳 + */ + private boolean isSupported(String hardType) { + return ObjectUtil.equals(HardTypeEnum.SUPERMARKET.getType(), hardType) + || ObjectUtil.equals(HardTypeEnum.CANTEEN.getType(), hardType); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index a5c0a0fd2..ee1f3bc12 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -711,6 +711,55 @@ public class HardAfterSaleServiceImpl extends ServiceImpl + implements HardOrderEvidenceImageService { + + /** + * 证据服务诊断日志标签,日志中不输出图片、Token 或本地完整路径。 + */ + private static final String TAG = "订单识别证据"; + + /** + * 终端现场图最大接收大小,单位字节;终端目标约100KB,服务端留出编码差异余量。 + */ + private static final int LIVE_IMAGE_MAX_BYTES = 512 * 1024; + + /** + * 后台参考人脸原图最大读取大小,单位字节,防止异常文件耗尽服务内存。 + */ + private static final int REFERENCE_IMAGE_MAX_BYTES = 10 * 1024 * 1024; + + /** + * 终端现场图允许的最大边长,超过时拒绝而不在服务端二次改变证据内容。 + */ + private static final int LIVE_IMAGE_MAX_EDGE = 640; + + /** + * 单个证据位置分布式锁的最长持有时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_EXPIRE_MILLIS = 180_000L; + + /** + * 并发上传同一证据时等待锁的最长时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_WAIT_MILLIS = 5_000L; + + /** + * 文件中心保存订单证据时使用的稳定目录前缀。 + */ + private static final String FILE_DIRECTORY_PREFIX = "order-evidence"; + + /** + * 文件中心标识订单证据文件的稳定业务类型。 + */ + private static final String FILE_TYPE = "order-evidence"; + + /** + * 服务端创建订单缺少终端订单号时使用的证据内部引用前缀;仅写入证据快照,不回写订单主表。 + */ + private static final String SERVER_ORDER_REFERENCE_PREFIX = "SERVER_ORDER_"; + + /** + * UPMS 人脸图片受信下载接口的路径后缀;其他来源地址一律拒绝。 + */ + private static final String TRUSTED_FACE_FILE_PATH = "/sys-file/oss/file"; + + /** + * 订单主表 Mapper,用于按终端订单号和设备校验订单归属。 + */ + private final HardOrderMapper hardOrderMapper; + + /** + * 设备 Mapper,用于确认请求头 DEVICE-ID 属于当前租户的有效设备。 + */ + private final HardManagerMapper hardManagerMapper; + + /** + * UPMS 人脸远程服务,用于校验用户、样本和审核通过原图的一致性。 + */ + private final RemoteUserFaceService remoteUserFaceService; + + /** + * UPMS 文件中心远程服务,用于读取受信参考图并保存订单专属文件。 + */ + private final RemoteFileService remoteFileService; + + /** + * 分布式锁模板,用于串行化同一订单证据位置的并发上传。 + */ + private final LockTemplate lockTemplate; + + /** + * 按终端订单号或服务端订单 ID 解析订单并返回当前证据状态。 + * + * @param hardId 当前设备主键 + * @param androidOrderCode 终端本地订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 当前订单状态及已登记证据 + */ + @Override + public HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId) { + HardOrderEntity order = requireOrder(hardId, androidOrderCode, orderId, false); + return HardOrderEvidenceResolveVo.builder() + .orderId(order.getId()) + .orderStatus(order.getOrderStatus()) + .evidenceImages(listByOrderId(order.getId())) + .build(); + } + + /** + * 提交元数据时先建立占位;参考人脸继续执行受信复制,现场图等待 multipart 上传。 + */ + @Override + public HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true); + validateMetadata(dto, order); + HardOrderEvidenceStatusEnum requestedStatus = HardOrderEvidenceStatusEnum.valueOfCode(dto.getStatus()); + if (requestedStatus != HardOrderEvidenceStatusEnum.PENDING + && requestedStatus != HardOrderEvidenceStatusEnum.UNAVAILABLE) { + throw new RuntimeException("终端只允许提交待补传或永久缺失状态"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE && StrUtil.isBlank(dto.getFailureReason())) { + throw new RuntimeException("永久缺失时必须说明失败原因"); + } + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "证据已经上传,无需重复处理"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(dto.getFailureReason())); + updateById(entity); + return result(entity, false, "已记录证据永久缺失"); + } + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + return copyReferenceImage(entity, order, dto); + } + return result(entity, false, "证据元数据已登记,等待现场图片上传"); + }); + } + + /** + * 上传现场图片时严格校验哈希、格式、尺寸和唯一位置,文件中心成功后才切换 AVAILABLE。 + */ + @Override + public HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true); + validateMetadata(dto, order); + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + throw new RuntimeException("后台参考人脸必须由服务端复制,终端不能直接上传"); + } + if (StrUtil.isBlank(dto.getImageHash())) { + throw new RuntimeException("现场图片哈希不能为空"); + } + ImageSnapshot image = readImage(file, LIVE_IMAGE_MAX_BYTES, true); + if (!dto.getImageHash().equalsIgnoreCase(image.hash())) { + throw new RuntimeException("现场图片内容与终端哈希不一致"); + } + validateReportedImage(dto, image); + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + if (!image.hash().equalsIgnoreCase(entity.getImageHash())) { + throw new RuntimeException("该证据位置已保存其他图片,禁止覆盖"); + } + return result(entity, false, "相同证据已经上传"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "现场证据上传成功"); + }); + } + + /** + * 订单提交的证据清单只创建 PENDING 占位,不执行远程文件调用,确保订单主链路快速完成。 + */ + @Override + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + public void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion) { + if (order == null || metadataList == null || metadataList.isEmpty()) return; + for (HardOrderEvidenceMetadataDto metadata : metadataList) { + validateMetadata(metadata, order); + HardOrderEvidenceImageEntity existing = baseMapper.findPosition(order.getId(), metadata.getEvidenceType(), + metadata.getEvidenceRole(), metadata.getSequenceNo()); + if (existing != null) { + validateHashConflict(existing, metadata.getImageHash()); + continue; + } + HardOrderEvidenceImageEntity entity = buildEntity(order, metadata, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + } + } + + /** + * 转换数据库实体并补齐稳定编码的中文名称,未知历史编码保持原值便于排障。 + */ + @Override + public List listByOrderId(Long orderId) { + if (orderId == null) return List.of(); + return baseMapper.listByOrderId(orderId).stream().map(entity -> { + HardOrderEvidenceImageVo vo = BeanUtils.toBean(entity, HardOrderEvidenceImageVo.class); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(entity.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(entity.getEvidenceRole()); + HardOrderEvidenceStatusEnum status = HardOrderEvidenceStatusEnum.valueOfCode(entity.getStatus()); + vo.setEvidenceTypeName(type == null ? entity.getEvidenceType() : type.getDescription()); + vo.setEvidenceRoleName(role == null ? entity.getEvidenceRole() : role.getDescription()); + vo.setStatusName(status == null ? entity.getStatus() : status.getDescription()); + return vo; + }).toList(); + } + + /** + * 校验设备存在,并按设备和至少一种订单标识限定订单,避免跨设备探测或写入证据。 + * + *

预约订单只有在核销成功后才会把 {@code hard_id} 更新为当前设备,因此终端即使提前知道 + * 服务端订单 ID,也不能在核销前或其他设备上写入证据。

+ * + * @param hardId 当前设备主键 + * @param androidOrderCode 终端本地订单号,允许为空 + * @param orderId 服务端订单主键,允许为空 + * @param requirePaid 是否要求订单处于已支付、已退款或已核销状态 + * @return 属于当前设备的订单 + */ + private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, Long orderId, boolean requirePaid) { + if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + if (StrUtil.isBlank(androidOrderCode) && orderId == null) { + throw new RuntimeException("终端订单号和服务端订单ID不能同时为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) throw new RuntimeException("当前租户下设备不存在"); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(HardOrderEntity::getHardId, hardId) + .eq(orderId != null, HardOrderEntity::getId, orderId) + .eq(StrUtil.isNotBlank(androidOrderCode), HardOrderEntity::getAndroidOrderCode, androidOrderCode); + HardOrderEntity order = hardOrderMapper.selectOne(query); + if (order == null) throw new RuntimeException("订单不存在或不属于当前设备"); + if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) { + throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据"); + } + return order; + } + + /** + * 校验类型、角色、序号、用户和人脸样本约束,阻止终端构造不一致的证据位置。 + */ + private void validateMetadata(HardOrderEvidenceMetadataDto dto, HardOrderEntity order) { + if (dto == null) throw new RuntimeException("证据元数据不能为空"); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(dto.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(dto.getEvidenceRole()); + if (type == null || role == null) throw new RuntimeException("证据类型或图片角色不合法"); + if (dto.getSequenceNo() == null || dto.getSequenceNo() < 0 || dto.getSequenceNo() > 99) { + throw new RuntimeException("证据图片序号不合法"); + } + if (type == HardOrderEvidenceTypeEnum.FACE_RECOGNITION) { + if (role != HardOrderEvidenceRoleEnum.FACE_REFERENCE && role != HardOrderEvidenceRoleEnum.FACE_LIVE) { + throw new RuntimeException("人脸识别证据角色不合法"); + } + if (dto.getSequenceNo() != 0) throw new RuntimeException("人脸识别证据序号必须为0"); + if (dto.getUserId() == null || dto.getUserId() <= 0 || StrUtil.isBlank(dto.getFaceSampleId())) { + throw new RuntimeException("人脸识别证据缺少用户或样本编号"); + } + if (!Objects.equals(order.getUserId(), dto.getUserId())) { + throw new RuntimeException("识别用户与订单用户不一致"); + } + if (role == HardOrderEvidenceRoleEnum.FACE_REFERENCE && StrUtil.isBlank(dto.getReferenceImageUrl())) { + throw new RuntimeException("参考人脸证据缺少识别时图片地址"); + } + } else if (role != HardOrderEvidenceRoleEnum.MENU_LIVE) { + throw new RuntimeException("菜谱识别证据角色不合法"); + } + } + + /** + * 创建或更新 PENDING 元数据,AVAILABLE 和 UNAVAILABLE 终态只做哈希一致性检查并保持不可变。 + */ + private HardOrderEvidenceImageEntity upsertMetadata(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = baseMapper.findPosition(order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + if (entity == null) { + entity = buildEntity(order, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + return entity; + } + validateHashConflict(entity, dto.getImageHash()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus()) + || HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return entity; + } + fillMetadata(entity, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + entity.setFailureReason(null); + updateById(entity); + return entity; + } + + /** + * 构造新的证据占位实体并写入订单、设备和租户快照。 + */ + private HardOrderEvidenceImageEntity buildEntity(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = new HardOrderEvidenceImageEntity(); + entity.setOrderId(order.getId()); + // 预约核销订单不是消费机现场创建的,通常没有 android_order_code;历史证据表该列为非空, + // 因此保存现有服务端订单号或订单主键生成的内部引用。现场订单仍保留真实终端订单号。 + entity.setAndroidOrderCode(resolveEvidenceOrderReference(order)); + entity.setHardId(order.getHardId()); + entity.setTenantId(TenantContextHolder.getTenantId()); + fillMetadata(entity, dto, appVersion); + return entity; + } + + /** + * 解析证据表必须保存的稳定订单引用,避免预约订单因没有终端订单号而无法写入证据。 + * + * @param order 已完成持久化的订单;现场订单应包含终端订单号,服务端订单至少应包含订单号或主键 + * @return 真实终端订单号、服务端订单号或基于服务端主键生成的内部引用 + */ + private String resolveEvidenceOrderReference(HardOrderEntity order) { + if (order == null) throw new RuntimeException("订单证据缺少订单信息"); + if (StrUtil.isNotBlank(order.getAndroidOrderCode())) return order.getAndroidOrderCode(); + if (StrUtil.isNotBlank(order.getOrderNo())) return order.getOrderNo(); + if (order.getId() == null) throw new RuntimeException("订单证据缺少服务端订单ID"); + return SERVER_ORDER_REFERENCE_PREFIX + order.getId(); + } + + /** + * 更新终端识别快照;文件中心字段只能由 storeImage 写入,不能由终端覆盖。 + */ + private void fillMetadata(HardOrderEvidenceImageEntity entity, HardOrderEvidenceMetadataDto dto, String appVersion) { + entity.setEvidenceType(dto.getEvidenceType()); + entity.setEvidenceRole(dto.getEvidenceRole()); + entity.setSequenceNo(dto.getSequenceNo()); + entity.setUserId(dto.getUserId()); + entity.setFaceSampleId(dto.getFaceSampleId()); + entity.setReferenceImageUrl(dto.getReferenceImageUrl()); + entity.setSimilarity(dto.getSimilarity()); + entity.setSimilarityThreshold(dto.getSimilarityThreshold()); + entity.setCapturedAt(dto.getCapturedAt()); + if (StrUtil.isNotBlank(dto.getImageHash())) entity.setImageHash(dto.getImageHash().toLowerCase()); + if (dto.getImageSize() != null) entity.setImageSize(dto.getImageSize()); + if (dto.getImageWidth() != null) entity.setImageWidth(dto.getImageWidth()); + if (dto.getImageHeight() != null) entity.setImageHeight(dto.getImageHeight()); + if (StrUtil.isNotBlank(appVersion)) entity.setAppVersion(appVersion); + } + + /** + * 通过 UPMS 权威记录校验参考图,来源已变化时标记永久缺失,远程暂时失败则保留 PENDING 重试。 + */ + private HardOrderEvidenceUploadResultVo copyReferenceImage(HardOrderEvidenceImageEntity entity, + HardOrderEntity order, + HardOrderEvidenceMetadataDto dto) { + R sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId()); + if (sourceResult == null || !sourceResult.isOk()) { + throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg()) + ? "查询后台参考人脸失败" : sourceResult.getMsg()); + } + SysUserFaceEvidenceSourceVo source = sourceResult.getData(); + if (source == null || StrUtil.isBlank(source.getFaceImg())) { + return markUnavailable(entity, "识别命中的后台人脸已不存在或不再可用"); + } + if (!Objects.equals(dto.getReferenceImageUrl(), source.getFaceImg())) { + return markUnavailable(entity, "后台参考人脸在识别后已发生变化"); + } + if (dto.getReferenceImageVersion() != null + && !Objects.equals(dto.getReferenceImageVersion(), source.getSourceUpdatedAt())) { + return markUnavailable(entity, "后台参考人脸版本在识别后已发生变化"); + } + String sourceFileName; + try { + sourceFileName = trustedFileName(source.getFaceImg()); + } catch (RuntimeException exception) { + return markUnavailable(entity, exception.getMessage()); + } + ImageSnapshot image; + try (Response response = remoteFileService.getFile(sourceFileName)) { + if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { + throw new RuntimeException("读取后台参考人脸失败"); + } + image = readImage(response.body().asInputStream(), sourceFileName, REFERENCE_IMAGE_MAX_BYTES, false); + } catch (IOException exception) { + throw new RuntimeException("读取后台参考人脸失败", exception); + } + if (StrUtil.isNotBlank(dto.getImageHash()) && !dto.getImageHash().equalsIgnoreCase(image.hash())) { + return markUnavailable(entity, "后台参考人脸内容与识别时哈希不一致"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "后台参考人脸已复制为订单证据"); + } + + /** + * 将不可恢复的参考图变化记录为 UNAVAILABLE,保留已有元数据供 PC 追溯。 + */ + private HardOrderEvidenceUploadResultVo markUnavailable(HardOrderEvidenceImageEntity entity, String reason) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(reason)); + updateById(entity); + return result(entity, false, reason); + } + + /** + * 写入文件中心并在成功响应后原子更新证据记录,不删除任何历史证据文件。 + */ + @SuppressWarnings("unchecked") + private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order, ImageSnapshot image, String originalName) { + MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(), image.bytes()); + R uploadResult = remoteFileService.innerUpload(uploadFile, FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map storage)) { + throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) ? "订单证据写入文件中心失败" : uploadResult.getMsg()); + } + entity.setSysFileId(numberValue(storage.get("id"))); + entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名")); + entity.setImageUrl(requiredString(storage.get("url"), "文件中心未返回访问地址")); + entity.setImageHash(image.hash()); + entity.setImageSize((long) image.bytes().length); + entity.setImageWidth(image.width()); + entity.setImageHeight(image.height()); + entity.setStatus(HardOrderEvidenceStatusEnum.AVAILABLE.getCode()); + entity.setFailureReason(null); + updateById(entity); + } + + /** + * 读取终端 MultipartFile 并复用统一图片内容校验。 + */ + private ImageSnapshot readImage(MultipartFile file, int maxBytes, boolean liveImage) { + if (file == null || file.isEmpty()) throw new RuntimeException("上传图片不能为空"); + try { + return readImage(new ByteArrayInputStream(file.getBytes()), file.getOriginalFilename(), maxBytes, liveImage); + } catch (IOException exception) { + throw new RuntimeException("读取上传图片失败", exception); + } + } + + /** + * 有上限地读取并解码图片,拒绝伪造后缀、空内容和超大像素尺寸。 + */ + private ImageSnapshot readImage(InputStream inputStream, String originalName, int maxBytes, boolean liveImage) { + byte[] bytes = readLimitedBytes(inputStream, maxBytes); + try { + BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(bytes)); + if (bufferedImage == null || bufferedImage.getWidth() <= 0 || bufferedImage.getHeight() <= 0) { + throw new RuntimeException("文件不是有效图片"); + } + String extension = detectExtension(bytes, originalName); + if (liveImage && Math.max(bufferedImage.getWidth(), bufferedImage.getHeight()) > LIVE_IMAGE_MAX_EDGE) { + throw new RuntimeException("现场图片最长边不能超过640像素"); + } + String contentType = "png".equals(extension) ? "image/png" : "image/jpeg"; + return new ImageSnapshot(bytes, sha256(bytes), bufferedImage.getWidth(), bufferedImage.getHeight(), extension, contentType); + } catch (IOException exception) { + throw new RuntimeException("图片解码失败", exception); + } + } + + /** + * 按字节签名识别 JPEG/PNG,扩展名只作为错误信息补充而不作为信任依据。 + */ + private String detectExtension(byte[] bytes, String originalName) { + if (bytes.length >= 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 && (bytes[2] & 0xff) == 0xff) { + return "jpg"; + } + if (bytes.length >= 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) { + return "png"; + } + throw new RuntimeException("仅支持真实的JPEG或PNG图片:" + StrUtil.blankToDefault(originalName, "未命名文件")); + } + + /** + * 按上限读取远程或上传字节流,超过上限立即拒绝以控制内存占用。 + */ + private byte[] readLimitedBytes(InputStream inputStream, int maxBytes) { + try (InputStream source = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = source.read(buffer)) >= 0) { + total += read; + if (total > maxBytes) throw new RuntimeException("图片文件超过允许大小"); + output.write(buffer, 0, read); + } + if (total == 0) throw new RuntimeException("图片文件不能为空"); + return output.toByteArray(); + } catch (IOException exception) { + throw new RuntimeException("读取图片文件失败", exception); + } + } + + /** + * 校验终端上报的大小和宽高,防止元数据与实际证据内容不一致。 + */ + private void validateReportedImage(HardOrderEvidenceMetadataDto dto, ImageSnapshot image) { + if (dto.getImageSize() != null && dto.getImageSize() != image.bytes().length) { + throw new RuntimeException("现场图片大小与终端上报不一致"); + } + if (dto.getImageWidth() != null && !dto.getImageWidth().equals(image.width())) { + throw new RuntimeException("现场图片宽度与终端上报不一致"); + } + if (dto.getImageHeight() != null && !dto.getImageHeight().equals(image.height())) { + throw new RuntimeException("现场图片高度与终端上报不一致"); + } + } + + /** + * 从受信文件接口地址提取对象名,禁止服务端按终端参数访问任意 URL。 + */ + private String trustedFileName(String sourceUrl) { + try { + var components = UriComponentsBuilder.fromUriString(sourceUrl).build(); + String path = components.getPath(); + String fileName = components.getQueryParams().getFirst("fileName"); + if (StrUtil.isBlank(path) || !path.endsWith(TRUSTED_FACE_FILE_PATH) || StrUtil.isBlank(fileName) + || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { + throw new RuntimeException("后台参考人脸来源不受信任"); + } + return fileName; + } catch (IllegalArgumentException exception) { + throw new RuntimeException("后台参考人脸地址不合法", exception); + } + } + + /** + * 相同证据位置只接受相同哈希;未知哈希的 PENDING 占位可在首次文件上传时补齐。 + */ + private void validateHashConflict(HardOrderEvidenceImageEntity existing, String incomingHash) { + if (existing != null && StrUtil.isNotBlank(existing.getImageHash()) && StrUtil.isNotBlank(incomingHash) + && !existing.getImageHash().equalsIgnoreCase(incomingHash)) { + throw new RuntimeException("该证据位置已登记其他图片哈希,禁止覆盖"); + } + } + + /** + * 使用不含租户编号的稳定业务 Key 串行化证据位置,租户前缀由 Redis 基础设施自动处理。 + */ + private T withEvidenceLock(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, LockedAction action) { + String lockKey = String.format("hard:order-evidence:%s:%s:%s:%s", order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + LockInfo lock = lockTemplate.lock(lockKey, EVIDENCE_LOCK_EXPIRE_MILLIS, EVIDENCE_LOCK_WAIT_MILLIS); + if (lock == null) throw new RuntimeException("该订单证据正在处理,请稍后重试"); + try { + return action.run(); + } finally { + if (!lockTemplate.releaseLock(lock)) log.warn("|{}|证据锁释放失败|orderId:{}|", TAG, order.getId()); + } + } + + /** + * 生成不包含用户姓名等敏感信息的订单证据文件名。 + */ + private String evidenceFileName(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String extension) { + return String.format("order-%s-%s-%s-%s.%s", order.getId(), dto.getEvidenceType().toLowerCase(), + dto.getEvidenceRole().toLowerCase(), dto.getSequenceNo(), extension); + } + + /** + * 将任意失败说明限制为单行500字符,避免终端路径或异常栈进入证据主记录。 + */ + private String sanitizeFailure(String source) { + String value = StrUtil.blankToDefault(source, "证据文件不可用").replace('\n', ' ').replace('\r', ' ').trim(); + return value.length() <= 500 ? value : value.substring(0, 500); + } + + /** + * 计算图片 SHA-256,使用 Java 17 十六进制编码保持终端与服务端一致。 + */ + private String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前JVM不支持SHA-256", exception); + } + } + + /** + * 将文件中心返回的数值主键安全转换为 Long。 + */ + private Long numberValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value != null && StrUtil.isNotBlank(value.toString())) { + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + // 统一在下方抛出文件中心协议错误。 + } + } + throw new RuntimeException("文件中心未返回有效文件主键"); + } + + /** + * 读取文件中心必需字符串字段,空值作为协议错误处理。 + */ + private String requiredString(Object value, String message) { + if (value == null || StrUtil.isBlank(value.toString())) throw new RuntimeException(message); + return value.toString(); + } + + /** + * 构造终端同步使用的简短幂等结果。 + */ + private HardOrderEvidenceUploadResultVo result(HardOrderEvidenceImageEntity entity, boolean uploaded, String message) { + return HardOrderEvidenceUploadResultVo.builder() + .evidenceId(entity.getId()) + .orderId(entity.getOrderId()) + .status(entity.getStatus()) + .imageUrl(entity.getImageUrl()) + .uploaded(uploaded) + .message(message) + .build(); + } + + /** + * 持锁执行单个证据位置动作的函数接口,允许统一保证锁释放。 + */ + @FunctionalInterface + private interface LockedAction { + + /** + * 执行可能读写证据和文件中心的业务动作。 + * + * @return 动作结果 + */ + T run(); + } + + /** + * 已校验图片的不可变内存快照,避免上传前后再次读取可变临时文件。 + * + * @param bytes 图片原始字节 + * @param hash SHA-256 哈希 + * @param width 图片宽度,单位像素 + * @param height 图片高度,单位像素 + * @param extension 由字节签名确定的扩展名 + * @param contentType 与扩展名一致的 MIME 类型 + */ + private record ImageSnapshot(byte[] bytes, String hash, int width, int height, String extension, + String contentType) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 65ad1aecd..10606711a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -43,6 +43,8 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto; +import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; import com.bsd.pjcloud.hard.api.dto.spu.DeductionSpuStockDto; import com.bsd.pjcloud.hard.api.dto.wechat.WechantReverseDto; import com.bsd.pjcloud.hard.api.dto.wechat.WechatMicropayDto; @@ -68,6 +70,7 @@ import com.bsd.pjcloud.supermarket.api.feign.RemoteSupermarketSpuService; import com.bsd.pjcloud.supermarket.dto.MallProductStockChangeDTO; import com.bsd.pjcloud.supermarket.dto.SkuStockChangeDTO; import com.bsd.pjcloud.supermarket.dto.SupermarketSpuUpdateStockReqDTO; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum; @@ -157,6 +160,7 @@ public class HardOrderServiceImpl extends ServiceImpl pagejoin(Page page, HardOrderDto dto) { @@ -230,6 +234,18 @@ public class HardOrderServiceImpl extends ServiceImpl implements HardXfjLogService { + + @Override + public Page pageList(Page page, HardXfjLogDto dto) { + return baseMapper.pageList(page, dto); + } + +} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java new file mode 100644 index 000000000..a8c6403b3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java @@ -0,0 +1,935 @@ +package com.bsd.pjcloud.hard.service.impl.xfj; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.CacheConstants; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; +import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto; +import com.bsd.pjcloud.hard.api.dto.xfj.*; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderSbVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.xfj.*; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +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.WalletCodeUseEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Validated +@Slf4j +@RequiredArgsConstructor +@Service +public class HardXfjServiceImpl implements HardXfjService { + + private final HardManagerService hardManagerService; + private final HardXfjLogService hardXfjLogService; + private final HardOrderService hardOrderService; + private final HardOrderTotalService hardOrderTotalService; + private final HardAfterSaleService hardAfterSaleService; + + private final HardOrderMapper hardOrderMapper; + + private final RemoteCodeUseService remoteCodeUseService; + private final RemoteUserService remoteUserService; + private final RemoteWalletService walletService; + + private final StringRedisTemplate redisTemplate; + + /** + * 检查设备并返回配置 + */ + public HardManagerEntity checkSignAndReturnConfig(String body, Map headers) { + Map transitionHeader = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + transitionHeader.put(entry.getKey().toUpperCase(), entry.getValue()); + } + String deviceId = transitionHeader.get("DEVICE-ID"); + if (ObjectUtil.isEmpty(deviceId)) { + throw new RuntimeException("|检查设备并返回配置|错误,deviceId不存在"); + } + HardManagerVo hardManager = hardManagerService.getByUniqueCode(deviceId); + if (ObjectUtil.isEmpty(hardManager)) { + throw new RuntimeException("|检查设备并返回配置|错误,deviceId对应的设备不存在"); + } + if (ObjectUtil.notEqual(HardTypeEnum.YUN_XFJ.getType(), hardManager.getType())) { + throw new RuntimeException("|检查设备并返回配置|错误,设备类型错误"); + } + // 验签 + return hardManager; + } + + /** + * 初始化日志 + */ + private HardXfjLogEntity initLogger(String body, Map headers, String title) { + Map transitionHeader = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + transitionHeader.put(entry.getKey().toUpperCase(), entry.getValue()); + } + + HardXfjLogEntity logger = new HardXfjLogEntity(); + logger.setTitle(title); + logger.setHardType(HardTypeEnum.YUN_XFJ.getType()); + logger.setReq(body); + logger.setHeaders(JSONUtil.toJsonStr(transitionHeader)); + logger.setStartTime(LocalDateTime.now()); + return logger; + } + + public void saveLogger(HardXfjLogEntity logger) { + LocalDateTime now = LocalDateTime.now(); + logger.setEndTime(now); + if (ObjectUtil.isNotEmpty(logger.getStartTime())) { + long durationSeconds = Duration.between(logger.getStartTime(), now).toMillis(); + logger.setCost(durationSeconds); + } + hardXfjLogService.save(logger); + } + + public String fenToYuan(BigDecimal fen) { + if (ObjectUtil.isEmpty(fen)) { + return "0"; + } + return fen.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).toString(); + } + + /** + * 获取服务器时间接口 + */ + @Override + public XfjServerTimeVo serverTime(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "获取服务器时间接口"); + + XfjServerTimeVo result = new XfjServerTimeVo(); + try { + + // 是否重复请求 + Map transitionHeader = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + transitionHeader.put(entry.getKey().toUpperCase(), entry.getValue()); + } + String deviceId = transitionHeader.get("DEVICE-ID"); + if (ObjectUtil.isEmpty(deviceId)) { + throw new RuntimeException("|检查设备并返回配置|错误,deviceId不存在"); + } + String lockKey = CacheConstants.HARD_XFJ_WHITE_LIST_LOCK + ":" + deviceId; + // 检查是否存在锁 + Boolean hasLock = false; + hasLock = redisTemplate.hasKey(lockKey); + if (ObjectUtil.isNotEmpty(hasLock) && hasLock) { + log.warn("|获取服务器时间接口|设备uniqueCode:{}在30分钟内已查询过,拒绝重复查询", deviceId); + result.setWLUptate(0); + result.setWLPage(1); + return result; + } + + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(entity.getUniqueCode()); + + XfjServerTimeDto dto = JSONUtil.toBean(body, XfjServerTimeDto.class); + if (ObjectUtil.isEmpty(dto)) { + throw new RuntimeException("解析参数为空"); + } + + LocalDateTime now = LocalDateTime.now(); + String timeStr = LocalDateTimeUtil.format(now, DatePattern.PURE_DATETIME_PATTERN); + int dayOfWeek = now.getDayOfWeek().getValue(); + // 将星期日从 7 改为 0 + if (dayOfWeek == 7) { + dayOfWeek = 0; + } + timeStr += dayOfWeek; + + result.setTime(timeStr); + logger.setRes(JSONUtil.toJsonStr(result)); + + WalletRemoteOpenCardCountDto countDto = new WalletRemoteOpenCardCountDto(); + countDto.setMchId(entity.getMerchantId()); + countDto.setCarNumberLength(10); + Integer carCount = walletService.remoteOpenCardCount(countDto).getData(); + + if (ObjectUtil.isNotEmpty(hasLock) && hasLock && dto.getWLSum() > 0) { + log.warn("|获取服务器时间接口|设备uniqueCode:{}在30分钟内已查询过,拒绝重复查询", entity.getUniqueCode()); + result.setWLUptate(0); + result.setWLPage(1); + return result; + } + // 起始页数(当WLUptate为1却WLPage为0时清除所有白名单) + int currentHour = LocalDateTime.now().getHour(); + if (currentHour == 3) { + result.setWLUptate(1); + result.setWLPage(0); + // 更新完成,设置30分钟的锁 + redisTemplate.opsForValue().set(lockKey, "1", Duration.ofMinutes(30)); + log.info("|获取服务器时间接口|设备 {} 3点清空白名单,设置30分钟锁", entity.getUniqueCode()); + } + // 查询错误不更新 + else if (ObjectUtil.isEmpty(carCount)) { + result.setWLUptate(0); + result.setWLPage(1); + } + // 已经更新完了 + else if (dto.getWLSum() >= carCount) { + result.setWLUptate(0); + result.setWLPage(1); + // 更新完成,设置30分钟的锁 + redisTemplate.opsForValue().set(lockKey, "1", Duration.ofMinutes(30)); + log.info("|获取服务器时间接口|设备 {} 更新完成,设置30分钟锁", entity.getUniqueCode()); + } + // 还未更新完 + else { + // 计算当前开始页面 + int startPage = dto.getWLSum() / 10; + if (startPage <= 0) { + startPage = 1; + } + result.setWLUptate(1); + result.setWLPage(startPage); + + // 设置锁 + // 启动更新,设置30分钟的锁 + redisTemplate.opsForValue().set(lockKey, "1", Duration.ofMinutes(30)); + log.info("|获取服务器时间接口|设备 {} 启动更新,设置30分钟锁", entity.getUniqueCode()); + } + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|获取服务器时间接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + // saveLogger(logger); + } + } + + /** + * 用户刷卡综合接口 + */ + @Override + public XfjConsumTransactionsVo consumTransactions(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "用户刷卡综合接口"); + XfjConsumTransactionsVo result = new XfjConsumTransactionsVo(); + try { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(entity.getUniqueCode()); + // 获取参数 + XfjConsumTransactionsDto dto = JSONUtil.toBean(body, XfjConsumTransactionsDto.class); + if (ObjectUtil.isEmpty(dto)) { + throw new RuntimeException("解析参数为空"); + } + if (!ObjectUtil.isAllNotEmpty(dto.getMode(), dto.getCardNo(), dto.getCardMode(), dto.getOrder())) { + throw new RuntimeException("解析参数错误"); + } + // 刷卡扣费 + if (ObjectUtil.equals(dto.getMode(), 0)) { + // 取餐 + if (ObjectUtil.equals(dto.getPayType(), 3)) { + + return null; + } else { + WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData(); + if (ObjectUtil.isEmpty(wallet)) { + throw new RuntimeException("非本单位饭卡"); + } + // 查询用户 + String username = "未知用户"; + UserInfo user = remoteUserService.getInnerByUserId(wallet.getUserId()).getData(); + if (ObjectUtil.isNotEmpty(user) && ObjectUtil.isNotEmpty(user.getSysUser().getName())) { + username = user.getSysUser().getName(); + } + // 解析金额 + int price = BigDecimal.valueOf(Double.valueOf(dto.getAmount())).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).intValue(); + + HardXfjFastCreateOrderDto payDto = new HardXfjFastCreateOrderDto(); + payDto.setUserId(user.getSysUser().getUserId()); + payDto.setHardId(entity.getId()); + payDto.setPrice(price); + payDto.setOrderNo(dto.getOrder()); + payDto.setWalletType(WalletTypeEnum.EAT); + payDto.setXfjPayType(HardXfjTypeEnum.CARD_PAY); + payDto.setPlatformType(AppPlusPlatformTypeEnum.ZAX_APP_WALLET); + Long orderId = hardOrderService.xfjFastCreateOrder(payDto); + + result.setName(username); + result.setCardNo(wallet.getCardNumber()); + result.setMoney(fenToYuan(BigDecimal.valueOf(wallet.getBalance()))); + result.setSubsidy("0"); + result.setTimes("0"); + result.setIntegral("0"); + result.setInTime(""); + result.setOutTime(""); + result.setCumulativeTime(""); + result.setAmount(dto.getAmount()); + result.setVoiceID(""); + result.setText("购买成功\r\n总消费" + dto.getAmount() + "元"); + return result; + } + + } + // 现金充值 + else if (ObjectUtil.equals(dto.getMode(), 1)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持现金充值"); + return result; + } + // 余额查询 + else if (ObjectUtil.equals(dto.getMode(), 2)) { + WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData(); + if (ObjectUtil.isEmpty(wallet)) { + throw new RuntimeException("非本单位饭卡"); + } + // 查询用户 + String username = "未知用户"; + UserInfo user = remoteUserService.getInnerByUserId(wallet.getUserId()).getData(); + if (ObjectUtil.isNotEmpty(user) && ObjectUtil.isNotEmpty(user.getSysUser().getName())) { + username = user.getSysUser().getName(); + } + result.setName(username); + result.setCardNo(wallet.getCardNumber()); + result.setMoney(fenToYuan(BigDecimal.valueOf(wallet.getBalance()))); + result.setSubsidy("0"); + result.setTimes("0"); + result.setIntegral("0"); + result.setInTime(""); + result.setOutTime(""); + result.setCumulativeTime(""); + result.setAmount(""); + result.setVoiceID(""); + result.setText("购买成功\r\n总消费" + dto.getAmount() + "元"); + return result; + } + // 钱包转账 + else if (ObjectUtil.equals(dto.getMode(), 3)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持钱包转账"); + return result; + } + // 现金退款 + else if (ObjectUtil.equals(dto.getMode(), 4)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持现金退款"); + return result; + } else { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持的操作"); + return result; + } + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|用户刷卡综合接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + logger.setRes(JSONUtil.toJsonStr(result)); + saveLogger(logger); + } + } + + /** + * 用户刷卡消费结果查询接口 + */ + @Override + public XfjOrderQueryVo orderQuery(String body, Map headers) { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + try { + + } catch (Exception e) { + e.printStackTrace(); + log.error("|用户刷卡消费结果查询接口|意外的错误error:{}", e.getMessage()); + } + return null; + } + + /** + * 终端查询日统计接口 + */ + @Override + public XfjCountsVo counts(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "终端查询日统计接口"); + XfjCountsVo result = new XfjCountsVo(); + try { + HardManagerEntity hardEntity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(hardEntity.getUniqueCode()); + + LocalDateTime now = LocalDateTime.now(); + StringBuilder text = new StringBuilder(); + // 查询此消费机的总订单、总人次 + BigDecimal totalProfitPrice, totalProfitCount = BigDecimal.valueOf(0); + + HardOrderDto totalSbDto = new HardOrderDto(); + totalSbDto.setHardId(hardEntity.getId()); + HardOrderSbVo hardOrderSbVo = hardOrderTotalService.totalSb(totalSbDto, null); + totalProfitPrice = hardOrderSbVo.getTotalProfitPrice(); + totalProfitCount = hardOrderSbVo.getTotalProfitCount(); + + text.append("消费总价格:").append(fenToYuan(totalProfitPrice)).append("元\n"); + text.append("消费总人次:").append(totalProfitCount.toString()).append("人\n"); + + // 查询此消费机今日的总订单、总人次 + BigDecimal todayProfitPrice, todayProfitCount = BigDecimal.valueOf(0); + HardOrderDto todaySbDto = new HardOrderDto(); + todaySbDto.setHardId(hardEntity.getId()); + todaySbDto.setPayTimeList( + new LocalDateTime[]{ + now.withHour(0).withMinute(0).withSecond(0), + now.withHour(23).withMinute(23).withSecond(59) + } + ); + HardOrderSbVo todayHardOrderSbVo = hardOrderTotalService.totalSb(todaySbDto, null); + todayProfitPrice = todayHardOrderSbVo.getTotalProfitPrice(); + todayProfitCount = todayHardOrderSbVo.getTotalProfitCount(); + + text.append("今日消费总价格:").append(fenToYuan(todayProfitPrice)).append("元\n"); + text.append("今日消费总人次:").append(todayProfitCount.toString()).append("人\n"); + result.setText(text.toString()); + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|终端查询日统计接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + logger.setRes(JSONUtil.toJsonStr(result)); + saveLogger(logger); + } + } + + /** + * KeepAlive + */ + @Override + public XfjKeepAliveVo keepAlive(String body, Map headers) { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + try { + + } catch (Exception e) { + e.printStackTrace(); + log.error("|KeepAlive|意外的错误error:{}", e.getMessage()); + } + return null; + } + + /** + * ConCode + */ + @Override + public XfjKeepAliveVo conCode(String deviceCode, String price, String body, Map headers) { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + try { + + } catch (Exception e) { + e.printStackTrace(); + log.error("|ConCode|意外的错误error:{}", e.getMessage()); + } + return null; + } + + /** + * 终端设备待查状态,按上翻/下翻键向服务器请求,服务器返回一条消费明细 + */ + @Override + public Object transaction(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "终端设备待查状态,按上翻/下翻键向服务器请求,服务器返回一条消费明细"); + XfjTransactionVo result = new XfjTransactionVo(); + try { + HardManagerEntity hardEntity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(hardEntity.getUniqueCode()); + // 获取参数 + XfjTransactionDto pageDto = JSONUtil.toBean(body, XfjTransactionDto.class); + + LocalDateTime now = LocalDateTime.now(); + + // 上翻/下翻 + if (ObjectUtil.isNotEmpty(pageDto) && ObjectUtil.isNotEmpty(pageDto.getNumber())) { + long current = pageDto.getNumber(); + long pageSize = 1; + HardOrderDto orderDto = new HardOrderDto(); + orderDto.setHardId(hardEntity.getId()); + orderDto.setPayTimeList( + new LocalDateTime[]{ + now.withHour(0).withMinute(0).withSecond(0), + now.withHour(23).withMinute(23).withSecond(59) + } + ); + Page orderPage = hardOrderMapper.pageJoin(new Page(current, pageSize), orderDto); + if (ObjectUtil.isEmpty(orderPage) || ObjectUtil.isEmpty(orderPage.getRecords())) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("今日没有更多订单了"); + return result; + } + HardOrderVo order = orderPage.getRecords().get(0); + if (ObjectUtil.isEmpty(order)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("此订单异常"); + return result; + } + // 查询用户 + String username = "未知用户"; + UserInfo user = remoteUserService.getInnerByUserId(order.getUserId()).getData(); + if (ObjectUtil.isNotEmpty(user) && ObjectUtil.isNotEmpty(user.getSysUser().getName())) { + username = user.getSysUser().getName(); + } + // 余额(分) + int balance = 0; + WalletManageVo wallet = walletService.getWalletById(order.getWalletId()).getData(); + if (ObjectUtil.isNotEmpty(wallet) && ObjectUtil.isNotEmpty(wallet.getBalance())) { + balance = wallet.getBalance(); + } + result.setNumber(String.valueOf(pageDto.getNumber())); + result.setTime(LocalDateTimeUtil.format(order.getPayTime(), DatePattern.PURE_DATETIME_PATTERN)); + result.setCardNo(""); + result.setName(username); + result.setConsume(fenToYuan(BigDecimal.valueOf(order.getPayPrice()))); + result.setBalance(fenToYuan(BigDecimal.valueOf(balance))); + result.setOrderSeralid(String.valueOf(order.getId())); + + Map resultMap = BeanUtil.beanToMap(result); + resultMap.put("Class", "消费"); + return resultMap; + } + // 退款 + else if (false) { + + } + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|终端设备待查状态,按上翻/下翻键向服务器请求,服务器返回一条消费明细|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + logger.setRes(JSONUtil.toJsonStr(result)); + saveLogger(logger); + } + } + + /** + * 订单退款接口 + */ + @Override + public XfjOrderRefundVo orderRefund(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "订单退款接口"); + XfjOrderRefundVo result = new XfjOrderRefundVo(); + try { + HardManagerEntity hardEntity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(hardEntity.getUniqueCode()); + // 获取参数 + XfjOrderRefundDto dto = JSONUtil.toBean(body, XfjOrderRefundDto.class); + // 退款 + if (ObjectUtil.isEmpty(dto) || ObjectUtil.isEmpty(dto.getOrderSeralid()) || ObjectUtil.isEmpty(dto.getAmount())) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("参数错误"); + return result; + } + // 查询订单 + HardOrderEntity order = hardOrderService.getById(Long.valueOf(dto.getOrderSeralid())); + if (ObjectUtil.isEmpty(order)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("参数错误"); + return result; + } + // 执行退款 + Boolean isRefund = hardAfterSaleService.adRefund(order.getId(), "退还消费"); + if (!isRefund) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("退款失败"); + return result; + } + // 查询用户 + String username = "未知用户"; + UserInfo user = remoteUserService.getInnerByUserId(order.getUserId()).getData(); + if (ObjectUtil.isNotEmpty(user) && ObjectUtil.isNotEmpty(user.getSysUser().getName())) { + username = user.getSysUser().getName(); + } + // 余额(分) + int balance = 0; + WalletManageVo wallet = walletService.getWalletById(order.getWalletId()).getData(); + if (ObjectUtil.isNotEmpty(wallet) && ObjectUtil.isNotEmpty(wallet.getBalance())) { + balance = wallet.getBalance(); + } + result.setName(username); + result.setCardNo(""); + result.setMoney(fenToYuan(BigDecimal.valueOf(balance))); + result.setSubsidy(""); + result.setTimes("0"); + result.setIntegral("0"); + result.setAmount(dto.getAmount()); + result.setCmdType(11); + logger.setRes(JSONUtil.toJsonStr(result)); + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|订单退款接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + logger.setRes(JSONUtil.toJsonStr(result)); + saveLogger(logger); + } + } + + /** + * 付款码(二维码)支付接口 + */ + @Override + @Transactional + public XfjQRCodeTransactionVo qrCodeTransaction(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "付款码(二维码)支付接口"); + XfjQRCodeTransactionVo result = new XfjQRCodeTransactionVo(); + try { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(entity.getUniqueCode()); + // 获取参数 + XfjQRCodeTransactionDto dto = JSONUtil.toBean(body, XfjQRCodeTransactionDto.class); + if (ObjectUtil.isEmpty(dto)) { + throw new RuntimeException("解析参数为空"); + } + // 扣费 + if (ObjectUtil.equals(dto.getMode(), "0")) { + // 取餐 + if (ObjectUtil.equals(dto.getPayType(), 3)) { + // 传过来的是`短码`需要解析一下 + WalletCodeUseEntity data = remoteCodeUseService.getInfoByShortCode(dto.getQR()).getData(); + if (ObjectUtil.isEmpty(data)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("二维码错误"); + return result; + } + if (ObjectUtil.isEmpty(data.getId())) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不是定餐二维码"); + return result; + } + // 查询订单 + HardOrderEntity order = hardOrderService.getById(data.getBizId()); + if (ObjectUtil.isEmpty(order)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("定餐订单不存在"); + return result; + } + HandCancelCodeDto cancelDto = new HandCancelCodeDto(); + cancelDto.setOrderId(order.getId()); + cancelDto.setHardwareId(entity.getId()); + cancelDto.setUserId(order.getUserId()); + Boolean isCancel = hardOrderService.handCancelCode(cancelDto); + if (!isCancel) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("核销订餐失败"); + return result; + } + result.setQrorder(String.valueOf(order.getId())); + return result; + } else { + // `QR`有三种可能性1、微信、支付宝、短码 + AppPlusPlatformTypeEnum platformType = judgeQrCode(dto.getQR()); + if (ObjectUtil.isEmpty(platformType)) { + throw new RuntimeException("二维码解析错误"); + } + Long userId = null; + WalletTypeEnum walletType = null; + HardXfjTypeEnum xfjPayType = HardXfjTypeEnum.QRCODE_PAY; + if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ZAX_APP_WALLET)) { + walletType = WalletTypeEnum.EAT; + // 传过来的是`短码`需要解析一下 + WalletCodeUseEntity data = remoteCodeUseService.getInfoByShortCode(dto.getQR()).getData(); + if (ObjectUtil.isEmpty(data)) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("二维码错误"); + return result; + } + userId = data.getUserId(); + } else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT)) { + xfjPayType = HardXfjTypeEnum.WECHAT_QRCODE_PAY; + } else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY)) { + xfjPayType = HardXfjTypeEnum.ALI_QRCODE_PAY; + } + + // 解析金额 + int price = BigDecimal.valueOf(Double.valueOf(dto.getAmount())).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).intValue(); + + HardXfjFastCreateOrderDto payDto = new HardXfjFastCreateOrderDto(); + payDto.setUserId(userId); + payDto.setHardId(entity.getId()); + payDto.setPrice(price); + payDto.setWalletType(walletType); + payDto.setXfjPayType(xfjPayType); + payDto.setPlatformType(platformType); + payDto.setDevicePayCode(dto.getQR()); + Long orderId = hardOrderService.xfjFastCreateOrder(payDto); + result.setQrorder(String.valueOf(orderId)); + return result; + } + } + // 充值 + else if (ObjectUtil.equals(dto.getMode(), "1")) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持充值"); + return result; + } + // 信息查询 + else if (ObjectUtil.equals(dto.getMode(), "3")) { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持信息查询"); + return result; + } else { + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg("不支持的操作"); + return result; + } + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|付款码(二维码)支付接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + logger.setRes(JSONUtil.toJsonStr(result)); + saveLogger(logger); + } + } + + /** + * 二维码支付结果查询接口 + */ + @Override + public XfjTransactionInquiryVo transactionInquiry(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "二维码支付结果查询接口"); + XfjTransactionInquiryVo result = new XfjTransactionInquiryVo(); + try { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + logger.setUniqueCode(entity.getUniqueCode()); + // 获取参数 + XfjTransactionInquiryDto dto = JSONUtil.toBean(body, XfjTransactionInquiryDto.class); + if (ObjectUtil.isEmpty(dto)) { + throw new RuntimeException("解析参数为空"); + } + // 查询订单 + HardOrderEntity order = hardOrderService.getById(dto.getQROrder()); + if (ObjectUtil.isEmpty(order)) { + throw new RuntimeException("订单不存在"); + } + // 查询用户 + String username = "未知用户"; + UserInfo user = remoteUserService.getInnerByUserId(order.getUserId()).getData(); + if (ObjectUtil.isNotEmpty(user) && ObjectUtil.isNotEmpty(user.getSysUser().getName())) { + username = user.getSysUser().getName(); + } + // 余额(分) + int balance = 0; + WalletManageVo wallet = walletService.getWalletById(order.getWalletId()).getData(); + if (ObjectUtil.isNotEmpty(wallet) && ObjectUtil.isNotEmpty(wallet.getBalance())) { + balance = wallet.getBalance(); + } + // 余额(元) + String priceStr = BigDecimal.valueOf(Double.valueOf(balance)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toString(); + // 支付金额(元) + String payPriceStr = BigDecimal.valueOf(Double.valueOf(order.getPayPrice())).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toString(); + result.setName(username); + result.setCardNo(""); + result.setMoney(priceStr); + result.setSubsidy(""); + result.setTimes("0"); + result.setIntegral("0"); + result.setInTime(""); + result.setOutTime(""); + result.setCumulativeTime(""); + result.setAmount(payPriceStr); + result.setVoiceID(null); + result.setText("购买成功\r\n总消费" + payPriceStr + "元"); + logger.setRes(JSONUtil.toJsonStr(result)); + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|二维码支付结果查询接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + saveLogger(logger); + } + } + + /** + * 菜品查询接口 + */ + @Override + public XfjMenusVo menus(String body, Map headers) { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + try { + + } catch (Exception e) { + e.printStackTrace(); + log.error("|菜品查询接口|意外的错误error:{}", e.getMessage()); + } + return null; + } + + /** + * 记账扣费流水上传接口 + */ + @Override + public XfjOffLinesVo offLines(String body, Map headers) { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + try { + + } catch (Exception e) { + e.printStackTrace(); + log.error("|记账扣费流水上传接口|意外的错误error:{}", e.getMessage()); + } + return null; + } + + /** + * 获取卡号白名单接口 + */ + @Override + public XfjWhiteListVo whiteList(String body, Map headers) { + HardXfjLogEntity logger = initLogger(body, headers, "获取卡号白名单接口"); + XfjWhiteListVo result = new XfjWhiteListVo(); + + try { + HardManagerEntity entity = checkSignAndReturnConfig(body, headers); + if (ObjectUtil.isEmpty(entity)) { + return null; + } + logger.setUniqueCode(entity.getUniqueCode()); + // 获取参数 + XfjWhiteListDto dto = JSONUtil.toBean(body, XfjWhiteListDto.class); + if (ObjectUtil.isEmpty(dto)) { + throw new RuntimeException("解析参数为空"); + } + if (!ObjectUtil.isAllNotEmpty(dto.getPage(), dto.getPageNumber(), dto.getCommID())) { + throw new RuntimeException("解析参数错误"); + } + + // 查询开卡数据(只查询本部门及以下的) + WalletRemoteOpenCardListDto openCardListDto = new WalletRemoteOpenCardListDto(); + openCardListDto.setCurrent(dto.getPage()); + openCardListDto.setPageSize(dto.getPageNumber()); + openCardListDto.setMchId(entity.getMerchantId()); + openCardListDto.setCarNumberLength(10); + Page walletActivePage = walletService.remoteOpenCardList(openCardListDto).getData(); + if (ObjectUtil.isEmpty(walletActivePage) || ObjectUtil.isEmpty(walletActivePage.getRecords())) { + result.setCommID(dto.getCommID()); + result.setPage(Math.toIntExact(dto.getPage())); + result.setPageLen(0); + result.setUptate(0); + result.setWLData(""); + logger.setRes(JSONUtil.toJsonStr(result)); + return result; + } + List walletActiveRecords = walletActivePage.getRecords(); + List whiteCarList = new ArrayList<>(); + // 是否查询结束 + boolean isSelectEnd = false; + if (walletActiveRecords.size() < dto.getPageNumber()) { + isSelectEnd = true; + } + for (int i = 0; i < walletActiveRecords.size(); i++) { + WalletActiveEntity traget = walletActiveRecords.get(i); + String whiteCarNumber = generateWhiteCarNumber(String.valueOf(traget.getCardSort()), traget.getCardNumber(), ObjectUtil.equals(traget.getDelFlag(), YesNoEnum.YES.getCode()) ? "0" : "1"); + whiteCarList.add(whiteCarNumber); + } + result.setCommID(dto.getCommID()); + result.setPage(Math.toIntExact(dto.getPage())); + result.setPageLen(walletActiveRecords.size()); + result.setUptate(isSelectEnd ? 0 : 1); + result.setWLData(whiteCarList.stream().collect(Collectors.joining(","))); + logger.setRes(JSONUtil.toJsonStr(result)); + return result; + } catch (Exception e) { + logger.setError(e.getMessage()); + e.printStackTrace(); + log.error("|获取卡号白名单接口|意外的错误error:{}", e.getMessage()); + result.setStatus(XfjBaseVo.STATUS_FAIL); + result.setMsg(e.getMessage()); + return result; + } finally { + if (ObjectUtil.isNotEmpty(result)) { + saveLogger(logger); + } + } + } + + /** + * 生成白名单需要的卡号格式 + * + * @param serialNumber 序号(1、2、3) + * @param carNumber 卡号(0000000001) + * @param operationType 操作类型(1:添加;0:删除;) + */ + private String generateWhiteCarNumber(String serialNumber, String carNumber, String operationType) { + return String.format("%s|%s|%s", serialNumber, carNumber, operationType); + } + + private AppPlusPlatformTypeEnum judgeQrCode(String qrCode) { + String qrCodePrefix = qrCode.substring(0, 2); + int qrCodeLength = qrCode.length(); + + // 支付宝`qrCode`18位纯数字,前缀以"25"到"29"或者 "30"开头 + if (qrCodeLength == 18 && Pattern.compile("^(2[5-9]|30)").matcher(qrCode).find()) { + return AppPlusPlatformTypeEnum.ALI_PAY; + } + // 微信`qrCode`18位纯数字,前缀以10、11、12、13、14、15开头 + else if (qrCodeLength == 18 && List.of("10", "11", "12", "13", "14", "15").contains(qrCodePrefix)) { + return AppPlusPlatformTypeEnum.WECHAT; + } + // 钱包二维码 + else if (qrCodeLength == 32) { + return AppPlusPlatformTypeEnum.ZAX_APP_WALLET; + } else { + return null; + } + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml index 9aee10fea..bde050db5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml @@ -6,8 +6,17 @@ - + + + + + + + + + + @@ -21,8 +30,17 @@ select a.id, a.dept_id as invoiceDeptId, a.hard_id, + a.printer_hard_id, a.order_id, + a.print_type, + a.print_no, + a.retry_version, + a.attempt_count, a.status, + a.failure_stage, + a.last_error, + a.printed_at, + a.reprint_reason, a.pic, a.create_by, a.create_time, @@ -34,15 +52,25 @@ a.del_flag = '0' + + and a.order_id = #{dto.orderId} + and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]} + + and a.print_type = #{dto.printType} + + + and a.status = #{dto.status} + - \ 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..103158b6f 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} @@ -421,6 +434,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-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-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/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/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/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/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/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-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..45679bd95 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 @@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.service.SysUserFaceService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -58,6 +59,18 @@ 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)); + } + /* ************************************* end 远程调用 end ************************************* */ + /* ************************************* start app start ************************************* */ @Operation(summary = "app查询人脸列表", description = "app查询人脸列表") @GetMapping("/app/page-join") 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..5fe2d9366 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,14 @@ 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.SysUserFaceEvidenceSourceVo; 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 +29,28 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper Long selectAllCount(); + /** + * 按用户和虹软样本编号读取审核通过的人脸原图来源,供订单证据服务复制订单专属快照。 + * + * @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; + } + 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..bf16dea3f 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 @@ -6,6 +6,7 @@ import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; 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.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; @@ -29,6 +30,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/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index c98b8f52d..b6e8a200d 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,6 +1,7 @@ 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; @@ -12,6 +13,7 @@ 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.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.service.SysDeptService; @@ -223,6 +225,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl 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..73a32887d 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; @@ -64,4 +70,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/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/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/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index e37d51d49..547ef9f7b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -12,6 +12,7 @@ import com.alibaba.excel.write.metadata.fill.FillConfig; import com.alibaba.fastjson.JSON; import com.baomidou.lock.annotation.Lock4j; 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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.entity.SysDept; @@ -31,6 +32,8 @@ import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; 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.dto.WalletTypeDto; import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; @@ -137,8 +140,15 @@ public class WalletTransactionServiceImpl extends ServiceImpllambdaUpdate() + .eq(WalletTransactionEntity::getId, dto.getId()) + .set(WalletTransactionEntity::getRemark, remark)); + // 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。 + if (updated == 0 && baseMapper.selectById(dto.getId()) == null) { + 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("流水归属部门不存在"); + } + // 传入空实体会跳过 MyBatis-Plus 的修改人和修改时间填充,因此保留更新实体承载审计字段。 + WalletTransactionEntity updateEntity = new WalletTransactionEntity(); + int updated = baseMapper.update(updateEntity, Wrappers.lambdaUpdate() + .eq(WalletTransactionEntity::getId, dto.getId()) + .set(WalletTransactionEntity::getDeptId, dto.getDeptId()) + .set(WalletTransactionEntity::getCreateTime, dto.getCreateTime())); + // 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。 + if (updated == 0 && baseMapper.selectById(dto.getId()) == null) { + 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 + + ) +