diff --git a/db/20260814_add_app_message_push.sql b/db/20260814_add_app_message_push.sql new file mode 100644 index 000000000..9358869ad --- /dev/null +++ b/db/20260814_add_app_message_push.sql @@ -0,0 +1,305 @@ +-- 商户业务消息中心、多推送供应商适配和业务 Transactional Outbox。 +-- 本脚本只新增独立表及表内索引,不修改 wallet_transaction、third_push_conf、third_push_record、 +-- third_push_record_user 或其他既有表,不回填历史消息,也不创建跨库外键。 + +-- 每个接收人持久化一条消息快照。本人消息和关系消息共用收件箱,但通过 recipient_type 明确来源。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_message` +( + `id` bigint(20) NOT NULL COMMENT '消息主键,由应用使用雪花算法生成', + `event_id` varchar(128) NOT NULL COMMENT '跨服务业务事件幂等ID,由来源Outbox稳定生成', + `event_code` varchar(64) NOT NULL COMMENT '固定业务事件编码,例如WALLET_PAYMENT或MEAL_VERIFIED', + `recipient_user_id` bigint(20) NOT NULL COMMENT '消息接收用户ID,App只能查询当前登录用户的数据', + `subject_user_id` bigint(20) NOT NULL COMMENT '事件主体用户ID,本人消息与接收人相同,关系消息为被监护人', + `recipient_type` varchar(16) NOT NULL COMMENT '接收类型,OWNER本人或RELATION关系人', + `relation_id` bigint(20) DEFAULT NULL COMMENT '生成关系消息时命中的关系记录ID,本人消息为空', + `relation_code` varchar(64) DEFAULT NULL COMMENT '关系类型编码快照,本人消息为空', + `relation_name` varchar(64) DEFAULT NULL COMMENT '关系类型名称快照,本人消息为空', + `subject_user_name` varchar(128) DEFAULT NULL COMMENT '事件主体用户姓名快照,用于关系消息展示', + `source_service` varchar(32) NOT NULL COMMENT '来源服务编码,首期为WALLET或HARDWARE', + `source_biz_type` varchar(64) NOT NULL COMMENT '来源业务类型快照,例如钱包业务类型或预约核销', + `source_biz_id` varchar(128) NOT NULL COMMENT '来源业务ID快照,例如钱包biz_id或硬件订单ID', + `source_mch_id` bigint(20) DEFAULT NULL COMMENT '事件来源商户ID快照,无法解析时为空且仅生成本人收件箱', + `source_mch_name` varchar(128) DEFAULT NULL COMMENT '事件来源商户名称快照,避免历史消息随商户改名变化', + `title` varchar(128) NOT NULL COMMENT '服务端固定模板渲染后的消息标题', + `content` varchar(2000) NOT NULL COMMENT '服务端固定模板渲染后的消息正文', + `payload_json` text DEFAULT NULL COMMENT '消息详情快照JSON,仅保存展示所需的非敏感字段', + `deep_link_path` varchar(256) DEFAULT NULL COMMENT 'App内部跳转路径,首期统一进入消息详情页', + `deep_link_params_json` varchar(1000) DEFAULT NULL COMMENT 'App内部跳转参数JSON,不保存鉴权信息', + `read_status` char(1) NOT NULL DEFAULT '0' COMMENT '已读状态,0未读,1已读', + `read_time` datetime DEFAULT NULL COMMENT '用户首次标记已读时间,未读时为空', + `occurred_time` datetime NOT NULL COMMENT '业务事件实际发生时间,不使用异步投递完成时间替代', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人,系统事件统一为system', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_message_event_user` (`tenant_id`, `event_id`, `recipient_user_id`), + KEY `idx_third_app_message_inbox` (`tenant_id`, `recipient_user_id`, `read_status`, `occurred_time`, `del_flag`), + KEY `idx_third_app_message_mch_event` (`tenant_id`, `source_mch_id`, `event_code`, `occurred_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'App业务消息收件箱'; + +-- 商户按事件独立配置本人Push与关系消息。不存在配置时由业务按两个开关均关闭处理。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_push_policy` +( + `id` bigint(20) NOT NULL COMMENT '策略主键,由应用使用雪花算法生成', + `mch_id` bigint(20) NOT NULL COMMENT '商户ID,父店和子店互不继承策略', + `event_code` varchar(64) NOT NULL COMMENT '固定业务事件编码', + `owner_push_enabled` char(1) NOT NULL DEFAULT '0' COMMENT '本人系统Push开关,0关闭,1开启;不影响本人收件箱', + `relation_message_enabled` char(1) NOT NULL DEFAULT '0' COMMENT '关系消息开关,0关闭,1开启;关闭时不生成关系收件箱或Push', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_push_policy` (`tenant_id`, `mch_id`, `event_code`), + KEY `idx_third_app_push_policy_mch` (`tenant_id`, `mch_id`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '商户App业务消息策略'; + +-- 商户关系消息策略允许的关系类型。关系编码来自UPMS关系类型协议,不在本表复制关系定义。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_push_policy_relation` +( + `id` bigint(20) NOT NULL COMMENT '策略关系主键,由应用使用雪花算法生成', + `policy_id` bigint(20) NOT NULL COMMENT '商户事件策略ID', + `relation_code` varchar(64) NOT NULL COMMENT '允许接收该事件的UPMS关系类型编码', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_policy_relation` (`tenant_id`, `policy_id`, `relation_code`), + KEY `idx_third_app_policy_relation_code` (`tenant_id`, `relation_code`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '商户App业务消息关系范围'; + +-- 用户业务Push总开关。没有持久化记录时按开启处理,只有用户修改后才落库。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_user_push_setting` +( + `id` bigint(20) NOT NULL COMMENT '设置主键,由应用使用雪花算法生成', + `user_id` bigint(20) NOT NULL COMMENT '设置所属用户ID', + `push_enabled` char(1) NOT NULL DEFAULT '1' COMMENT '业务系统Push总开关,0关闭,1开启;不影响已生成收件箱消息', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_user_push_setting` (`tenant_id`, `user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'App用户业务Push总设置'; + +-- 用户本人事件Push开关。没有持久化记录的事件按开启处理。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_user_event_setting` +( + `id` bigint(20) NOT NULL COMMENT '事件设置主键,由应用使用雪花算法生成', + `user_id` bigint(20) NOT NULL COMMENT '设置所属用户ID', + `event_code` varchar(64) NOT NULL COMMENT '固定业务事件编码', + `push_enabled` char(1) NOT NULL DEFAULT '1' COMMENT '本人事件系统Push开关,0关闭,1开启;不影响本人收件箱', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_user_event_setting` (`tenant_id`, `user_id`, `event_code`), + KEY `idx_third_app_user_event_user` (`tenant_id`, `user_id`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'App用户本人事件Push设置'; + +-- 监护人按具体关系记录和事件主动订阅。新关系记录不会继承旧关系订阅。 +CREATE TABLE `pjcloudx_thirdparty`.`third_app_relation_subscription` +( + `id` bigint(20) NOT NULL COMMENT '订阅主键,由应用使用雪花算法生成', + `relation_id` bigint(20) NOT NULL COMMENT 'UPMS具体关系记录ID,关系重建后必须重新订阅', + `subscriber_user_id` bigint(20) NOT NULL COMMENT '订阅用户ID,通常为监护人', + `subject_user_id` bigint(20) NOT NULL COMMENT '被订阅事件主体用户ID,通常为被监护人', + `relation_code` varchar(64) NOT NULL COMMENT '订阅时的关系类型编码快照', + `event_code` varchar(64) NOT NULL COMMENT '固定业务事件编码', + `enabled` char(1) NOT NULL DEFAULT '0' COMMENT '订阅开关,0关闭时不生成关系收件箱或Push,1开启', + `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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_app_relation_subscription` (`tenant_id`, `relation_id`, `event_code`), + KEY `idx_third_app_relation_subscriber` (`tenant_id`, `subscriber_user_id`, `enabled`, `del_flag`), + KEY `idx_third_app_relation_subject` (`tenant_id`, `subject_user_id`, `event_code`, `enabled`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'App关系事件订阅'; + +-- 供应商无关的设备绑定。同一逻辑设备可以同时保存多个供应商令牌以支持后续故障切换。 +CREATE TABLE `pjcloudx_thirdparty`.`third_push_device_binding` +( + `id` bigint(20) NOT NULL COMMENT '设备绑定主键,由应用使用雪花算法生成', + `user_id` bigint(20) NOT NULL COMMENT '当前绑定用户ID,账号切换时必须解绑旧用户', + `client_device_id` varchar(128) NOT NULL COMMENT 'App生成并持久化的逻辑设备ID,同一安装保持稳定', + `provider_code` varchar(32) NOT NULL COMMENT '推送供应商编码,首期为JPUSH', + `provider_registration_id` varchar(256) NOT NULL COMMENT '供应商设备令牌或Registration ID,属于敏感投递标识', + `app_code` varchar(64) NOT NULL COMMENT '客户端应用编码,用于同供应商多应用隔离', + `platform` varchar(16) NOT NULL COMMENT '客户端平台,ANDROID或IOS;H5不建立绑定', + `enabled` char(1) NOT NULL DEFAULT '1' COMMENT '绑定有效状态,0停用,1有效', + `last_active_time` datetime DEFAULT NULL COMMENT 'App最近成功上报绑定或活跃时间', + `unbind_time` datetime 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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_push_provider_token` (`provider_code`, `app_code`, `provider_registration_id`), + UNIQUE KEY `uk_third_push_user_device_provider` (`tenant_id`, `user_id`, `client_device_id`, `provider_code`), + KEY `idx_third_push_device_user` (`tenant_id`, `user_id`, `enabled`, `provider_code`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '通用推送设备绑定'; + +-- 一条收件箱消息最多一条系统Push投递任务;被策略跳过也记录原因,便于商户排查。 +CREATE TABLE `pjcloudx_thirdparty`.`third_push_delivery` +( + `id` bigint(20) NOT NULL COMMENT '投递任务主键,由应用使用雪花算法生成', + `message_id` bigint(20) NOT NULL COMMENT 'App业务消息ID,一条消息最多一个系统Push任务', + `recipient_user_id` bigint(20) NOT NULL COMMENT '系统Push目标用户ID', + `source_mch_id` bigint(20) DEFAULT NULL COMMENT '消息来源商户ID快照,用于PC数据范围查询', + `event_code` varchar(64) NOT NULL COMMENT '固定业务事件编码', + `status` varchar(16) NOT NULL COMMENT '投递状态,PENDING、PROCESSING、RETRY、SUCCESS、SKIPPED或FAILED', + `skip_reason` varchar(64) DEFAULT NULL COMMENT '跳过原因编码,例如MERCHANT_DISABLED、USER_DISABLED或NO_DEVICE', + `attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '已执行供应商调用次数,跨供应商累计,最多8次', + `next_retry_time` datetime DEFAULT NULL COMMENT '下次允许重试时间,非RETRY状态为空', + `final_provider_code` varchar(32) DEFAULT NULL COMMENT '最终受理消息的供应商编码,未成功时为空', + `provider_message_id` varchar(128) DEFAULT NULL COMMENT '供应商返回的消息ID,供应商未返回时为空', + `success_time` datetime DEFAULT NULL COMMENT '供应商明确受理时间', + `last_error` varchar(2000) 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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_push_delivery_message` (`message_id`), + KEY `idx_third_push_delivery_scan` (`tenant_id`, `status`, `next_retry_time`, `del_flag`), + KEY `idx_third_push_delivery_mch_time` (`tenant_id`, `source_mch_id`, `create_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'App业务消息Push投递任务'; + +-- 供应商调用尝试日志。主供应商明确失败后切备用供应商时,每次调用分别保存一行。 +CREATE TABLE `pjcloudx_thirdparty`.`third_push_delivery_attempt` +( + `id` bigint(20) NOT NULL COMMENT '尝试日志主键,由应用使用雪花算法生成', + `delivery_id` bigint(20) NOT NULL COMMENT 'Push投递任务ID', + `attempt_no` int(11) NOT NULL COMMENT '投递任务内递增的供应商调用序号', + `provider_code` varchar(32) NOT NULL COMMENT '本次调用的推送供应商编码', + `result_status` varchar(16) NOT NULL COMMENT '调用结果,ACCEPTED、RETRYABLE_FAILED或PERMANENT_FAILED', + `provider_message_id` varchar(128) DEFAULT NULL COMMENT '供应商返回的消息ID,未受理时为空', + `error_code` varchar(64) DEFAULT NULL COMMENT '供应商错误码或系统错误分类,不保存密钥和令牌', + `error_message` varchar(2000) DEFAULT NULL COMMENT '脱敏后的失败诊断信息', + `started_time` datetime NOT NULL COMMENT '本次供应商调用开始时间', + `finished_time` datetime 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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_push_delivery_attempt` (`delivery_id`, `attempt_no`), + KEY `idx_third_push_attempt_time` (`tenant_id`, `create_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = 'Push供应商调用尝试日志'; + +-- 钱包流水与通知事件同事务落库。一条钱包流水仅生成一条Outbox明细,多钱包由event_group_key异步聚合。 +CREATE TABLE `pjcloudx_wallet`.`wallet_message_outbox` +( + `id` bigint(20) NOT NULL COMMENT 'Outbox主键,同时作为聚合事件ID来源,由应用使用雪花算法生成', + `wallet_transaction_id` bigint(20) NOT NULL COMMENT 'wallet_transaction流水ID,保证每条新流水只写一次Outbox', + `event_group_key` varchar(256) NOT NULL COMMENT '按租户、用户、业务类型、业务ID和来源商户生成的聚合键', + `event_code` varchar(64) NOT NULL COMMENT '映射后的固定消息事件编码', + `subject_user_id` bigint(20) NOT NULL COMMENT '钱包所有人用户ID,也是本人消息接收人', + `source_mch_id` bigint(20) DEFAULT NULL COMMENT '来源商户ID快照,订单类取实际商户,账户类取当前食堂,无法解析时为空', + `source_mch_name` varchar(128) DEFAULT NULL COMMENT '来源商户名称快照,无法解析时为空', + `source_scene` varchar(32) NOT NULL DEFAULT 'WALLET' COMMENT '来源场景,WALLET普通钱包变化或ORDER钱包订单', + `source_biz_type` varchar(64) NOT NULL COMMENT 'WalletBizEnum持久化编码快照', + `source_biz_id` varchar(128) NOT NULL COMMENT '钱包流水biz_id快照', + `wallet_id` bigint(20) NOT NULL COMMENT '发生变化的钱包ID', + `wallet_name` varchar(128) DEFAULT NULL COMMENT '钱包名称或别名快照,用于多钱包明细展示', + `amount` int(11) NOT NULL COMMENT '本次钱包变动金额,单位分,收入为正、支出为负', + `balance` int(11) NOT NULL COMMENT '本次变动后的钱包余额,单位分', + `occurred_time` datetime NOT NULL COMMENT '钱包流水发生时间', + `status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '处理状态,PENDING、PROCESSING、RETRY、SUCCESS或FAILED', + `attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '向Thirdparty提交事件的累计尝试次数,最多8次', + `next_retry_time` datetime DEFAULT NULL COMMENT '下次允许重试时间,首次处理和终态为空', + `processed_time` datetime DEFAULT NULL COMMENT 'Thirdparty幂等接收成功时间', + `last_error` varchar(2000) 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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wallet_message_outbox_transaction` (`wallet_transaction_id`), + KEY `idx_wallet_message_outbox_group` (`tenant_id`, `event_group_key`, `status`, `create_time`, `del_flag`), + KEY `idx_wallet_message_outbox_scan` (`tenant_id`, `status`, `next_retry_time`, `create_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '钱包业务消息Transactional Outbox'; + +-- 用餐核销状态和事件快照同事务落库;订单重复核销或异步重试不会生成重复消息。 +CREATE TABLE `pjcloudx_hardware`.`hard_message_outbox` +( + `id` bigint(20) NOT NULL COMMENT 'Outbox主键,由应用使用雪花算法生成', + `event_code` varchar(64) NOT NULL COMMENT '固定消息事件编码,首期仅MEAL_VERIFIED', + `order_id` bigint(20) NOT NULL COMMENT '发生核销的硬件预约订单ID', + `subject_user_id` bigint(20) NOT NULL COMMENT '实际用餐用户ID,也是本人消息接收人', + `source_mch_id` bigint(20) NOT NULL COMMENT '订单实际所属商户ID', + `source_mch_name` varchar(128) DEFAULT NULL COMMENT '订单商户名称快照', + `meal_name` varchar(256) DEFAULT NULL COMMENT '餐段、菜谱或核销内容摘要快照', + `occurred_time` datetime NOT NULL COMMENT '核销成功时间', + `status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '处理状态,PENDING、PROCESSING、RETRY、SUCCESS或FAILED', + `attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '向Thirdparty提交事件的累计尝试次数,最多8次', + `next_retry_time` datetime DEFAULT NULL COMMENT '下次允许重试时间,首次处理和终态为空', + `processed_time` datetime DEFAULT NULL COMMENT 'Thirdparty幂等接收成功时间', + `last_error` varchar(2000) 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 '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_message_outbox_order_event` (`tenant_id`, `event_code`, `order_id`), + KEY `idx_hard_message_outbox_scan` (`tenant_id`, `status`, `next_retry_time`, `create_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '硬件核销消息Transactional Outbox'; diff --git a/db/20260814_add_hard_car_product_source.sql b/db/20260814_add_hard_car_product_source.sql new file mode 100644 index 000000000..edbf73ee4 --- /dev/null +++ b/db/20260814_add_hard_car_product_source.sql @@ -0,0 +1,120 @@ +-- 订单购物车商品来源快照。 +-- 父购物车保存统一来源类型,商品行保存进入对应详情页所需的真实业务 ID。 +-- 历史数据仅回填能够唯一确定来源和详情 ID 的记录,缺失或歧义数据保持 NULL,禁止猜测商品表。 + +ALTER TABLE `pjcloudx_hardware`.`hard_parent_car` + ADD COLUMN `product_source` varchar(32) DEFAULT NULL + COMMENT '商品来源类型:CANTEEN_DAILY_MENU每日菜谱、CANTEEN_ONLINE_SPU食堂在线商品、CANTEEN_SPU食堂基础菜品、MALL_PRODUCT共富商城商品' AFTER `hard_type`; + +ALTER TABLE `pjcloudx_hardware`.`hard_car` + ADD COLUMN `source_product_id` bigint(20) DEFAULT NULL + COMMENT '商品来源详情ID:每日菜谱为canteen_daily_menu.id,食堂在线/基础菜品同product_id,共富商城为商品SPU ID' AFTER `product_id`; + +-- 每日菜谱使用服务端写入的 cartScene 识别,包含尚未下单的有效草稿。 +UPDATE `pjcloudx_hardware`.`hard_parent_car` +SET `product_source` = 'CANTEEN_DAILY_MENU' +WHERE `product_source` IS NULL + AND JSON_VALID(`take_out_setting_json`) = 1 + AND BINARY JSON_UNQUOTE(JSON_EXTRACT(`take_out_setting_json`, '$.cartScene')) = BINARY 'DAILY_MENU'; + +-- SELF_APP 当前购物车创建分支固定对应共富商城,包含尚未下单的有效草稿。 +UPDATE `pjcloudx_hardware`.`hard_parent_car` +SET `product_source` = 'MALL_PRODUCT' +WHERE `product_source` IS NULL + AND `hard_type` = '8'; + +-- 已下单的食堂购物车按订单类型补齐来源;外卖与线上预约都使用 canteen_online_spu。 +UPDATE `pjcloudx_hardware`.`hard_parent_car` parent_car + JOIN ( + SELECT DISTINCT `parent_car_id` + FROM `pjcloudx_hardware`.`hard_order` + WHERE COALESCE(`del_flag`, '0') = '0' + AND `sys_type` = '2' + AND `order_type` IN ('2', '10') + AND `parent_car_id` IS NOT NULL + ) online_order ON online_order.`parent_car_id` = parent_car.`id` +SET parent_car.`product_source` = 'CANTEEN_ONLINE_SPU' +WHERE parent_car.`product_source` IS NULL; + +UPDATE `pjcloudx_hardware`.`hard_parent_car` parent_car + JOIN ( + SELECT DISTINCT `parent_car_id` + FROM `pjcloudx_hardware`.`hard_order` + WHERE COALESCE(`del_flag`, '0') = '0' + AND `sys_type` = '2' + AND `order_type` = '1' + AND `parent_car_id` IS NOT NULL + ) canteen_order ON canteen_order.`parent_car_id` = parent_car.`id` +SET parent_car.`product_source` = 'CANTEEN_SPU' +WHERE parent_car.`product_source` IS NULL; + +-- 旧 App 普通食堂草稿接口固定按线上预约商品创建;每日菜谱草稿已在前面的 cartScene 分支排除。 +UPDATE `pjcloudx_hardware`.`hard_parent_car` +SET `product_source` = 'CANTEEN_ONLINE_SPU' +WHERE `product_source` IS NULL + AND `hard_type` = '2' + AND `order_status` = '1'; + +-- 食堂在线商品和基础菜品的详情 ID 与购物车 product_id 相同,可直接安全回填。 +UPDATE `pjcloudx_hardware`.`hard_car` car + JOIN `pjcloudx_hardware`.`hard_parent_car` parent_car ON parent_car.`id` = car.`parent_car_id` +SET car.`source_product_id` = car.`product_id` +WHERE car.`source_product_id` IS NULL + AND parent_car.`product_source` IN ('CANTEEN_ONLINE_SPU', 'CANTEEN_SPU'); + +-- 共富商城购物车 product_id 为 SKU ID;只回填仍能唯一关联到有效 SKU 的商品 SPU ID。 +UPDATE `pjcloudx_hardware`.`hard_car` car + JOIN `pjcloudx_hardware`.`hard_parent_car` parent_car ON parent_car.`id` = car.`parent_car_id` + JOIN `pjcloudx_supermarket`.`mall_product_sku` sku + ON sku.`id` = car.`product_id` + AND COALESCE(sku.`del_flag`, '0') = '0' + AND (sku.`tenant_id` = car.`tenant_id` OR sku.`tenant_id` IS NULL OR car.`tenant_id` IS NULL) +SET car.`source_product_id` = sku.`product_id` +WHERE car.`source_product_id` IS NULL + AND parent_car.`product_source` = 'MALL_PRODUCT'; + +-- 执行每日菜谱回填前先输出无法唯一匹配的历史商品,结果需保留 NULL 供人工核对。 +SELECT car.`id` AS `car_id`, + car.`parent_car_id`, + car.`product_id`, + COUNT(DISTINCT daily_menu.`id`) AS `matched_daily_menu_count` +FROM `pjcloudx_hardware`.`hard_car` car + JOIN `pjcloudx_hardware`.`hard_parent_car` parent_car ON parent_car.`id` = car.`parent_car_id` + JOIN `pjcloudx_hardware`.`hard_order` hard_order + ON hard_order.`parent_car_id` = parent_car.`id` + AND COALESCE(hard_order.`del_flag`, '0') = '0' + LEFT JOIN `pjcloudx_canteen`.`canteen_daily_menu` daily_menu + ON daily_menu.`spu_id` = car.`product_id` + AND daily_menu.`merchant_id` = hard_order.`merchant_id` + AND daily_menu.`start_time` = hard_order.`appt_use_date` + AND daily_menu.`hour_type` = CAST(hard_order.`hour_type` AS SIGNED) + AND COALESCE(daily_menu.`del_flag`, '0') = '0' + AND (daily_menu.`tenant_id` = hard_order.`tenant_id` OR daily_menu.`tenant_id` IS NULL OR hard_order.`tenant_id` IS NULL) +WHERE parent_car.`product_source` = 'CANTEEN_DAILY_MENU' + AND car.`source_product_id` IS NULL +GROUP BY car.`id`, car.`parent_car_id`, car.`product_id` +HAVING COUNT(DISTINCT daily_menu.`id`) <> 1; + +-- 每日菜谱只回填商户、用餐日期、餐段、SPU 和租户共同确定的唯一菜谱记录。 +UPDATE `pjcloudx_hardware`.`hard_car` car + JOIN ( + SELECT source_car.`id` AS `car_id`, MIN(daily_menu.`id`) AS `daily_menu_id` + FROM `pjcloudx_hardware`.`hard_car` source_car + JOIN `pjcloudx_hardware`.`hard_parent_car` parent_car ON parent_car.`id` = source_car.`parent_car_id` + JOIN `pjcloudx_hardware`.`hard_order` hard_order + ON hard_order.`parent_car_id` = parent_car.`id` + AND COALESCE(hard_order.`del_flag`, '0') = '0' + JOIN `pjcloudx_canteen`.`canteen_daily_menu` daily_menu + ON daily_menu.`spu_id` = source_car.`product_id` + AND daily_menu.`merchant_id` = hard_order.`merchant_id` + AND daily_menu.`start_time` = hard_order.`appt_use_date` + AND daily_menu.`hour_type` = CAST(hard_order.`hour_type` AS SIGNED) + AND COALESCE(daily_menu.`del_flag`, '0') = '0' + AND (daily_menu.`tenant_id` = hard_order.`tenant_id` OR daily_menu.`tenant_id` IS NULL OR hard_order.`tenant_id` IS NULL) + WHERE parent_car.`product_source` = 'CANTEEN_DAILY_MENU' + AND source_car.`source_product_id` IS NULL + GROUP BY source_car.`id` + HAVING COUNT(DISTINCT daily_menu.`id`) = 1 + ) matched_daily_menu ON matched_daily_menu.`car_id` = car.`id` +SET car.`source_product_id` = matched_daily_menu.`daily_menu_id` +WHERE car.`source_product_id` IS NULL; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardProductSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardProductSourceEnum.java new file mode 100644 index 000000000..cd480deb8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardProductSourceEnum.java @@ -0,0 +1,70 @@ +package com.bsd.pjcloud.hard.api.constant; + +import cn.hutool.core.util.ObjectUtil; + +import java.util.Arrays; + +/** + * 订单购物车商品来源枚举。 + * 枚举编码持久化到 {@code hard_parent_car.product_source},同时作为 App 商品详情路由协议,禁止随意改码。 + */ +public enum HardProductSourceEnum { + + /** 每日菜谱,购物车商品 ID 为 {@code canteen_spu.id},来源详情 ID 为 {@code canteen_daily_menu.id}。 */ + CANTEEN_DAILY_MENU("CANTEEN_DAILY_MENU", "每日菜谱"), + + /** 食堂在线商品,购物车商品 ID 和来源详情 ID 均为 {@code canteen_online_spu.id}。 */ + CANTEEN_ONLINE_SPU("CANTEEN_ONLINE_SPU", "食堂在线商品"), + + /** 食堂基础菜品,购物车商品 ID 和来源详情 ID 均为 {@code canteen_spu.id}。 */ + CANTEEN_SPU("CANTEEN_SPU", "食堂基础菜品"), + + /** 共富商城商品,购物车商品 ID 为 SKU ID,来源详情 ID 为商城商品 SPU ID。 */ + MALL_PRODUCT("MALL_PRODUCT", "共富商城商品"); + + /** 持久化和跨端传输使用的稳定编码。 */ + private final String type; + + /** 商品来源中文说明,仅用于服务端展示和诊断。 */ + private final String description; + + /** + * 创建商品来源枚举值。 + * + * @param type 持久化及跨端协议编码 + * @param description 中文业务说明 + */ + HardProductSourceEnum(String type, String description) { + this.type = type; + this.description = description; + } + + /** + * 获取持久化和跨端传输使用的稳定编码。 + * + * @return 商品来源编码 + */ + public String getType() { + return type; + } + + /** + * 获取商品来源中文说明。 + * + * @return 商品来源说明 + */ + public String getDescription() { + return description; + } + + /** + * 按持久化编码解析商品来源,空值或未知编码返回 {@code null} 以兼容历史购物车。 + * + * @param type 数据库或接口中的商品来源编码 + * @return 匹配的商品来源;无法识别时返回 {@code null} + */ + public static HardProductSourceEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) return null; + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(value.getType(), type)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCreateCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCreateCarDto.java index b0b15fbf8..f3a9d6b16 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCreateCarDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCreateCarDto.java @@ -6,22 +6,37 @@ import lombok.Data; import java.math.BigDecimal; +/** + * 创建购物车商品请求。 + * 商品计价 ID 与详情来源 ID 分离,避免每日菜谱和商城 SKU 在订单详情中再次跨服务反查。 + */ @Data public class HardCreateCarDto { + /** 父购物车 ID,由服务端创建分组购物车时回填,App 批量上传场景可为空。 */ @Schema(description = "父购物车id", requiredMode = Schema.RequiredMode.REQUIRED) private Long parentCarId; + /** 当前商品计价和库存校验使用的 ID,具体数据表由购物车来源类型决定。 */ @Schema(description = "商品id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1034") private Long productId; + /** + * 商品来源详情 ID,仅在商品 ID 不能直接用于详情页时由调用方提供。 + * 新版 App 的每日菜谱传 {@code canteen_daily_menu.id};旧客户端未传时仍允许下单,但该订单不提供商品详情跳转。 + * 其他来源由服务端根据已校验商品补齐,不信任客户端传值。 + */ + @Schema(description = "商品来源详情ID") + private Long sourceProductId; + + /** 商品计价性质,决定使用数量还是重量计算金额。 */ @Schema(description = "食堂性质", requiredMode = Schema.RequiredMode.REQUIRED, example = "20798") private HardProductNatureEnum productNature; - // TODO 食堂特有 + /** 按件商品数量;称重商品场景可为空。 */ @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20798") private Integer productCount; - // TODO 超市特有 + /** 称重商品重量,单位沿用现有超市和智能柜协议;按件商品场景可为空。 */ @Schema(description = "重量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20798") private BigDecimal productWeight; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardCarEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardCarEntity.java index 4f2637288..13b42c0e8 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardCarEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardCarEntity.java @@ -46,6 +46,14 @@ public class HardCarEntity extends Model { @Schema(description = "商品id") private Long productId; + /** + * 商品来源详情 ID,由创建购物车时按父购物车商品来源固化。 + * 每日菜谱对应 {@code canteen_daily_menu.id},食堂在线/基础菜品等于商品 ID,共富商城对应商品 SPU ID; + * 无法唯一识别的历史数据为空。 + */ + @Schema(description = "商品来源详情ID") + private Long sourceProductId; + /** * 商品名 */ @@ -131,4 +139,4 @@ public class HardCarEntity 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/order/HardMessageOutboxEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardMessageOutboxEntity.java new file mode 100644 index 000000000..09432580f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardMessageOutboxEntity.java @@ -0,0 +1,86 @@ +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.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Hardware用餐核销业务消息Transactional Outbox,保存订单状态成功转换时的不可变展示快照。 + */ +@Data +@TenantTable +@TableName("hard_message_outbox") +public class HardMessageOutboxEntity implements Serializable { + + /** Outbox主键,由MyBatis-Plus雪花算法生成并用于稳定事件ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 固定消息事件编码,首期仅为MEAL_VERIFIED。 */ + private String eventCode; + + /** 被核销的Hardware预约订单ID。 */ + private Long orderId; + + /** 实际用餐用户ID,也是本人消息接收人。 */ + private Long subjectUserId; + + /** 订单实际所属商户ID。 */ + private Long sourceMchId; + + /** 核销发生时的商户名称快照,远程查询失败时允许为空。 */ + private String sourceMchName; + + /** 餐段、商品或套餐摘要快照,来源于订单商品描述。 */ + private String mealName; + + /** 订单由PAY成功转换到CANCEL的服务器时间。 */ + private LocalDateTime occurredTime; + + /** 处理状态,PENDING、PROCESSING、RETRY、SUCCESS或FAILED。 */ + private String status; + + /** 向Thirdparty提交的累计尝试次数,最多8次。 */ + private Integer attemptCount; + + /** 下次允许重试时间,非RETRY状态为空。 */ + private LocalDateTime nextRetryTime; + + /** Thirdparty幂等接收成功时间。 */ + private LocalDateTime processedTime; + + /** 最近一次脱敏失败摘要,不保存请求报文或设备凭证。 */ + private String lastError; + + /** 创建人,系统核销通常由公共填充器写入。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** Outbox创建时间,用于扫描和保留清理。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 最近修改人。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** 最近修改时间,也用于处理中超时恢复。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** 逻辑删除标记,0有效,1删除。 */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; + + /** 记录所属租户ID。 */ + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java index e4512ac2d..4b8cda9ae 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java @@ -51,6 +51,13 @@ public class HardParentCarEntity extends Model { @Schema(description = "硬件设备类型") private String hardType; + /** + * 购物车商品来源类型,值来自 {@link com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum}。 + * 同一父购物车内的商品必须属于同一来源;历史或不支持详情跳转的购物车为空。 + */ + @Schema(description = "购物车商品来源类型") + private String productSource; + /** * 订单状态 */ 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 7cc03382e..0ba972bd4 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 @@ -3,27 +3,62 @@ package com.bsd.pjcloud.hard.api.vo.order; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import lombok.Data; +/** + * 订单购物车展示对象,在持久化快照之外补充统计字段和 App 商品详情路由协议。 + */ @Data public class HardCarVo extends HardCarEntity { - // 商品id【超市特有】 + /** 超市商品 SPU ID,仅超市统计和展示场景赋值。 */ private Long spuId; - // 商品规格【超市特有】 + /** 超市商品规格,仅超市商品存在规格时赋值。 */ private String spec; - // 商品规格【超市特有】 + /** 超市商品单位,仅超市商品存在单位时赋值。 */ private String unit; - // 商品数量【统计使用】 + /** 商品数量汇总值,仅统计查询赋值。 */ private Long count; - // 购买次数【统计使用】 + /** 商品购买次数汇总值,仅统计查询赋值。 */ private Long payCount; - // 统计使用 + /** + * 原订单购买份数,只读展示字段;普通订单商品列表中与持久化 {@link #getProductCount()} 一致。 + */ + private Integer originalProductCount; + + /** + * 已完成退款份数,只统计售后状态为完成的商品明细;空响应按零处理。 + */ + private Integer refundedProductCount; + + /** + * 申请中退款份数,只统计仍占用退款额度的待处理售后;不从当前可履约数量中提前扣除。 + */ + private Integer applyingProductCount; + + /** + * 当前剩余可履约份数,计算规则为原购份数减已完成退款份数,最小为零。 + */ + private Integer effectiveProductCount; + + /** 统计结果所属商户 ID,不作为购物车持久化字段。 */ private Long merchantId; + /** 统计结果所属商户名称,不作为购物车持久化字段。 */ private String merchantName; + /** + * App 商品详情数据来源,值来自 {@link com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum}; + * 为空表示历史订单或当前商品类型没有可用详情页,调用方不得按 ID 猜测数据表。 + */ + private String detailSource; + + /** + * App 商品详情页使用的业务 ID,直接取创建购物车时固化的 {@link #getSourceProductId()}。 + */ + private Long detailProductId; + } 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 02d14e947..d5f552950 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 @@ -11,10 +11,12 @@ 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.constant.order.HardOrderTypeEnum; 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; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; @@ -37,6 +39,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; @RestController @@ -96,9 +99,63 @@ public class HardApiController { } @GetMapping("/get-by-order-id") - @Operation(summary = "根据订单id查询购物车详情") + @Operation(summary = "根据订单id查询终端待履约购物车详情") public R> getByOrderId(@RequestParam Long orderId) { - return R.ok(hardCarService.getByOrderId(orderId)); + List carList = hardCarService.getByOrderId(orderId); + HardOrderEntity order = hardwareOrderService.getById(orderId); + return R.ok(projectFulfillableReservationCars(order, carList)); + } + + /** + * 食堂预约核销只向消费机暴露退款成功后的剩余商品;其他订单继续保持原接口数量和金额语义。 + * + *

App 订单详情使用独立接口,仍可同时读取原购、已退和剩余数量。这里覆盖旧版食堂、超市消费机 + * 共用的二维码核销接口,避免终端继续按原购物车数量出餐。

+ * + * @param order 订单快照,用于限定食堂预约场景 + * @param carList 带退款数量统计的购物车展示对象 + * @return 终端可继续履约的商品;非食堂预约订单原样返回 + */ + private List projectFulfillableReservationCars(HardOrderEntity order, List carList) { + if (ObjectUtil.isEmpty(carList) || order == null + || !ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + || !ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + return carList; + } + return carList.stream().filter(ObjectUtil::isNotEmpty).filter(car -> resolveEffectiveCount(car) > 0).peek(car -> { + int originalCount = resolveOriginalCount(car); + int effectiveCount = resolveEffectiveCount(car); + car.setProductCount(effectiveCount); + if (car.getTotalPrice() != null && originalCount > 0 && effectiveCount < originalCount) { + long effectiveTotalPrice = BigDecimal.valueOf(car.getTotalPrice()) + .multiply(BigDecimal.valueOf(effectiveCount)) + .divide(BigDecimal.valueOf(originalCount), 0, RoundingMode.HALF_UP) + .longValue(); + car.setTotalPrice(effectiveTotalPrice); + } + }).toList(); + } + + /** + * 解析购物车原购份数,旧响应缺少显式快照字段时回退持久化数量。 + * + * @param car 购物车展示对象 + * @return 非负原购份数 + */ + private int resolveOriginalCount(HardCarVo car) { + Integer count = car.getOriginalProductCount() == null ? car.getProductCount() : car.getOriginalProductCount(); + return count == null ? 0 : Math.max(0, count); + } + + /** + * 解析退款成功后的剩余可履约份数,旧数据缺少统计字段时回退原购份数。 + * + * @param car 购物车展示对象 + * @return 非负剩余份数 + */ + private int resolveEffectiveCount(HardCarVo car) { + Integer count = car.getEffectiveProductCount() == null ? resolveOriginalCount(car) : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); } @GetMapping("/get-order") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java index 1554bd6be..f956218d4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java @@ -6,6 +6,8 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; @@ -41,6 +43,9 @@ public class HardCanteenApptTotalController { private final HardCanteenApptTotalService canteenApptTotalService; + /** 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 */ + private final HardCanteenTotalService canteenTotalService; + private final RemoteMchService remoteMchService; @@ -64,6 +69,25 @@ public class HardCanteenApptTotalController { /* ************************************* end app end ************************************* */ + /** + * 按预约使用日期统计食堂预约部门。 + * + * @param dto 预约统计筛选条件,其中 {@code apptUseDateTimeList} 表示预约使用日期范围 + * @return 按食堂聚合并包含部门、用餐时间段明细的预约统计结果 + */ + @Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门") + @ApiCryptoAes + @PostMapping("/list-by-dept") + public R> listByDept(@RequestBody HardOrderDto dto) { + // 预约统计必须以预约使用日期为唯一时间口径,避免调用方残留支付时间条件导致交叉筛选。 + dto.setPayTimeList(null); + dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + dto.setTotalIsFindChildren(true); + dto.setApptIdOrApptRuleIdNotNull(true); + PjcloudUser user = SecurityUtils.getUser(); + return R.ok(canteenTotalService.listByDept(dto, user, null)); + } + @Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计") @PostMapping("/proudct-reserv-total") public R> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java new file mode 100644 index 000000000..784aa8a23 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.hard.service.HardMessageOutboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Hardware核销消息Outbox调度器,周期触发投递恢复和保留期清理。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HardMessageOutboxJob { + + /** Hardware核销Outbox处理服务。 */ + private final HardMessageOutboxService outboxService; + + /** + * 每十秒扫描一次到期事件,实际并发领取由Outbox条件更新控制。 + */ + @Scheduled(fixedDelay = 10000L, initialDelay = 15000L) + public void processDueEvents() { + try { + int processed = outboxService.processDueEvents(); + if (processed > 0) { + log.info("核销消息Outbox扫描完成,processed:{}", processed); + } + } catch (Exception exception) { + log.error("核销消息Outbox扫描异常", exception); + } + } + + /** + * 每日凌晨分批清理超过保留期的终态Outbox。 + */ + @Scheduled(cron = "0 30 2 * * ?") + public void cleanupExpiredEvents() { + try { + int deleted = outboxService.cleanupExpiredEvents(); + if (deleted > 0) { + log.info("核销消息Outbox到期清理完成,deleted:{}", deleted); + } + } catch (Exception exception) { + log.error("核销消息Outbox到期清理异常", exception); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java index 5a3743977..b9312e946 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java @@ -7,6 +7,9 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 售后商品快照 Mapper,负责按订单父购物车和售后状态聚合退款占用量。 + */ @Mapper public interface HardAfterSaleCarMapper extends PjcloudBaseMapper { @@ -19,6 +22,16 @@ public interface HardAfterSaleCarMapper extends PjcloudBaseMapper getOccupiedByCarParentId(@Param("carParentId") Long carParentId, @Param("statuses") List statuses); + /** + * 批量查询父购物车在指定售后状态下的商品占用汇总,结果按父购物车和商品 ID 分组。 + * + * @param carParentIds 父购物车 ID 集合 + * @param statuses 计入占用的售后状态编码集合 + * @return 每个父购物车下按商品 ID 聚合的占用明细,无匹配数据时返回空列表 + */ + List getOccupiedByCarParentIds(@Param("carParentIds") List carParentIds, + @Param("statuses") List statuses); + /** * 查询指定售后单保存的商品快照,审核同意时据此退库存。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardMessageOutboxMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardMessageOutboxMapper.java new file mode 100644 index 000000000..3732eee08 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardMessageOutboxMapper.java @@ -0,0 +1,39 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.order.HardMessageOutboxEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * Hardware用餐核销业务消息Outbox数据访问入口。 + */ +@Mapper +public interface HardMessageOutboxMapper extends PjcloudBaseMapper { + + /** + * 跨租户物理删除超过30天的成功Outbox,每次限制数量以控制锁范围。 + * + * @param before 成功处理时间截止值 + * @param limit 单次最大删除数量 + * @return 实际删除数量 + */ + @InterceptorIgnore(tenantLine = "true") + @Delete("DELETE FROM hard_message_outbox WHERE status = 'SUCCESS' AND processed_time < #{before} LIMIT #{limit}") + int physicalDeleteSuccessBefore(@Param("before") LocalDateTime before, @Param("limit") int limit); + + /** + * 跨租户物理删除超过一年的失败终态Outbox,每次限制数量以保留排查窗口。 + * + * @param before 创建时间截止值 + * @param limit 单次最大删除数量 + * @return 实际删除数量 + */ + @InterceptorIgnore(tenantLine = "true") + @Delete("DELETE FROM hard_message_outbox WHERE status = 'FAILED' AND create_time < #{before} LIMIT #{limit}") + int physicalDeleteFailedBefore(@Param("before") LocalDateTime before, @Param("limit") int limit); +} 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 d88ad49bc..bcf822aad 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 @@ -27,7 +27,7 @@ import java.util.List; public interface HardOrderMapper extends PjcloudBaseMapper { /** - * 按主键查询并锁定订单行,供退款创建、审核、取消和后台直退串行化同一订单。 + * 按主键查询并锁定订单行,供退款创建、审核、取消、后台直退和预约核销串行化同一订单。 * * @param id 订单主键 * @return 锁定后的订单;不存在时返回 {@code null} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 2151d4275..77f024c45 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -4,9 +4,14 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import java.util.List; +import java.util.Map; +/** + * 售后商品快照与订单商品退款数量聚合服务。 + */ public interface HardAfterSaleCarService extends IService { /** @@ -42,6 +47,21 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 + * + * @param carParentId 原订单父购物车 ID + * @param carList 原订单购物车展示对象;原始 {@code productCount} 不会被修改 + */ + void fillRefundQuantityStats(Long carParentId, List carList); + + /** + * 批量补充多个订单购物车的退款数量统计,整个列表固定执行两次售后聚合查询。 + * + * @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射 + */ + void fillRefundQuantityStats(Map> carListsByParentId); + /** * 查询单个售后申请保存的商品快照。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardMessageOutboxService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardMessageOutboxService.java new file mode 100644 index 000000000..29a7cd024 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardMessageOutboxService.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.service; + +/** + * Hardware用餐核销消息Outbox处理服务,负责投递、重试恢复和保留清理。 + */ +public interface HardMessageOutboxService { + + /** + * 扫描并处理到期的核销Outbox。 + * + * @return 本轮成功领取的事件数量 + */ + int processDueEvents(); + + /** + * 分批物理清理超过保留期的成功和失败终态Outbox。 + * + * @return 本轮实际清理记录数量 + */ + int cleanupExpiredEvents(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardMessageOutboxServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardMessageOutboxServiceImpl.java new file mode 100644 index 000000000..7f4d0138b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardMessageOutboxServiceImpl.java @@ -0,0 +1,237 @@ +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.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.hard.api.entity.order.HardMessageOutboxEntity; +import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; +import com.bsd.pjcloud.hard.service.HardMessageOutboxService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppMessageService; +import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Hardware核销消息Outbox处理实现,以Outbox主键构造稳定事件ID向Thirdparty进行至少一次投递。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardMessageOutboxServiceImpl implements HardMessageOutboxService { + + /** 待处理Outbox状态。 */ + private static final String STATUS_PENDING = "PENDING"; + + /** 正在由某一服务实例处理的Outbox状态。 */ + private static final String STATUS_PROCESSING = "PROCESSING"; + + /** 等待下一次退避重试的Outbox状态。 */ + private static final String STATUS_RETRY = "RETRY"; + + /** Thirdparty已经幂等接收的Outbox终态。 */ + private static final String STATUS_SUCCESS = "SUCCESS"; + + /** 达到最大尝试次数后的Outbox失败终态。 */ + private static final String STATUS_FAILED = "FAILED"; + + /** PROCESSING状态无更新后允许其他实例恢复的超时时间,单位分钟。 */ + private static final long PROCESSING_TIMEOUT_MINUTES = 10L; + + /** 单个核销事件向Thirdparty提交的最大尝试次数。 */ + private static final int MAX_ATTEMPTS = 8; + + /** 单轮跨租户最多扫描的候选Outbox数量。 */ + private static final int SCAN_LIMIT = 100; + + /** 单轮清理每种终态最多物理删除的记录数量。 */ + private static final int CLEANUP_BATCH_SIZE = 1000; + + /** 成功Outbox保留天数。 */ + private static final long SUCCESS_RETENTION_DAYS = 30L; + + /** 失败终态Outbox保留天数。 */ + private static final long FAILED_RETENTION_DAYS = 365L; + + /** 八次尝试后续使用的退避分钟数,索引与已完成尝试次数对应。 */ + private static final List RETRY_INTERVAL_MINUTES = List.of(1L, 5L, 15L, 60L, 180L, 360L, 720L); + + /** Hardware核销Outbox数据访问入口。 */ + private final HardMessageOutboxMapper outboxMapper; + + /** Thirdparty业务消息中心内部Feign客户端。 */ + private final RemoterThirdAppMessageService appMessageService; + + /** 单条事件领取和结果落库使用的本地事务模板。 */ + private final TransactionTemplate transactionTemplate; + + /** + * 跨租户扫描到期候选,并逐条恢复租户上下文处理。 + * + * @return 本轮成功领取的事件数量 + */ + @Override + public int processDueEvents() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime processingCutoff = now.minusMinutes(PROCESSING_TIMEOUT_MINUTES); + List candidates = outboxMapper.selectList( + Wrappers.lambdaQuery() + .and(wrapper -> wrapper + .eq(HardMessageOutboxEntity::getStatus, STATUS_PENDING) + .or(current -> current.eq(HardMessageOutboxEntity::getStatus, STATUS_RETRY) + .le(HardMessageOutboxEntity::getNextRetryTime, now)) + .or(current -> current.eq(HardMessageOutboxEntity::getStatus, STATUS_PROCESSING) + .le(HardMessageOutboxEntity::getUpdateTime, processingCutoff))) + .orderByAsc(HardMessageOutboxEntity::getCreateTime) + .last("limit " + SCAN_LIMIT)); + int processed = 0; + for (HardMessageOutboxEntity candidate : candidates) { + if (candidate.getTenantId() == null) { + log.warn("核销消息Outbox缺少租户ID,outboxId:{}", candidate.getId()); + continue; + } + try { + Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), tenantId -> + transactionTemplate.execute(status -> processOne(candidate.getId(), processingCutoff))); + if (Boolean.TRUE.equals(claimed)) { + processed++; + } + } catch (Exception exception) { + log.error("核销消息Outbox处理异常,outboxId:{},tenantId:{}", candidate.getId(), candidate.getTenantId(), exception); + } + } + return processed; + } + + /** + * 分批物理删除到期终态记录。 + * + * @return 本轮实际删除数量 + */ + @Override + public int cleanupExpiredEvents() { + LocalDateTime now = LocalDateTime.now(); + return outboxMapper.physicalDeleteSuccessBefore(now.minusDays(SUCCESS_RETENTION_DAYS), CLEANUP_BATCH_SIZE) + + outboxMapper.physicalDeleteFailedBefore(now.minusDays(FAILED_RETENTION_DAYS), CLEANUP_BATCH_SIZE); + } + + /** + * 条件领取单条核销事件并提交Thirdparty,崩溃恢复继续使用相同事件ID。 + * + * @param outboxId Outbox主键 + * @param processingCutoff PROCESSING超时恢复截止时间 + * @return 是否成功领取并执行 + */ + private boolean processOne(Long outboxId, LocalDateTime processingCutoff) { + HardMessageOutboxEntity current = outboxMapper.selectById(outboxId); + if (current == null || current.getAttemptCount() >= MAX_ATTEMPTS || !isDue(current, processingCutoff)) { + return false; + } + HardMessageOutboxEntity claim = new HardMessageOutboxEntity(); + claim.setStatus(STATUS_PROCESSING); + claim.setNextRetryTime(null); + int claimed = outboxMapper.update(claim, Wrappers.lambdaUpdate() + .eq(HardMessageOutboxEntity::getId, current.getId()) + .eq(HardMessageOutboxEntity::getStatus, current.getStatus()) + .eq(STATUS_PROCESSING.equals(current.getStatus()), HardMessageOutboxEntity::getUpdateTime, current.getUpdateTime())); + if (claimed == 0) { + return false; + } + int attemptCount = current.getAttemptCount() + 1; + try { + R result = appMessageService.receiveMealVerifiedEvent(buildEvent(current)); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new IllegalStateException(result == null ? "Thirdparty无响应" + : StrUtil.blankToDefault(result.getMsg(), "Thirdparty未确认接收核销消息事件")); + } + HardMessageOutboxEntity success = new HardMessageOutboxEntity(); + success.setStatus(STATUS_SUCCESS); + success.setAttemptCount(attemptCount); + success.setProcessedTime(LocalDateTime.now()); + success.setLastError(null); + outboxMapper.update(success, Wrappers.lambdaUpdate() + .eq(HardMessageOutboxEntity::getId, current.getId()) + .eq(HardMessageOutboxEntity::getStatus, STATUS_PROCESSING)); + } catch (Exception exception) { + markFailure(current.getId(), attemptCount, exception); + } + return true; + } + + /** + * 判断当前记录是否满足首次、重试或超时恢复条件。 + * + * @param outbox 当前Outbox记录 + * @param processingCutoff PROCESSING超时恢复截止时间 + * @return 是否允许领取 + */ + private boolean isDue(HardMessageOutboxEntity outbox, LocalDateTime processingCutoff) { + if (STATUS_PENDING.equals(outbox.getStatus())) { + return true; + } + if (STATUS_RETRY.equals(outbox.getStatus())) { + return outbox.getNextRetryTime() != null && !outbox.getNextRetryTime().isAfter(LocalDateTime.now()); + } + return STATUS_PROCESSING.equals(outbox.getStatus()) && outbox.getUpdateTime() != null + && !outbox.getUpdateTime().isAfter(processingCutoff); + } + + /** + * 将核销Outbox快照转换为类型明确的跨服务事件。 + * + * @param outbox 核销Outbox快照 + * @return Thirdparty用餐核销事件 + */ + private MealVerifiedEventDto buildEvent(HardMessageOutboxEntity outbox) { + MealVerifiedEventDto event = new MealVerifiedEventDto(); + event.setEventId("MEAL:" + outbox.getId()); + event.setEventCode(outbox.getEventCode()); + event.setTenantId(outbox.getTenantId()); + event.setSubjectUserId(outbox.getSubjectUserId()); + event.setOrderId(outbox.getOrderId()); + event.setSourceMchId(outbox.getSourceMchId()); + event.setSourceMchName(outbox.getSourceMchName()); + event.setMealName(outbox.getMealName()); + event.setOccurredTime(outbox.getOccurredTime()); + return event; + } + + /** + * 将事件安排退避重试或标记失败终态,错误摘要严格截断。 + * + * @param outboxId Outbox主键 + * @param attemptCount 本次完成后的累计尝试次数 + * @param exception 本轮处理异常 + */ + private void markFailure(Long outboxId, int attemptCount, Exception exception) { + HardMessageOutboxEntity failure = new HardMessageOutboxEntity(); + failure.setAttemptCount(attemptCount); + failure.setLastError(StrUtil.sub(StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName()), 0, 2000)); + if (attemptCount >= MAX_ATTEMPTS) { + failure.setStatus(STATUS_FAILED); + failure.setNextRetryTime(null); + } else { + failure.setStatus(STATUS_RETRY); + failure.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(attemptCount))); + } + outboxMapper.update(failure, Wrappers.lambdaUpdate() + .eq(HardMessageOutboxEntity::getId, outboxId) + .eq(HardMessageOutboxEntity::getStatus, STATUS_PROCESSING)); + } + + /** + * 根据已完成尝试次数取得下一次退避分钟数。 + * + * @param attemptCount 已完成尝试次数 + * @return 下一次重试等待分钟数 + */ + private long retryMinutes(int attemptCount) { + int index = Math.max(0, Math.min(attemptCount - 1, RETRY_INTERVAL_MINUTES.size() - 1)); + return RETRY_INTERVAL_MINUTES.get(index); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java index bb2279267..aa980c5f3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.service.HardApiService; +import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; import com.bsd.pjcloud.hard.service.ReservationMenuQueryService; @@ -54,6 +55,10 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ private final HardManagerService hardManagerService; private final HardApiService hardApiService; private final HardOrderService hardOrderService; + + /** 订单购物车服务,用于在直接核销接口中再次确认至少存在一份可履约商品。 */ + private final HardCarService hardCarService; + private final RemoteUserService remoteUserService; private final RemoteDeptService remoteDeptService; private final RemoteMchService remoteMchService; @@ -61,6 +66,7 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ /** 食堂菜品远程服务,用于批量补充订单商品类型和套餐组成。 */ private final RemoteCanteenSpuService remoteCanteenSpuService; + /** {@inheritDoc} */ /** {@inheritDoc} */ @Override public ReservationMenuQueryVo query(ReservationMenuQueryDto dto) { @@ -72,7 +78,9 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ query.setMerchantId(terminalMerchant.merchant().getId()); query.setOrderType(HardOrderTypeEnum.RESERVE.getType()); query.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType())); - query.setRefundStatusCodes(List.of(HardRefundStatusEnum.NONE.getStatus(), HardRefundStatusEnum.REJECT.getStatus())); + // 部分退款成功时订单仍为已支付并可继续核销,只有全额退款会由订单状态过滤掉。 + query.setRefundStatusCodes(List.of(HardRefundStatusEnum.NONE.getStatus(), HardRefundStatusEnum.REJECT.getStatus(), + HardRefundStatusEnum.SUCCESS.getStatus())); query.setApptIdOrApptRuleIdNotNull(true); query.setApptUseDateStart(LocalDate.now().atStartOfDay()); query.setIsFindCarList(true); @@ -84,11 +92,13 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return aggregate(recognizedUser, terminalMerchant, orders, dto.getMaskName(), productMetadata); } + /** {@inheritDoc} */ @Override public Boolean cancel(ReservationMenuCancelDto dto) { return cancel(dto, null); } + /** {@inheritDoc} */ @Override public Boolean cancel(ReservationMenuCancelDto dto, Long hardId) { HardOrderEntity order = hardOrderService.getById(dto.getOrderId()); @@ -98,11 +108,14 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ throw new RuntimeException("该预约订单当前不可核销"); } if (!isCancelableRefundStatus(order.getRefundStatus())) { - throw new RuntimeException("该预约订单正在退款或已退款,不能核销"); + throw new RuntimeException("该预约订单正在退款,不能核销"); } if (order.getApptUseDate() == null || order.getApptUseDate().isBefore(LocalDate.now())) { throw new RuntimeException("该预约订单已失效"); } + if (!hasFulfillableProduct(order.getId())) { + throw new RuntimeException("该预约订单商品已全部退款,不能核销"); + } HandCancelCodeDto cancelDto = new HandCancelCodeDto(); cancelDto.setOrderId(order.getId()); @@ -111,6 +124,17 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return hardOrderService.handCancelCode(cancelDto); } + /** + * 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。 + * + * @param orderId 待核销预约订单 ID + * @return 存在剩余商品时返回 {@code true} + */ + private boolean hasFulfillableProduct(Long orderId) { + List cars = hardCarService.getByOrderId(orderId); + return cars != null && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + } + private Long resolveVerificationHardId(HardOrderEntity order, Long hardId) { if (hardId == null) return -1L; HardManagerEntity hard = hardManagerService.getById(hardId); @@ -120,10 +144,17 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return hard.getId(); } + /** + * 判断预约订单退款聚合状态是否允许继续履约,历史空值按未售后兼容,未知状态按不可核销处理。 + * + * @param refundStatus 订单退款聚合状态编码 + * @return 未售后、退款拒绝或部分退款成功时返回 {@code true} + */ private static boolean isCancelableRefundStatus(String refundStatus) { return StrUtil.isBlank(refundStatus) || Objects.equals(refundStatus, HardRefundStatusEnum.NONE.getStatus()) - || Objects.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()); + || Objects.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()) + || Objects.equals(refundStatus, HardRefundStatusEnum.SUCCESS.getStatus()); } private TerminalMerchant resolveTerminalMerchant(Long hardId) { @@ -263,27 +294,28 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ Map> grouped = new TreeMap<>(); for (HardOrderVo order : orders) { + List fulfillableItems = new ArrayList<>(); + if (order.getCarList() != null) { + for (HardCarVo car : order.getCarList()) { + int effectiveCount = resolveEffectiveProductCount(car); + if (effectiveCount <= 0 || StrUtil.isBlank(car.getProductName())) continue; + // 历史购物车允许商品 ID 为空,不应拿空键访问 Map.of() 这类不可变映射。 + ProductMetadata metadata = car.getProductId() == null ? null : productMetadata.get(car.getProductId()); + fulfillableItems.add(new OrderItemAccumulator( + car.getProductId(), car.getProductName().trim(), car.getProductPic(), effectiveCount, + metadata == null ? 1 : metadata.spuType(), + metadata == null ? List.of() : metadata.comboItems())); + } + } + // 全部商品退款后不能只留下一个空订单核销入口,订单和餐段都应从结果中消失。 + if (fulfillableItems.isEmpty()) continue; String hourType = resolveHourType(order); LinkedHashMap meals = grouped.computeIfAbsent(order.getApptUseDate(), ignored -> new LinkedHashMap<>()); MealAccumulator meal = meals.computeIfAbsent(hourType, ignored -> new MealAccumulator(hourType, resolveMealTitle(order, hourType))); - List orderItems = null; + fulfillableItems.forEach(item -> meal.items().add(item.productName())); if (order.getId() != null) { meal.orderIds().add(order.getId()); - orderItems = meal.orderItems().computeIfAbsent(order.getId(), ignored -> new ArrayList<>()); - } - if (order.getCarList() == null) continue; - for (HardCarVo car : order.getCarList()) { - if (car != null && StrUtil.isNotBlank(car.getProductName())) { - String productName = car.getProductName().trim(); - meal.items().add(productName); - if (orderItems != null) { - ProductMetadata metadata = productMetadata.get(car.getProductId()); - orderItems.add(new OrderItemAccumulator( - car.getProductId(), productName, car.getProductPic(), car.getProductCount(), - metadata == null ? 1 : metadata.spuType(), - metadata == null ? List.of() : metadata.comboItems())); - } - } + meal.orderItems().put(order.getId(), fulfillableItems); } } @@ -309,6 +341,18 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return result; } + /** + * 解析退款成功后的剩余可履约份数;旧服务数据缺少统计字段时回退原购买数量。 + * + * @param car 订单购物车展示对象 + * @return 可出现在终端待核销菜单中的非负份数 + */ + private static int resolveEffectiveProductCount(HardCarVo car) { + if (car == null) return 0; + Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); + } + /** * 将单个餐段聚合结果转换为终端响应,并保持订单及商品的稳定顺序。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 11ecfed6a..539dc118f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; @@ -16,10 +17,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * 购物车退款表 + * 售后商品快照服务实现,保留原购物车数量并为各消费端补充退款后的只读数量口径。 */ @Validated @Slf4j @@ -27,6 +30,7 @@ import java.util.List; @Service public class HardAfterSaleCarServiceImpl extends ServiceImpl implements HardAfterSaleCarService { + /** 订单 Mapper,用于从订单 ID 解析其父购物车 ID。 */ private final HardOrderMapper hardOrderMapper; @Override @@ -68,6 +72,101 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl同一商品存在多行历史快照时按购物车顺序分配累计退款份数,避免把商品级汇总重复写入每一行。

+ * + * @param carParentId 原订单父购物车 ID + * @param carList 原订单购物车展示对象;方法只写入非持久化统计字段 + */ + @Override + public void fillRefundQuantityStats(Long carParentId, List carList) { + if (carParentId == null || ObjectUtil.isEmpty(carList)) return; + fillRefundQuantityStats(Map.of(carParentId, carList)); + } + + /** + * 批量统计避免订单列表按每笔订单额外执行两次售后查询;每个购物车仍独立分配商品级累计份数。 + * + * @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射 + */ + @Override + public void fillRefundQuantityStats(Map> carListsByParentId) { + if (ObjectUtil.isEmpty(carListsByParentId)) return; + List parentIds = carListsByParentId.keySet().stream().filter(java.util.Objects::nonNull).distinct().toList(); + if (parentIds.isEmpty()) return; + Map> refundedByParent = quantityMap(parentIds, HardSaleStatusEnum.COMPLETE.getStatus()); + Map> applyingByParent = quantityMap(parentIds, HardSaleStatusEnum.APPLY.getStatus()); + carListsByParentId.forEach((parentId, carList) -> fillCarQuantityStats(carList, + new LinkedHashMap<>(refundedByParent.getOrDefault(parentId, Map.of())), + new LinkedHashMap<>(applyingByParent.getOrDefault(parentId, Map.of())))); + } + + /** + * 为同一父购物车的每行商品分配累计售后份数,重复商品行不会重复消费同一份退款。 + * + * @param carList 原订单购物车展示对象 + * @param refundedQuantities 尚未分配的已退款商品份数 + * @param applyingQuantities 尚未分配的申请中商品份数 + */ + private void fillCarQuantityStats(List carList, Map refundedQuantities, + Map applyingQuantities) { + if (ObjectUtil.isEmpty(carList)) return; + for (HardCarVo car : carList) { + int originalCount = safeCount(car.getProductCount()); + int refundedCount = takeQuantity(refundedQuantities, car.getProductId(), originalCount); + int applyingCount = takeQuantity(applyingQuantities, car.getProductId(), originalCount - refundedCount); + car.setOriginalProductCount(originalCount); + car.setRefundedProductCount(refundedCount); + car.setApplyingProductCount(applyingCount); + car.setEffectiveProductCount(Math.max(0, originalCount - refundedCount)); + } + } + + /** + * 查询单一售后状态下各商品累计份数,并转换为后续可扣减的可变映射。 + * + * @param carParentIds 原订单父购物车 ID 集合 + * @param status 售后状态协议编码 + * @return 父购物车 ID 到商品累计份数映射,异常负数按零处理 + */ + private Map> quantityMap(List carParentIds, String status) { + Map> result = new LinkedHashMap<>(); + for (HardAfterSaleCarEntity item : baseMapper.getOccupiedByCarParentIds(carParentIds, List.of(status))) { + if (item.getParentCarId() == null || item.getProductId() == null) continue; + result.computeIfAbsent(item.getParentCarId(), ignored -> new LinkedHashMap<>()) + .merge(item.getProductId(), safeCount(item.getProductCount()), Integer::sum); + } + return result; + } + + /** + * 从商品级累计份数中为当前购物车行领取不超过本行上限的数量,并保存剩余值供重复商品行继续领取。 + * + * @param quantities 商品 ID 到尚未分配份数的映射 + * @param productId 当前购物车商品 ID + * @param limit 当前行最多允许分配的份数 + * @return 分配给当前购物车行的非负份数 + */ + private int takeQuantity(Map quantities, Long productId, int limit) { + if (productId == null || limit <= 0) return 0; + int remaining = Math.max(0, quantities.getOrDefault(productId, 0)); + int taken = Math.min(limit, remaining); + quantities.put(productId, remaining - taken); + return taken; + } + + /** + * 将数据库可空商品份数转换为非负整数,防止历史异常值扩大剩余数量。 + * + * @param count 数据库商品份数 + * @return 可参与退款统计的非负份数 + */ + private int safeCount(Integer count) { + return count == null ? 0 : Math.max(0, count); + } + /** * 审核与详情展示只读取申请时保存的商品快照,避免依赖已变化的商品主数据。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 3bdaa609b..1c471c272 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -26,8 +26,18 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ private final HardCarMapper hardCarMapper; + /** + * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 + * + * @param page 分页参数 + * @param dto 预约统计筛选条件,其中预约时间范围由 {@code apptUseDateTimeList} 提供 + * @param dataScope 数据权限范围,当前查询链路暂未启用 + * @return 按预约使用日期分页的菜品预约统计结果 + */ @Override public Page productReservTotal(Page page, HardOrderDto dto, DataScope dataScope) { + // 专用预约统计接口不接受支付时间口径,避免与预约使用日期形成意外的交叉条件。 + dto.setPayTimeList(null); dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); Page pageList = hardCarMapper.productTotalByApptUseDate(page, dto, null); if (ObjectUtil.isNotEmpty(pageList)) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index b8ab385f8..0c37f3074 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; @@ -28,6 +29,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.mapper.order.HardParentCarMapper; +import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardSpuService; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; @@ -41,7 +43,6 @@ import com.bsd.pjcloud.supermarket.vo.mall.MallProductDetailAppVo; import com.bsd.pjcloud.supermarket.vo.mall.MallProductSkuAppVo; import com.bsd.pjcloud.thirdparty.dto.pay.PayDto; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -64,7 +65,6 @@ import java.util.concurrent.atomic.AtomicInteger; * @date 2025-01-05 16:44:54 */ @Validated -@Slf4j @RequiredArgsConstructor @Service public class HardCarServiceImpl extends ServiceImpl implements HardCarService { @@ -76,6 +76,9 @@ public class HardCarServiceImpl extends ServiceImpl mchDetail(Long merchantId, Long userId, HardTypeEnum hardTypeEnum) { - HardParentCarEntity parentCarDO = getParentCar(merchantId, userId, hardTypeEnum); + HardParentCarEntity parentCarDO = getParentCar(merchantId, userId, hardTypeEnum, + resolveProductSource(hardTypeEnum, HardOrderTypeEnum.RESERVE)); if (ObjectUtil.isEmpty(parentCarDO)) return null; List carList = getByParentCarId(parentCarDO.getId()); if (ObjectUtil.isEmpty(carList)) return null; @@ -116,12 +128,42 @@ public class HardCarServiceImpl extends ServiceImpl getByOrderId(Long orderId) { HardOrderEntity order = hardCarMapper.selectById(orderId); if (ObjectUtil.isEmpty(order)) return null; List list = getByParentCarId(order.getParentCarId()); - return BeanUtils.toBean(list, HardCarVo.class); + List result = BeanUtils.toBean(list, HardCarVo.class); + fillAppProductDetailMetadata(order.getParentCarId(), result); + hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), result); + return result; + } + + /** + * 使用下单时固化的父购物车来源和商品行详情 ID 写入 App 路由元数据,不在订单详情阶段远程反查业务表。 + * + * @param parentCarId 原订单关联的父购物车 ID + * @param carList 订单商品快照;只补充响应字段,不修改持久化数据 + */ + private void fillAppProductDetailMetadata(Long parentCarId, List carList) { + if (parentCarId == null || ObjectUtil.isEmpty(carList)) return; + HardParentCarEntity parentCar = hardParentCarMapper.selectById(parentCarId); + HardProductSourceEnum productSource = parentCar == null + ? null + : HardProductSourceEnum.valueOfByType(parentCar.getProductSource()); + if (productSource == null) return; + carList.stream() + .filter(item -> item.getSourceProductId() != null) + .forEach(item -> { + item.setDetailSource(productSource.getType()); + item.setDetailProductId(item.getSourceProductId()); + }); } @Override @@ -136,6 +178,11 @@ public class HardCarServiceImpl extends ServiceImpl carList = dto.getList().stream().filter(v -> v.getProductCount() > 0).toList(); - HardParentCarEntity parentCar = getParentCar(dto.getMerchantId(), dto.getUserId(), dto.getHardType()); + HardParentCarEntity parentCar = getParentCar(dto.getMerchantId(), dto.getUserId(), dto.getHardType(), productSource); if (ObjectUtil.isNotEmpty(parentCar)) { baseMapper.delete(new LambdaQueryWrapper().eq(HardCarEntity::getParentCarId, parentCar.getId())); // 添加购物车 if (ObjectUtil.isNotEmpty(carList)) { getSelf().appCreateCard(parentCar.getId(), dto.getHardType(), dto.getOrderType(), carList); } + HardParentCarEntity updateParentCar = new HardParentCarEntity(); + updateParentCar.setId(parentCar.getId()); + updateParentCar.setProductSource(productSource == null ? null : productSource.getType()); if (ObjectUtil.isNotEmpty(dto.getTakeOutSetting())) { - HardParentCarEntity updateParentCar = new HardParentCarEntity(); - updateParentCar.setId(parentCar.getId()); updateParentCar.setTakeOutSettingJson(dto.getTakeOutSetting()); - hardParentCarMapper.updateById(updateParentCar); } + hardParentCarMapper.updateById(updateParentCar); return; } // 添加父订单 - HardParentCarEntity parentCarObj = HardParentCarEntity.builder().userId(dto.getUserId()).merchantId(dto.getMerchantId()).hardType(dto.getHardType().getType()).orderStatus(HardOrderStatusEnum.CREATE.getType()).takeOutSettingJson(dto.getTakeOutSetting()).build(); + HardParentCarEntity parentCarObj = HardParentCarEntity.builder().userId(dto.getUserId()).merchantId(dto.getMerchantId()) + .hardType(dto.getHardType().getType()).productSource(productSource == null ? null : productSource.getType()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()).takeOutSettingJson(dto.getTakeOutSetting()).build(); hardParentCarMapper.insert(parentCarObj); // 添加购物车 getSelf().appCreateCard(parentCarObj.getId(), dto.getHardType(), dto.getOrderType(), carList); @@ -201,6 +252,10 @@ public class HardCarServiceImpl extends ServiceImpl saveBatchCarts(HardCarBatchSaveOrUpdateDto dto, boolean useDailyMenuProducts) { if (dto == null || ObjectUtil.isEmpty(dto.getGroups())) { @@ -234,10 +289,14 @@ public class HardCarServiceImpl extends ServiceImpl productList) { List productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList(); @@ -289,6 +352,7 @@ public class HardCarServiceImpl extends ServiceImpl list) { // 创建父订单 - HardParentCarEntity parentCarObj = HardParentCarEntity.builder().userId(userId).merchantId(merchantId).hardType(sysType.getType()).orderStatus(HardOrderStatusEnum.PAY.getType()).build(); + HardProductSourceEnum productSource = resolveProductSource(sysType, orderTYpe); + HardParentCarEntity parentCarObj = HardParentCarEntity.builder().userId(userId).merchantId(merchantId).hardType(sysType.getType()) + .productSource(productSource == null ? null : productSource.getType()).orderStatus(HardOrderStatusEnum.PAY.getType()).build(); hardParentCarMapper.insert(parentCarObj); getSelf().appCreateCard(parentCarObj.getId(), sysType, orderTYpe, list); return parentCarObj.getId(); @@ -502,6 +575,14 @@ public class HardCarServiceImpl extends ServiceImpl productList) { List productIds = productList.stream().map(HardCreateCarDto::getProductId).toList(); @@ -516,6 +597,7 @@ public class HardCarServiceImpl extends ServiceImpl ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { int totalPrice = v.getProductCount() * spu.getPrice(); item.setProductId(spu.getId()); + item.setSourceProductId(spu.getId()); item.setProductName(spu.getName()); item.setProductPrice(spu.getPrice()); item.setProductPic(spu.getPicUrl()); @@ -537,6 +619,7 @@ public class HardCarServiceImpl extends ServiceImpl ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { int totalPrice = v.getProductCount() * spu.getPrice(); item.setProductId(spu.getId()); + item.setSourceProductId(spu.getId()); item.setProductName(spu.getName()); item.setProductPrice(spu.getPrice()); item.setProductPic(spu.getPicUrl()); @@ -675,6 +758,7 @@ public class HardCarServiceImpl extends ServiceImpl() .eq(HardParentCarEntity::getUserId, userId) .eq(HardParentCarEntity::getMerchantId, merchantId) .eq(HardParentCarEntity::getHardType, hardTypeEnum.getType()) + .eq(productSource != null, HardParentCarEntity::getProductSource, + productSource == null ? null : productSource.getType()) + .isNull(productSource == null, HardParentCarEntity::getProductSource) .eq(HardParentCarEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType()) ); } + /** + * 根据现有购物车创建分支解析可进入 App 详情页的商品来源。 + * 每日菜谱使用专用上传接口单独指定,超市、智能柜和第三方商品暂不属于本次四类协议,因此返回 {@code null}。 + * + * @param sysType 购物车所属系统类型 + * @param orderType 创建购物车时使用的订单类型 + * @return 可持久化的商品来源;当前业务没有对应详情协议时返回 {@code null} + */ + private HardProductSourceEnum resolveProductSource(HardTypeEnum sysType, HardOrderTypeEnum orderType) { + if (ObjectUtil.equals(sysType, HardTypeEnum.CANTEEN)) { + if (ObjectUtil.equals(orderType, HardOrderTypeEnum.RESERVE)) { + return HardProductSourceEnum.CANTEEN_ONLINE_SPU; + } + if (ObjectUtil.equals(orderType, HardOrderTypeEnum.BELOW)) { + return HardProductSourceEnum.CANTEEN_SPU; + } + } + if (ObjectUtil.equals(sysType, HardTypeEnum.SELF_APP)) { + return HardProductSourceEnum.MALL_PRODUCT; + } + return null; + } + // 获取线下超市商品价格 private Integer getSupermarket(SmErpUnitWithSpuVo vo) { if (ObjectUtil.isEmpty(vo)) return 0; 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 2c67b6676..e8b940403 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 @@ -48,6 +48,7 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; 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.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelActCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardDoneHardQrCodeScanOrderDto; @@ -89,6 +90,7 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo; import com.bsd.pjcloud.hard.util.MchJobTimeUtils; import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; @@ -98,6 +100,7 @@ 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.api.feign.RemoterThirdAppPlusPayService; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; @@ -180,6 +183,8 @@ public class HardOrderServiceImpl extends ServiceImpl carList = hardCarService.getByParentCarId(detail.getParentCarId()); List carVoList = BeanUtils.toBean(carList, HardCarVo.class); + hardAfterSaleCarService.fillRefundQuantityStats(detail.getParentCarId(), carVoList); detail.setCarList(carVoList); }); // 预约订单 @@ -2141,6 +2151,9 @@ public class HardOrderServiceImpl extends ServiceImpl sourceCarList, Long payerUserId) { HardParentCarEntity targetParentCar = HardParentCarEntity.builder() .userId(payerUserId) .merchantId(sourceParentCar.getMerchantId()) .hardType(sourceParentCar.getHardType()) + .productSource(sourceParentCar.getProductSource()) .orderStatus(HardOrderStatusEnum.CREATE.getType()) .takeOutSettingJson(sourceParentCar.getTakeOutSettingJson()) .build(); @@ -2190,6 +2210,7 @@ public class HardOrderServiceImpl extends ServiceImpl clonedCars = sourceCarList.stream().map(source -> HardCarEntity.builder() .parentCarId(targetParentCar.getId()) .productId(source.getProductId()) + .sourceProductId(source.getSourceProductId()) .productName(source.getProductName()) .productPic(source.getProductPic()) .productPrice(source.getProductPrice()) @@ -3294,6 +3315,9 @@ public class HardOrderServiceImpl extends ServiceImpl reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild); if (reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) { throw new RuntimeException("钱包扣款错误"); @@ -3395,12 +3419,19 @@ public class HardOrderServiceImpl extends ServiceImpl 0; + boolean isMealVerification = ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + && ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + && ObjectUtil.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()); + int updated = isMealVerification + ? baseMapper.update(updateObj, new LambdaUpdateWrapper() + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())) + : baseMapper.updateById(updateObj); + if (updated == 0) { + throw new RuntimeException("订单状态已变化,核销失败"); + } + if (isMealVerification) { + createMealVerifiedOutbox(order); + } + return true; + } + + /** + * 校验食堂预约订单是否仍可履约,确保旧终端直接调用通用核销接口时不能绕过退款状态与剩余数量。 + * + *

申请中的商品仍属于原订单履约资源,但为避免退款与出餐同时成功,整笔订单在申请处理期间暂停核销; + * 部分退款完成后只要仍有剩余商品即可继续核销。其他订单类型保持通用核销接口的历史行为。

+ * + * @param order 已持有数据库行锁的订单快照 + */ + private void validateCanteenReservationBeforeCancel(HardOrderEntity order) { + boolean isCanteenReservation = ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + && ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()); + if (!isCanteenReservation) return; + if (!ObjectUtil.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) { + throw new RuntimeException("该预约订单当前不可核销"); + } + if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) { + throw new RuntimeException("该预约订单正在退款,不能核销"); + } + List cars = hardCarService.getByOrderId(order.getId()); + boolean hasFulfillableProduct = ObjectUtil.isNotEmpty(cars) + && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + if (!hasFulfillableProduct) { + throw new RuntimeException("该预约订单商品已全部退款,不能核销"); + } + } + + /** + * 判断订单聚合退款状态是否允许预约履约;未知状态按不可核销处理,避免未来状态被旧终端误放行。 + * + * @param refundStatus 订单退款聚合状态,历史空值等同未售后 + * @return 未售后、退款拒绝或部分退款成功时返回 {@code true} + */ + private boolean isReservationRefundStatusFulfillable(String refundStatus) { + return StrUtil.isBlank(refundStatus) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.NONE.getStatus()) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.SUCCESS.getStatus()); + } + + /** + * 解析购物车退款成功后的剩余可履约份数,兼容历史响应只包含原购买数量的情况。 + * + * @param car 带售后统计的订单商品快照 + * @return 非负剩余份数 + */ + private int resolveEffectiveProductCount(HardCarVo car) { + if (car == null) return 0; + Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); + } + + /** + * 在预约订单PAY到CANCEL状态转换的同一本地事务内保存用餐核销消息快照。 + * + * @param order 状态转换前的食堂预约订单 + */ + private void createMealVerifiedOutbox(HardOrderEntity order) { + if (order.getTenantId() == null || order.getUserId() == null || order.getMerchantId() == null) { + throw new IllegalStateException("食堂预约核销缺少租户、用餐人或商户,无法创建消息Outbox"); + } + HardMessageOutboxEntity outbox = new HardMessageOutboxEntity(); + outbox.setTenantId(order.getTenantId()); + outbox.setEventCode(AppMessageEventEnum.MEAL_VERIFIED.getCode()); + outbox.setOrderId(order.getId()); + outbox.setSubjectUserId(order.getUserId()); + outbox.setSourceMchId(order.getMerchantId()); + outbox.setSourceMchName(resolveMessageMerchantName(order.getMerchantId())); + outbox.setMealName(StrUtil.blankToDefault(order.getProductDesc(), "预约用餐")); + outbox.setOccurredTime(LocalDateTime.now()); + outbox.setStatus("PENDING"); + outbox.setAttemptCount(0); + if (hardMessageOutboxMapper.insert(outbox) != 1) { + throw new IllegalStateException("创建用餐核销消息Outbox失败"); + } + } + + /** + * 尝试补充核销商户名称快照;远程服务异常只影响展示名称,不应回滚已经完成的核销。 + * + * @param merchantId 订单实际商户ID + * @return 商户名称,无法取得时为空 + */ + private String resolveMessageMerchantName(Long merchantId) { + try { + R result = remoteMchService.getById(merchantId); + return result != null && result.isOk() && result.getData() != null ? result.getData().getName() : null; + } catch (Exception exception) { + log.warn("核销消息商户名称解析失败,merchantId:{},errorType:{}", merchantId, + exception.getClass().getSimpleName()); + return null; + } } /** @@ -4173,11 +4311,14 @@ public class HardOrderServiceImpl extends ServiceImpl findCarListFuture = CompletableFuture.runAsync(() -> { if (dto.getIsFindCarList()) { + Map> carListsByParentId = new LinkedHashMap<>(); for (HardOrderVo v : list) { List carList = hardCarService.getByParentCarId(v.getParentCarId()); List carVoList = BeanUtils.toBean(carList, HardCarVo.class); + if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList); v.setCarList(carVoList); } + hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId); } }); // 预约订单 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml index 33ffb43bd..d4bb18746 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml @@ -84,6 +84,32 @@ group by a.parent_car_id, a.product_id + + - \ No newline at end of file + 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 7d389be70..8e59846ee 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 @@ -3,7 +3,7 @@ - +