diff --git a/db/20260909_add_hard_door_flow_manual_settle_vo.sql b/db/20260909_add_hard_door_flow_manual_settle_vo.sql new file mode 100644 index 000000000..0d9a0e03e --- /dev/null +++ b/db/20260909_add_hard_door_flow_manual_settle_vo.sql @@ -0,0 +1,6 @@ +-- 智能柜手动结算「操作快照」字段(2026-09-09) +-- 用途:手动结算/取消订单时,在 hard_door_flow 保存完整操作快照 +-- (操作人id/姓名/时间/购物车明细含商品名单价/合计/支付方式/备注),供事后二次判断复核 +-- 与旷世识别回调字段(sku_callback_vo)互不干扰。 +ALTER TABLE hard_door_flow + ADD COLUMN manual_settle_vo text DEFAULT NULL COMMENT '手动结算/取消操作快照(操作人+购物车明细+金额+备注)'; diff --git a/db/20260909_智能柜手动结算测试数据.sql b/db/20260909_智能柜手动结算测试数据.sql new file mode 100644 index 000000000..ffd383b94 --- /dev/null +++ b/db/20260909_智能柜手动结算测试数据.sql @@ -0,0 +1,206 @@ +-- ================================================================ +-- 智能柜订单手动结算 —— 测试数据脚本(2026-09-09) +-- ================================================================ +-- 用途:在【测试环境】生成 10 笔可手动结算的智能柜免密订单 +-- pay_type='9'(智能柜免密) + sys_type='4'(生鲜柜) + order_status='1'(待结算) +-- 覆盖表:hard_order(订单)/ hard_auth(用户授权)/ hard_door_flow(智能柜流程) +-- 标记:三张表 create_by='test-manual-settle',可重复执行(先清理再生成) +-- 执行:mysql -h127.0.0.1 -P33905 -uroot -p pjcloudx_hardware < 本文件 +-- +-- 前置条件(缺一不可,脚本会自动挑选并检查): +-- 1. 已有智能柜设备:hard_door_manager + hard_door_cabinet 有数据 +-- 2. 柜内已配置商品:hard_spu_join 有 spu_id 指向有效 hard_spu 的记录 +-- (否则订单详情页手动结算区显示"未找到该柜的商品配置",无法结算) +-- 3. 钱包库存在消费钱包(wallet_type.unique_code='2',同租户优先) +-- —— 结算时会真实扣款(force 强制扣款允许透支),并真实生成钱包流水 +-- 说明: +-- - 本地 docker 为分库架构:硬件=pjcloudx_hardware 钱包=pjcloudx_wallet 用户=pjcloudx; +-- 单库环境请去掉 SQL 里的 pjcloudx_wallet. / pjcloudx. 库名前缀 +-- - 订单 order_type='1'(BELOW 线下购买),与生产 TOB 用户走钱包扣款一致; +-- 不要用 TOC_VISITOR('8'),那会走支付宝免密扣款,测试环境无第三方单会结算失败 +-- - 流程 flow_status='50'(已提交旷世识别、等待回调),即旷世跑路后的真实卡死状态 +-- - 视频为公网示例视频(主摄 vjs.zencdn.net / 辅摄 MDN,浏览器实测可达; +-- media.w3.org / Google 桶在部分网络环境会被阻断,勿用),每单主摄/辅摄两路 +-- - 极老库若报 parent_car_id 不能为 NULL,先执行: +-- ALTER TABLE hard_order MODIFY parent_car_id bigint NULL DEFAULT NULL; +-- ================================================================ + + +-- ---------- 一、清理旧测试数据(脚本可重复执行) ---------- +DELETE FROM hard_door_flow WHERE create_by = 'test-manual-settle'; +DELETE FROM hard_auth WHERE create_by = 'test-manual-settle'; +DELETE FROM hard_order WHERE create_by = 'test-manual-settle'; + +-- ---------- 二、自动挑选基础数据 ---------- +-- 智能柜:优先挑有商品配置的柜子(hard_door_manager.id / 设备id / 租户 / 设备编号) +SELECT dm.id, dm.hard_id, dm.tenant_id, dm.device_code +INTO @hard_door_id, @hard_id, @tenant_id, @device_code +FROM hard_door_manager dm +WHERE dm.del_flag = '0' + AND EXISTS ( + SELECT 1 FROM hard_spu_join j + WHERE j.hard_door_id = dm.id AND j.del_flag = '0' AND j.spu_id IS NOT NULL + ) +ORDER BY dm.id +LIMIT 1; + +-- 货柜号:挑该柜配置商品最多的货柜 +SELECT j.cabinet_no INTO @cabinet_no +FROM hard_spu_join j +WHERE j.hard_door_id = @hard_door_id AND j.del_flag = '0' AND j.spu_id IS NOT NULL +GROUP BY j.cabinet_no +ORDER BY COUNT(*) DESC +LIMIT 1; + +-- 商户信息:从设备取 +SELECT MAX(m.merchant_id), MAX(m.dept_id) INTO @merchant_id, @merchant_dept_id +FROM hard_manager m +WHERE m.id = @hard_id AND m.del_flag = '0'; + +-- 商户类型:优先沿用同商户已有订单的 merchant_type(查不到则为 NULL,不影响结算) +SELECT MAX(o.merchant_type) INTO @merchant_type +FROM hard_order o +WHERE o.merchant_id = @merchant_id AND o.merchant_type IS NOT NULL; + +-- 测试用户 + 消费钱包(wallet_type.unique_code='2'),同租户优先 +SELECT w.user_id, w.id INTO @user_id, @wallet_id +FROM pjcloudx_wallet.wallet_manage w +JOIN pjcloudx_wallet.wallet_type t ON t.id = w.wallet_type_id AND t.del_flag = '0' AND t.unique_code = '2' +WHERE w.del_flag = '0' +ORDER BY (w.tenant_id = @tenant_id) DESC, w.id +LIMIT 1; + +-- 用户部门(sys_user 不在本实例时可注释掉,手工 SET @user_dept_id) +SELECT MAX(u.dept_id) INTO @user_dept_id +FROM pjcloudx.sys_user u +WHERE u.user_id = @user_id; + +-- 测试主键基址:各表当前最大 id + 100 亿,避开现有与后续雪花 id +SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @order_id_base FROM hard_order; +SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @flow_id_base FROM hard_door_flow; +SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @auth_id_base FROM hard_auth; + +-- ★ 检查:除 @merchant_type 外下列都不能为 NULL;为 NULL 说明缺前置数据, +-- 请手工 SET 对应变量(或先在 柜机管理页 给柜子上架商品)后重跑 +SELECT @hard_door_id AS 柜配置id, + @hard_id AS 设备id, + @cabinet_no AS 货柜号, + @tenant_id AS 租户, + @merchant_id AS 商户id, + @merchant_dept_id AS 商户部门, + @merchant_type AS 商户类型_可空, + @user_id AS 用户id, + @wallet_id AS 钱包id, + @order_id_base AS 订单id基址; + +-- ---------- 三、生成 10 组 授权 / 流程 / 订单 ---------- + +-- 1. 用户授权(hard_auth):已授权、未完成 +INSERT INTO hard_auth +(id, user_id, hard_id, cabinet_no, auth_type, auth_status, auth_time, is_done, create_by, create_time, del_flag, tenant_id) +SELECT @auth_id_base + n, @user_id, @hard_id, @cabinet_no, 'TEST', '1', + NOW() - INTERVAL (n * 2) HOUR, '0', + 'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id +FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 +) seq; + +-- 2. 智能柜流程(hard_door_flow):卡在"已提交识别、等待旷世回调"(flow_status='50') +INSERT INTO hard_door_flow +(id, user_id, user_type, biz_type, hard_id, hard_door_id, cabinet_no, order_id, auth_id, + request_open_dto, request_open_vo, request_open_time, + is_open, open_callback_time, open_callback_vo, + video_callback_vo, video_callback_time, + sku_submit_dto, sku_submit_vo, sku_submit_recog_id, sku_submit_time, + flow_status, create_by, create_time, del_flag, tenant_id) +SELECT + @flow_id_base + n, @user_id, '0', '10', @hard_id, @hard_door_id, @cabinet_no, + @order_id_base + n, @auth_id_base + n, + -- 请求开门参数/返回 + CONCAT('{"outOpenNo":"', @flow_id_base + n, '","hardDoorId":', @hard_door_id, ',"cabinetNo":', @cabinet_no, ',"userId":', @user_id, '}'), + CONCAT('{"code":0,"msg":"成功","data":{"outOpenNo":"', @flow_id_base + n, '","isOpen":true}}'), + NOW() - INTERVAL (n * 2 - 2) MINUTE, + -- 开门回调(成功开门) + '1', NOW() - INTERVAL (n * 2 - 1) MINUTE, + CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0), '","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS"}'), + -- 视频回调(公网示例视频,主摄/辅摄两路) + CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0), + '","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS","resultMsg":null,"videoList":[', + '{"cameraNo":1,"videoUrl":"https://vjs.zencdn.net/v/oceans.mp4"},', + '{"cameraNo":2,"videoUrl":"https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"}]}'), + NOW() - INTERVAL (n * 2) HOUR + INTERVAL 3 MINUTE, + -- 已提交旷世识别(模拟旷世跑路前的最后一步,识别回调永远不回来) + CONCAT('{"recog_id":"', @flow_id_base + n, '","resource_type":"1","container_id":"', @hard_door_id, + '","sub_mch_id":"', @order_id_base + n, + '","resource_urls":["https://media.w3.org/2010/05/sintel/trailer.mp4"]}'), + CONCAT('{"code":0,"is_success":true,"ks_recog_id":"TEST-RECOG-', n, '","msg":"提交成功"}'), + CONCAT('TEST-RECOG-', n), + NOW() - INTERVAL (n * 2) HOUR + INTERVAL 4 MINUTE, + -- 流程状态 50=已提交识别;sku_callback_*/pay_time 留空(未结算) + '50', 'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id +FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 +) seq; + +-- 3. 订单(hard_order):待结算的智能柜免密订单(形态对齐后端 createHardDoorOrder) +INSERT INTO hard_order +(id, user_id, user_dept_id, merchant_id, merchant_type, merchant_dept_id, hard_id, wallet_id, + order_type, order_status, pay_type, sys_type, + total_price, extra_price, pay_price, is_offline_order, product_desc, + create_by, create_time, del_flag, tenant_id) +SELECT @order_id_base + n, @user_id, @user_dept_id, @merchant_id, @merchant_type, @merchant_dept_id, + @hard_id, @wallet_id, + '1', '1', '9', '4', + 0, 0, 0, '0', CONCAT('智能柜手动结算测试订单-', n), + 'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id +FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 +) seq; + +-- ---------- 四、生成结果检查 ---------- +SELECT o.id AS 订单id, + o.product_desc AS 商品描述, + o.order_status AS 订单状态, + o.pay_type AS 支付方式, + o.sys_type AS 设备类型, + f.id AS 流程id, + f.flow_status AS 流程状态, + f.cabinet_no AS 货柜号, + a.id AS 授权id, + a.is_done AS 授权完成 +FROM hard_order o +JOIN hard_door_flow f ON f.order_id = o.id +JOIN hard_auth a ON a.id = f.auth_id +WHERE o.create_by = 'test-manual-settle' +ORDER BY o.id; + +-- ================================================================ +-- 测试入口与验证步骤 +-- ================================================================ +-- 1. 用对该订单商户有数据范围的管理员账号登录后台 +-- 2. 入口一:订单管理 → 未识别订单(hard-recog,本批次 10 笔会出现在列表)→ 订单详情 +-- 入口二:订单管理 → 订单列表 → 订单详情(售后订单详情页 order-refund) +-- 3. 详情页右侧导航应出现「手动结算」: +-- - 左侧为所选货柜按层商品(来自 hard_spu_join 配置),可调数量 +-- - 右侧为已选购物车 + 自动播放的公网示例视频(主摄/辅摄可切换) +-- 4. 点「手动结算」→ 确认 → 期望: +-- - 订单 order_status 变为 5(已支付)、pay_time/pay_price/total_price 写入 +-- - 生成 hard_parent_car / hard_car 商品明细,详情页"购物车"区出现商品 +-- - 智能柜库存扣减(hard_spu.repertory_count / hard_spu_join.repertory_count 减少) +-- - hard_door_flow.flow_status 变 70、pay_time 写入、sku_callback_vo 记录手动结算留痕 +-- - hard_auth.is_done 变 1 +-- - 钱包库生成一笔 PAYMENT 扣款流水(允许透支) +-- 5. 点「用户未拿商品,取消订单」→ 确认 → 期望:order_status 变 30(已取消)、 +-- flow 的 sku_callback_vo 记录手动取消留痕 +-- +-- ---------- 测试完成后的清理 ---------- +-- 重新执行本脚本第一、二节即可重置 10 笔新订单; +-- 已结算订单产生的购物车明细按需清理: +-- DELETE pc, c FROM hard_parent_car pc +-- LEFT JOIN hard_car c ON c.parent_car_id = pc.id +-- WHERE pc.id IN (SELECT parent_car_id FROM hard_order +-- WHERE create_by = 'test-manual-settle' AND parent_car_id IS NOT NULL); +-- 钱包流水在钱包库 wallet_transaction(biz_type='PAYMENT', biz_id=订单id),按需处理 diff --git a/db/20260910_add_hard_print_task_quartz_job.sql b/db/20260910_add_hard_print_task_quartz_job.sql new file mode 100644 index 000000000..7c29e5ebb --- /dev/null +++ b/db/20260910_add_hard_print_task_quartz_job.sql @@ -0,0 +1,42 @@ +-- ===================================================================== +-- 打印任务补偿扫描:由 hard 模块内 @Scheduled 改为 quartz 调度中心统一触发 +-- 接口:GET /hard-invoice/task/process-due(HardInvoiceController#processDueTasks) +-- +-- 上线步骤: +-- 1. 按部署环境修改 execute_path(默认为本机直连示例): +-- - 直连硬件服务(推荐,调度中心与服务同内网): +-- http://:6080/hard-invoice/task/process-due +-- - 走网关: +-- http://<网关地址>:9999/hard/hard-invoice/task/process-due +-- 2. 执行本 INSERT; +-- 3. 在管理端「工具 / 任务管理」页面找到该任务点击「启动」 +-- (job_status=1 已发布不会自动运行,启动后变为 2 运行中)。 +-- +-- 注意: +-- * pjcloud-daemon-quartz 当前 quartz isClustered=false,调度中心必须单实例部署; +-- 调度中心多实例会导致同一触发点被多次执行(接口内分布式锁仅兜底重叠执行, +-- 不能区分多次正常触发)。 +-- * 任务必须挂在租户 1 下管理;接口内部已跳过租户过滤,扫描全部租户的任务。 +-- * 该 SQL 不含 ON DUPLICATE KEY,重复执行会主键冲突;若已在页面调整过任务, +-- 请勿重复执行,以免覆盖手工配置。 +-- ===================================================================== +INSERT INTO `pjcloudx_job`.`sys_job` ( + `job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, + `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`, + `job_execute_status`, `create_by`, `tenant_id`, `remark` +) VALUES ( + 1949000000000000001, + 'hard-print-task-process-due', + 'DEFAULT', + '1', + '3', + 'http://127.0.0.1:6080/hard-invoice/task/process-due', + '0/10 * * * * ?', + '3', + '2', + '1', + '0', + 'admin', + 1, + '智能柜打印任务补偿扫描(原 HardPrintTaskJob,每10秒扫描待生成/待补偿下发的打印任务)' +); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardOfflineUserSyncDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardOfflineUserSyncDto.java new file mode 100644 index 000000000..fa9a0ec40 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardOfflineUserSyncDto.java @@ -0,0 +1,37 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * Android 终端离线人员快照分页请求。 + * + *

按设备绑定部门树的用户主键升序做固定上界游标分页;部门和钱包类型均由 + * DEVICE-ID 解析,终端不可指定范围。首次请求 snapshotMaxId 传空,由服务端 + * 固定本次快照上界,后续请求必须原样携带。

+ */ +@Data +public class HardOfflineUserSyncDto { + + /** + * 排他游标;首页传 0,之后使用上一页返回的 nextAfterId。 + */ + @NotNull(message = "离线人员同步游标不能为空") + @Min(value = 0, message = "离线人员同步游标不能小于0") + private Long afterId; + + /** + * 单页用户数量;默认 200,最大 500。 + */ + @Min(value = 1, message = "离线人员同步分页大小必须大于0") + @Max(value = 500, message = "离线人员同步分页大小不能超过500") + private Integer limit; + + /** + * 首次请求由服务端固定的快照用户主键上界;后续请求原样携带。 + */ + @Min(value = 0, message = "离线人员同步快照上界无效") + private Long snapshotMaxId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardDoorManualSettleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardDoorManualSettleDto.java index 451932475..1e4d856d7 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardDoorManualSettleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardDoorManualSettleDto.java @@ -17,6 +17,9 @@ public class HardDoorManualSettleDto { @Schema(description = "手动选择的商品列表", requiredMode = Schema.RequiredMode.REQUIRED) private List items; + @Schema(description = "操作备注(选填,记录人工判断依据)") + private String reason; + @Data public static class Item { diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardDoorFlowEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardDoorFlowEntity.java index c1b02c357..24fd27a31 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardDoorFlowEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardDoorFlowEntity.java @@ -182,6 +182,12 @@ public class HardDoorFlowEntity extends Model { @Schema(description = "sku识别主动获取时间") private LocalDateTime skuDrivingTime; + /** + * 手动结算/取消操作快照(操作人+购物车明细+金额+备注) + */ + @Schema(description = "手动结算/取消操作快照(操作人+购物车明细+金额+备注)") + private String manualSettleVo; + /** * 支付时间 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserSyncSnapshotVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserSyncSnapshotVo.java new file mode 100644 index 000000000..57380a721 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserSyncSnapshotVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo.android; + +import lombok.Data; + +import java.util.List; + +/** + * Android 终端离线人员快照分页结果。 + */ +@Data +public class HardOfflineUserSyncSnapshotVo { + + /** + * 当前页用户记录;服务端按用户主键严格升序返回,仅包含绑定了可用钱包的用户。 + */ + private List records; + + /** + * 下一页排他游标;末页等于当前请求游标。 + */ + private Long nextAfterId; + + /** + * 是否还有位于固定快照上界内的下一页。 + */ + private Boolean hasMore; + + /** + * 本次快照固定的用户主键上界;分页期间数据变化不会扩大同步范围。 + */ + private Long snapshotMaxId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserVo.java new file mode 100644 index 000000000..ef936ea0d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/HardOfflineUserVo.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.hard.api.vo.android; + +import lombok.Data; + +/** + * Android 终端离线人员快照记录。 + * + *

仅包含断网消费匹配和展示所需的字段:姓名已按首字符脱敏,不返回手机号、 + * 头像等个人信息;余额为同步时刻的服务端快照,离线扣款后以本地订单为准。

+ */ +@Data +public class HardOfflineUserVo { + + /** + * 用户ID;同时作为快照分页游标。 + */ + private Long userId; + + /** + * 脱敏用户名,例如“张*”。 + */ + private String username; + + /** + * 钱包卡号;断网刷离线饭卡时的匹配键。 + */ + private String walletCardNumber; + + /** + * 钱包id。 + */ + private Long walletId; + + /** + * 钱包类型id。 + */ + private Long walletTypeId; + + /** + * 钱包余额(分)快照。 + */ + private Integer walletBalance; + + /** + * 用户所属的商户id,主要用于机器的离线模式。 + */ + private Long canteenId; +} 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 ce83ad1cf..324e9f6e7 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,7 +6,6 @@ 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 @@ -18,7 +17,6 @@ import org.springframework.scheduling.annotation.EnableScheduling; @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 71fbfaab9..e0632548f 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 @@ -6,13 +6,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; 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.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 lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -32,6 +37,7 @@ import jakarta.validation.Valid; * @author dzc * @date 2025-01-05 14:17:32 */ +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/hard-invoice") @@ -41,6 +47,42 @@ public class HardInvoiceController { private final HardInvoiceService hardInvoiceService; + private final RedissonClient redissonClient; + + /** 打印任务补偿扫描的跨实例互斥锁键。 */ + private static final String PROCESS_DUE_LOCK_KEY = "hard:print:process-due"; + + /** + * 打印任务补偿扫描入口,由 quartz 调度中心(sys_job 的 rest 任务)定时触发。 + * + *

调度中心单点触发配合分布式锁保证同一时刻全集群至多一轮扫描,替代原 @Scheduled + * 在集群环境下各节点重复扫描、重复下发 MQTT 的行为。请求头若带 TENANT_ID 会被 + * 租户过滤器限定为单租户,这里显式跳过租户过滤以扫描全部租户的任务,任务内部 + * 仍按各自租户上下文处理。

+ */ + @Operation(summary = "定时任务补偿扫描打印任务", description = "quartz 定时任务调用,扫描待生成与待补偿下发的打印任务") + @Inner(value = false) + @GetMapping("/task/process-due") + public R processDueTasks() { + RLock lock = redissonClient.getLock(PROCESS_DUE_LOCK_KEY); + if (!lock.tryLock()) { + log.info("打印任务补偿扫描上一轮尚未结束,本轮跳过"); + return R.ok(0); + } + try { + TenantContextHolder.setTenantSkip(); + int processed = hardInvoiceService.processDueTasks(); + if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed); + return R.ok(processed); + } catch (Exception exception) { + log.error("打印任务补偿扫描异常", exception); + return R.failed("打印任务补偿扫描异常:" + exception.getMessage()); + } finally { + TenantContextHolder.clear(); + lock.unlock(); + } + } + @Operation(summary = "后台打印", description = "后台打印") @SysLog("后台打印") 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 d51639ad0..1c1573490 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 @@ -22,6 +22,7 @@ import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; +import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; @@ -340,6 +341,21 @@ public class HardApiController { return R.ok(hardApiService.getUserByCard(card)); } + /** + * 按当前设备绑定部门树同步脱敏后的离线人员快照。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param dto 排他游标、分页大小和固定快照上界 + * @return 当前页离线人员、下一游标和快照上界 + */ + @PostMapping("/offline-user/sync-snapshot") + @Operation(summary = "业务终端同步离线人员快照", description = "按设备绑定部门返回脱敏姓名的离线消费人员游标快照") + public R syncOfflineUserSnapshot( + @RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardOfflineUserSyncDto dto) { + return R.ok(hardApiService.syncOfflineUserSnapshot(hardId, dto)); + } + @GetMapping("/chart-latitude") @Operation(summary = "统计每天、每周、每月、每年的交易人数和次数") public R chartLatitude(@RequestParam Long merchantId) { 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 02efec9d2..b37225222 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 @@ -330,7 +330,7 @@ public class HardOrderController { @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_manualSettle')") public R manualSettle(@RequestBody HardDoorManualSettleDto dto) { PjcloudUser user = SecurityUtils.getUser(); - hardwareOrderService.manualSettleHardDoorOrder(dto, user.getUsername()); + hardwareOrderService.manualSettleHardDoorOrder(dto, user.getId(), user.getUsername()); return R.ok(); } @@ -339,7 +339,7 @@ public class HardOrderController { @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_manualSettle')") public R manualCancel(@RequestBody HardDoorManualSettleDto dto) { PjcloudUser user = SecurityUtils.getUser(); - hardwareOrderService.manualCancelHardDoorOrder(dto.getOrderId(), user.getUsername()); + hardwareOrderService.manualCancelHardDoorOrder(dto.getOrderId(), user.getId(), user.getUsername(), dto.getReason()); return R.ok(); } 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 deleted file mode 100644 index 449a6f65a..000000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java +++ /dev/null @@ -1,32 +0,0 @@ -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/service/HardApiService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApiService.java index 5fb2f5957..2fac6925b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApiService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApiService.java @@ -1,9 +1,9 @@ package com.bsd.pjcloud.hard.service; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; -import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; +import com.bsd.pjcloud.hard.api.dto.hard.HardOfflineUserSyncDto; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; -import jakarta.validation.Valid; +import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo; import java.util.List; @@ -12,4 +12,12 @@ public interface HardApiService { HardAdUserVo getUserById(Long userId, HardTypeEnum type); HardAdUserVo getUserByCard(String card); + + /** + * 按设备绑定部门树返回离线人员固定上界游标快照。 + * + *

记录仅包含断网消费所需字段,姓名脱敏且不返回手机号头像;未绑定可用 + * 钱包或钱包已冻结的用户不会出现在快照中。

+ */ + HardOfflineUserSyncSnapshotVo syncOfflineUserSnapshot(Long hardId, HardOfflineUserSyncDto dto); } 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 6190b25f3..e7470af96 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 @@ -123,12 +123,12 @@ public interface HardOrderService extends IService { /** * 手动结算智能柜订单(第三方识别不可用时,人工核对视频后结算) */ - Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, String operator); + Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, Long operatorId, String operatorName); /** * 手动取消智能柜订单(用户未拿商品) */ - Long manualCancelHardDoorOrder(Long orderId, String operator); + Long manualCancelHardDoorOrder(Long orderId, Long operatorId, String operatorName, String reason); /** * 快速扣款 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApiServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApiServiceImpl.java index ff56e27e0..ee09c0446 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApiServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApiServiceImpl.java @@ -1,14 +1,25 @@ package com.bsd.pjcloud.hard.service.impl; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.bsd.pjcloud.admin.api.dto.DeptAllListDto; import com.bsd.pjcloud.admin.api.dto.UserInfo; +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.RemoteUserService; -import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.dto.hard.HardOfflineUserSyncDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; +import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo; +import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.HardApiService; +import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -19,7 +30,15 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Validated @Slf4j @@ -27,9 +46,17 @@ import java.util.List; @Service public class HardApiServiceImpl implements HardApiService { + /** + * 离线人员快照默认分页大小;与 DTO 上限保持一致由校验注解约束。 + */ + private static final int OFFLINE_USER_SYNC_DEFAULT_LIMIT = 200; + private final RemoteUserService userService; private final RemoteWalletService remoteWalletService; private final RemoteMchService remoteMchService; + private final RemoteDeptService remoteDeptService; + private final HardManagerMapper hardManagerMapper; + private final HardManagerService hardManagerService; @Override public HardAdUserVo getUserById(Long userId, HardTypeEnum type) { @@ -92,4 +119,216 @@ public class HardApiServiceImpl implements HardApiService { } return result; } + + /** + * 按设备绑定部门树返回脱敏后的离线人员游标快照。 + * + *

部门范围、钱包类型全部由 DEVICE-ID 对应的真实绑定关系解析;姓名只保留 + * 首字符,其余以星号替代,且不返回手机号和头像。未绑定当前类型钱包、卡号为空 + * 或钱包已冻结的用户不进入快照;游标始终按部门全部用户主键推进,被过滤用户 + * 不会造成分页重复。

+ */ + @Override + public HardOfflineUserSyncSnapshotVo syncOfflineUserSnapshot(Long hardId, HardOfflineUserSyncDto dto) { + TrustedDeviceContext trustedDevice = resolveTrustedDevice(hardId); + HardTypeEnum hardType = HardTypeEnum.valueOfByType(trustedDevice.hardType()); + if (!ObjectUtil.equals(HardTypeEnum.CANTEEN, hardType) + && !ObjectUtil.equals(HardTypeEnum.SUPERMARKET, hardType)) { + throw new RuntimeException("当前设备类型不支持离线人员同步"); + } + WalletTypeEnum walletType = ObjectUtil.equals(HardTypeEnum.CANTEEN, hardType) + ? WalletTypeEnum.EAT + : WalletTypeEnum.PAY; + + List deptIds = resolveDepartmentTree(trustedDevice.merchantDeptId()); + if (deptIds.isEmpty()) { + throw new RuntimeException("设备绑定部门不存在或已删除"); + } + + R> userResponse = userService.getUserIdListByDeptIdList(deptIds); + if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null) { + String message = userResponse == null || StrUtil.isBlank(userResponse.getMsg()) + ? "查询部门用户失败" + : userResponse.getMsg(); + throw new RuntimeException(message); + } + List users = userResponse.getData().stream() + .filter(user -> user.getUserId() != null && StrUtil.isNotBlank(user.getName())) + .sorted(Comparator.comparing(SysUser::getUserId)) + .toList(); + + long normalizedAfterId = dto.getAfterId(); + Long requestedSnapshotMaxId = dto.getSnapshotMaxId(); + if (requestedSnapshotMaxId == null) { + requestedSnapshotMaxId = users.isEmpty() ? 0L : users.get(users.size() - 1).getUserId(); + } + final long normalizedSnapshotMaxId = requestedSnapshotMaxId; + if (normalizedAfterId > normalizedSnapshotMaxId) { + throw new RuntimeException("离线人员同步游标超过快照上界"); + } + int normalizedLimit = dto.getLimit() == null ? OFFLINE_USER_SYNC_DEFAULT_LIMIT : dto.getLimit(); + + List fetchedPage = users.stream() + .filter(user -> user.getUserId() > normalizedAfterId + && user.getUserId() <= normalizedSnapshotMaxId) + .limit(normalizedLimit + 1L) + .toList(); + boolean hasMore = fetchedPage.size() > normalizedLimit; + List pageUsers = hasMore ? fetchedPage.subList(0, normalizedLimit) : fetchedPage; + + HardOfflineUserSyncSnapshotVo snapshot = new HardOfflineUserSyncSnapshotVo(); + snapshot.setRecords(buildOfflineUserRecords(pageUsers, walletType)); + // 游标按快照全集推进;本页末尾用户即使因无钱包被过滤也不会导致重复拉取。 + snapshot.setNextAfterId(pageUsers.isEmpty() + ? normalizedAfterId + : pageUsers.get(pageUsers.size() - 1).getUserId()); + snapshot.setHasMore(hasMore); + snapshot.setSnapshotMaxId(normalizedSnapshotMaxId); + return snapshot; + } + + /** + * 将一页部门用户组装为脱敏离线人员记录;食堂归属按部门聚合缓存一次查询。 + */ + private List buildOfflineUserRecords( + List pageUsers, + WalletTypeEnum walletType + ) { + if (pageUsers.isEmpty()) { + return List.of(); + } + List userIds = pageUsers.stream().map(SysUser::getUserId).toList(); + R> walletResponse = remoteWalletService.getWalletsByUserIds(userIds, walletType); + if (walletResponse == null || !walletResponse.isOk() || walletResponse.getData() == null) { + String message = walletResponse == null || StrUtil.isBlank(walletResponse.getMsg()) + ? "查询用户钱包失败" + : walletResponse.getMsg(); + throw new RuntimeException(message); + } + Map walletByUserId = walletResponse.getData().stream() + .filter(wallet -> wallet.getUserId() != null) + .collect(Collectors.toMap( + WalletManageEntity::getUserId, + Function.identity(), + (first, second) -> first + )); + + Map canteenByDeptId = new HashMap<>(); + List records = new ArrayList<>(pageUsers.size()); + for (SysUser user : pageUsers) { + WalletManageEntity wallet = walletByUserId.get(user.getUserId()); + // 无卡用户无法离线刷卡,冻结钱包不可消费,均不进入离线快照。 + if (wallet == null || StrUtil.isBlank(wallet.getCardNumber())) { + continue; + } + if (ObjectUtil.equals(wallet.getIsFreeze(), YesNoEnum.YES.getCode())) { + continue; + } + HardOfflineUserVo record = new HardOfflineUserVo(); + record.setUserId(user.getUserId()); + record.setUsername(maskUsername(user.getName())); + record.setWalletCardNumber(wallet.getCardNumber()); + record.setWalletId(wallet.getId()); + record.setWalletTypeId(wallet.getWalletTypeId()); + record.setWalletBalance(wallet.getBalance()); + record.setCanteenId(resolveCanteenId(user.getDeptId(), canteenByDeptId)); + records.add(record); + } + return records; + } + + /** + * 按部门查询食堂商户并缓存;查询失败不阻断人员同步,仅本条记录缺少食堂归属。 + */ + private Long resolveCanteenId(Long deptId, Map cache) { + if (deptId == null) { + return null; + } + if (cache.containsKey(deptId)) { + return cache.get(deptId); + } + Long canteenId = null; + try { + R response = remoteMchService.getViewsCanteenMchByDeptId(deptId); + if (response != null && response.isOk() && response.getData() != null) { + canteenId = response.getData().getId(); + } + } catch (Exception error) { + log.warn("离线人员同步查询部门食堂失败 deptId:{}", deptId, error); + } + cache.put(deptId, canteenId); + return canteenId; + } + + /** + * 姓名脱敏:仅保留首字符,两字姓名补一个星号,更长姓名统一补两个星号。 + */ + private String maskUsername(String name) { + if (StrUtil.isBlank(name)) { + return "未知"; + } + if (name.length() == 1) { + return name; + } + return name.charAt(0) + (name.length() == 2 ? "*" : "**"); + } + + /** + * 在租户全部部门中收集绑定部门及其子孙部门主键。 + */ + private List resolveDepartmentTree(Long merchantDeptId) { + R> response = remoteDeptService.getInnerAllDept(new DeptAllListDto()); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null || StrUtil.isBlank(response.getMsg()) + ? "查询部门树失败" + : response.getMsg(); + throw new RuntimeException(message); + } + Set deptIds = new HashSet<>(); + deptIds.add(merchantDeptId); + collectDescendants(response.getData(), merchantDeptId, deptIds); + return new ArrayList<>(deptIds); + } + + /** + * 深度优先收集子孙部门;已访问主键直接跳过,防御异常父指针成环。 + */ + private void collectDescendants(List allDepts, Long parentId, Set result) { + for (SysDept dept : allDepts) { + if (parentId.equals(dept.getParentId()) && result.add(dept.getDeptId())) { + collectDescendants(allDepts, dept.getDeptId(), result); + } + } + } + + /** + * 校验 DEVICE-ID 对应当前租户真实设备,并解析设备绑定商户、部门与设备类型。 + */ + private TrustedDeviceContext resolveTrustedDevice(Long hardId) { + if (hardId == null) { + throw new RuntimeException("DEVICE-ID不能为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) { + throw new RuntimeException("当前租户下设备不存在"); + } + if (hard.getMerchantId() == null) { + throw new RuntimeException("设备尚未绑定商户"); + } + HardManagerVo hardDetail = hardManagerService.getByHardId(hardId); + if (hardDetail == null || hardDetail.getMerchantDeptId() == null) { + throw new RuntimeException("设备绑定商户缺少部门"); + } + return new TrustedDeviceContext( + hard.getMerchantId(), + hardDetail.getMerchantDeptId(), + hard.getType() + ); + } + + /** + * 服务端可信设备上下文:设备自身并不携带任何商户信息,全部由绑定关系解析。 + */ + private record TrustedDeviceContext(Long merchantId, Long merchantDeptId, String hardType) { + } } 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 40bdf7761..fa6134f91 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 @@ -681,7 +681,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl manualVo = new LinkedHashMap<>(); - manualVo.put("manual", true); - manualVo.put("manualCancel", isCancel); - manualVo.put("operator", operator); - manualVo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - if (ObjectUtil.isNotEmpty(dto)) { - manualVo.put("orderId", dto.getOrderId()); - manualVo.put("items", dto.getItems()); + private String buildManualSettleVo(HardOrderEntity order, HardDoorManualSettleDto dto, List carListDto, Long operatorId, String operatorName) { + Map vo = new LinkedHashMap<>(); + vo.put("manual", true); + vo.put("manualCancel", false); + vo.put("operatorId", operatorId); + vo.put("operator", operatorName); + vo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + vo.put("orderId", order.getId()); + if (ObjectUtil.isNotEmpty(dto) && ObjectUtil.isNotEmpty(dto.getReason())) { + vo.put("reason", dto.getReason()); } - return JSONUtil.toJsonStr(manualVo); + vo.put("payChannel", ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.TOC_VISITOR.getType()) ? "支付宝免密扣款" : "钱包强制扣款"); + // 购物车快照:商品名/单价以结算时刻为准 + List> items = new ArrayList<>(); + int totalPrice = 0; + List spuList = ObjectUtil.isEmpty(carListDto) + ? List.of() + : hardSpuService.getByIds(carListDto.stream().map(HardCreateCarDto::getProductId).toList()); + for (HardCreateCarDto car : carListDto) { + HardSpuEntity spu = spuList.stream().filter(v -> ObjectUtil.equals(v.getId(), car.getProductId())).findFirst().orElse(null); + int price = ObjectUtil.isEmpty(spu) || ObjectUtil.isEmpty(spu.getPrice()) ? 0 : spu.getPrice(); + Map item = new LinkedHashMap<>(); + item.put("productId", car.getProductId()); + item.put("name", ObjectUtil.isEmpty(spu) ? String.valueOf(car.getProductId()) : spu.getName()); + item.put("price", price); + item.put("productCount", car.getProductCount()); + item.put("totalPrice", price * car.getProductCount()); + totalPrice += price * car.getProductCount(); + items.add(item); + } + vo.put("items", items); + vo.put("totalPrice", totalPrice); + return JSONUtil.toJsonStr(vo); + } + + /** + * 构建智能柜手动取消快照 + */ + private String buildManualCancelVo(Long orderId, Long operatorId, String operatorName, String reason) { + Map vo = new LinkedHashMap<>(); + vo.put("manual", true); + vo.put("manualCancel", true); + vo.put("operatorId", operatorId); + vo.put("operator", operatorName); + vo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + vo.put("orderId", orderId); + if (ObjectUtil.isNotEmpty(reason)) { + vo.put("reason", reason); + } + return JSONUtil.toJsonStr(vo); } @Override diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java index 52ec87606..8be880c85 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/api/feign/RemoteMqttEventService.java @@ -18,7 +18,7 @@ import java.util.List; public interface RemoteMqttEventService { @NoToken - @GetMapping("/mqtt/send-default-msg") + @PostMapping("/mqtt/send-default-msg") R sendDefaultMsg(@RequestBody MqttBaseDto dto); /** @@ -43,11 +43,12 @@ public interface RemoteMqttEventService { R sendCanteenClassifyChangeMsg(@RequestParam Long deptId); /** - * 超市商品变化 + * 超市商品变化;spuIds 非空时终端只定向同步这些商品 */ @NoToken @GetMapping("/mqtt/send-supermarket-product-change-msg") - R sendSupermarketProductChangeMsg(@RequestParam Long deptId); + R sendSupermarketProductChangeMsg(@RequestParam Long deptId, + @RequestParam(value = "spuIds", required = false) List spuIds); /** * 超市分类变化 diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java new file mode 100644 index 000000000..85a7ae1df --- /dev/null +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardSupermarketProductMsg.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.mqtt.dto.hard; + +import com.bsd.pjcloud.mqtt.dto.MqttHardBaseMsg; +import lombok.Data; + +import java.util.List; + +/** + * 超市商品变更消息,携带发生变化的商品 SPU 编号供终端定向同步; + * 未携带编号时终端回退为常规增量同步。 + */ +@Data +public class HardSupermarketProductMsg extends MqttHardBaseMsg { + + private List spuIds; + +} diff --git a/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java b/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java index 53ee11dd2..2698dc067 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java +++ b/pjcloud-mqtt/pjcloud-mqtt-biz/src/main/java/com/bsd/pjcloud/mqtt/controller/MqttEventController.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.mqtt.constant.MqttActionEnum; import com.bsd.pjcloud.mqtt.dto.MqttBaseDto; import com.bsd.pjcloud.mqtt.dto.hard.HardCustomerMsg; import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg; +import com.bsd.pjcloud.mqtt.dto.hard.HardSupermarketProductMsg; import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg; import com.bsd.pjcloud.mqtt.service.MqttEventService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -77,13 +78,20 @@ public class MqttEventController { */ @Inner @GetMapping("/send-supermarket-product-change-msg") - public R sendSupermarketProductChangeMsg(@RequestParam Long deptId) { + public R sendSupermarketProductChangeMsg(@RequestParam Long deptId, + @RequestParam(required = false) List spuIds) { MqttBaseDto dto = new MqttBaseDto<>(); mqttEventService.getTopicParamsByUserDeptId(dto, deptId); if (ObjectUtil.isNotEmpty(deptId)) { dto.setMerchantDeptId(deptId); } dto.setAction(MqttActionEnum.SUPERMARKET_PRODUCT); + // 携带变更商品编号时终端只定向同步这些商品,否则回退常规增量同步 + if (ObjectUtil.isNotEmpty(spuIds)) { + HardSupermarketProductMsg msg = new HardSupermarketProductMsg(); + msg.setSpuIds(spuIds); + dto.setData(msg); + } return R.ok(mqttEventService.defaultAction(dto)); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java index 10ef8edc2..7988d5970 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SupermarketSpuQueryDTO.java @@ -3,9 +3,13 @@ package com.bsd.pjcloud.supermarket.dto; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + @Data @Schema(description = "AD商品 SPU 查询参数") public class SupermarketSpuQueryDTO { @@ -15,6 +19,12 @@ public class SupermarketSpuQueryDTO { @Schema(description="商品 SPU 编号,自增") private Long id; + /** + * 商品 SPU 编号集合,定向同步指定商品 + */ + @Schema(description="商品 SPU 编号集合,定向同步指定商品") + private List ids; + /** * 货号 */ @@ -45,4 +55,12 @@ public class SupermarketSpuQueryDTO { @Schema(description="商品性质(字典)") private String productNature; + /** + * 增量同步起点:只返回该时间之后有变化的商品(含已删除的墓碑行)。 + * 传入后查询不再过滤上架/删除状态,靠返回的 delFlag 区分。 + */ + @Schema(description="增量同步起点,只返回该时间之后有变化的商品(含删除墓碑)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java index 16030bc88..88527fc6d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/ad/SpuVo.java @@ -87,6 +87,12 @@ public class SpuVo { @Schema(description="创建时间") private LocalDateTime createTime; + /** + * 是否删除;增量同步时为 "1" 的行是服务端删除墓碑,终端应清理本地对应商品 + */ + @Schema(description="是否删除,增量同步墓碑标记") + private String delFlag; + } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml index 6dded74c2..8e8f416f0 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml @@ -123,6 +123,13 @@ pjcloud-common-sequence 5.7.0 + + + com.bsd + pjcloud-mqtt-api + 5.7.0 + compile + diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java index b1263473a..777c5a2be 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmErpSpuServiceImpl.java @@ -43,7 +43,9 @@ import com.bsd.pjcloud.supermarket.vo.erpspu.SmErpSpuStockExcel; import com.bsd.pjcloud.supermarket.vo.sm.smspu.SmErpPageScopeVo; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.annotations.ParameterObject; @@ -71,6 +73,7 @@ import java.util.stream.Collectors; * @author zjf * @date 2024-12-23 10:42:43 */ +@Slf4j @Service @AllArgsConstructor public class SmErpSpuServiceImpl extends ServiceImpl implements SmErpSpuService { @@ -79,6 +82,8 @@ public class SmErpSpuServiceImpl extends ServiceImpl list) { + // 删除前留档部门归属,删除后按部门通知终端 + List spuEntities = baseMapper.selectByIds(list); + // 逻辑删除不会更新 update_time,先补一次更新时间保证终端增量同步能拉到删除墓碑 + baseMapper.update(null, new LambdaUpdateWrapper() + .set(SmErpSpuEntity::getUpdateTime, LocalDateTime.now()) + .in(SmErpSpuEntity::getId, list)); baseMapper.deleteByIds(list); //删除 @@ -504,9 +520,44 @@ public class SmErpSpuServiceImpl extends ServiceImpl> deptSpuIds = spuEntities.stream() + .filter(v -> v.getDeptId() != null) + .collect(Collectors.groupingBy(SmErpSpuEntity::getDeptId, + Collectors.mapping(SmErpSpuEntity::getId, Collectors.toList()))); + deptSpuIds.forEach(this::notifySupermarketProductChange); + return R.ok(); } + /** + * 入参实体可能不携带部门信息,缺失时回查数据库。 + */ + private Long resolveSpuDeptId(SmErpSpuEntity supermarketSpu) { + if (supermarketSpu.getDeptId() != null) { + return supermarketSpu.getDeptId(); + } + if (supermarketSpu.getId() == null) { + return null; + } + SmErpSpuEntity dbSpu = baseMapper.selectById(supermarketSpu.getId()); + return dbSpu != null ? dbSpu.getDeptId() : null; + } + + /** + * 超市商品变更后通知终端定向同步;MQTT 发送失败只记日志,不影响业务事务。 + */ + private void notifySupermarketProductChange(Long deptId, List spuIds) { + if (deptId == null || CollectionUtils.isEmpty(spuIds)) { + return; + } + try { + remoteMqttEventService.sendSupermarketProductChangeMsg(deptId, spuIds); + } catch (Exception e) { + log.error("|SmErpSpuServiceImpl|通知终端同步超市商品变更失败deptId:|{}|spuIds:|{}|", deptId, spuIds, e); + } + } + @Override @Transactional public R updateSpuStock(SupermarketSpuUpdateStockReqDTO updateStockReqDTO) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml index e2a78fd99..8018725c3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmErpSpuMapper.xml @@ -38,6 +38,7 @@ es.sales_count, es.dept_id, es.create_by, + es.del_flag, u.unit, u.spec, u.retail_price, @@ -46,13 +47,27 @@ FROM sm_erp_spu es left join sm_erp_unit u on es.id = u.spu_id and u.is_base=1 - left join sm_erp_stock st - on es.id = st.erp_spu_id + left join sm_erp_stock st + on es.id = st.erp_spu_id - u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0 + + + + es.update_time >= #{supermarketSpuQueryDTO.updateTime} + + + u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0 + + AND es.id = #{supermarketSpuQueryDTO.id} + + AND es.id IN + + #{spuIdItem} + + AND es.product_code = #{supermarketSpuQueryDTO.productCode} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml index 9364a3abe..4e201dfd1 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/application.yml @@ -9,8 +9,8 @@ spring: name: @artifactId@ cloud: nacos: - username: @nacos.username@ - password: @nacos.password@ + username: nacos + password: Aa@bsd.321 discovery: server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848} config: 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 73a32887d..74996c4eb 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java @@ -30,6 +30,10 @@ public interface RemoteWalletService { @GetMapping("/wallet-manage/get-wallet") R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode); + @NoToken + @GetMapping("/wallet-manage/get-wallets-by-user-ids") + R> getWalletsByUserIds(@RequestParam List userIds, @RequestParam WalletTypeEnum walletTypeCode); + @NoToken @GetMapping("/wallet-manage/get-wallet-by-wallet-id") R getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index 49d7012b8..729c98627 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -143,6 +143,19 @@ public class WalletManageController { return R.ok(wallet); } + /** + * 按用户 id 列表批量查询同一钱包类型的钱包。 + * + *

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

+ */ + @Inner + @GetMapping("/get-wallets-by-user-ids") + public R> getWalletsByUserIds(@RequestParam List userIds, + @RequestParam WalletTypeEnum walletTypeCode) { + return R.ok(walletManageService.getWalletsByUserIds(userIds, walletTypeCode)); + } + @Inner @GetMapping("/del-wallet") public R delWallet(@RequestParam("userIds") List userIds) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 45f93f985..b02346ac5 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -37,6 +37,14 @@ public interface WalletManageService extends IService { */ WalletManageEntity getWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode); + /** + * 按用户 id 列表批量获取同一钱包类型的全部钱包(没有缓存)。 + * + *

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

+ */ + List getWalletsByUserIds(List<@NotNull Long> userIds, @NotNull WalletTypeEnum walletTypeCode); + /** * 获取钱包(没有缓存) */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index 37424437a..4c58322c0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -71,6 +71,11 @@ public class WalletManageServiceImpl extends ServiceImpl getWalletsByUserIds(List userIds, WalletTypeEnum walletTypeCode) { + if (ObjectUtil.isEmpty(userIds)) { + return List.of(); + } + WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode); + if (ObjectUtil.isEmpty(walletType)) { + return List.of(); + } + // 单条 in 查询按批拆分,避免部门人数较多时超出数据库参数上限。 + List wallets = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += WALLETS_BY_USER_IDS_BATCH) { + int end = Math.min(start + WALLETS_BY_USER_IDS_BATCH, userIds.size()); + wallets.addAll(baseMapper.selectList(new LambdaQueryWrapper() + .in(WalletManageEntity::getUserId, userIds.subList(start, end)) + .eq(WalletManageEntity::getWalletTypeId, walletType.getId()) + )); + } + return wallets; + } + @Override public WalletManageEntity getWallet(String cardNumber) { return baseMapper.selectOne(new LambdaQueryWrapper()