From f7ff1354164ec3e3aada33dd844bd9fa5f0f6f6f Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Fri, 14 Aug 2026 19:49:06 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E9=92=B1=E5=8C=85=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E3=80=81=E8=A2=AB=E7=9B=91=E6=8A=A4=E4=BA=BA=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=B6=88=E6=81=AF=E6=8F=90=E9=86=92=E3=80=81?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B6=88=E6=81=AF=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260814_add_app_message_push.sql | 305 +++++++++++
db/20260814_add_hard_car_product_source.sql | 120 ++++
.../api/constant/HardProductSourceEnum.java | 70 +++
.../hard/api/dto/order/HardCreateCarDto.java | 19 +-
.../hard/api/entity/order/HardCarEntity.java | 10 +-
.../entity/order/HardMessageOutboxEntity.java | 86 +++
.../api/entity/order/HardParentCarEntity.java | 7 +
.../pjcloud/hard/api/vo/order/HardCarVo.java | 27 +-
.../order/HardCanteenApptTotalController.java | 24 +
.../hard/job/HardMessageOutboxJob.java | 49 ++
.../mapper/order/HardMessageOutboxMapper.java | 39 ++
.../service/HardMessageOutboxService.java | 21 +
.../impl/HardMessageOutboxServiceImpl.java | 237 ++++++++
.../HardCanteenApptTotalServiceImpl.java | 10 +
.../impl/order/HardCarServiceImpl.java | 140 ++++-
.../impl/order/HardOrderServiceImpl.java | 78 ++-
.../resources/mapper/order/HardCarMapper.xml | 4 +-
.../mapper/order/HardParentCarMapper.xml | 3 +-
.../feign/RemoterThirdAppMessageService.java | 40 ++
.../appmessage/AppMessageEventEnum.java | 81 +++
.../AppMessageRecipientTypeEnum.java | 24 +
.../dto/appmessage/AppPushPolicyItemDto.java | 31 ++
.../dto/appmessage/AppPushPolicySaveDto.java | 25 +
.../dto/appmessage/AppPushTestDto.java | 17 +
.../AppRelationSubscriptionSaveDto.java | 26 +
.../appmessage/AppUserEventSettingDto.java | 22 +
.../appmessage/AppUserPushSettingSaveDto.java | 24 +
.../dto/appmessage/MealVerifiedEventDto.java | 49 ++
.../dto/appmessage/PushDeviceBindingDto.java | 33 ++
.../appmessage/WalletMessageDetailDto.java | 37 ++
.../dto/appmessage/WalletMessageEventDto.java | 61 +++
.../pjcloud/thirdparty/dto/jg/JgPusDto.java | 12 +
.../appmessage/AppMessageBaseEntity.java | 49 ++
.../appmessage/ThirdAppMessageEntity.java | 92 ++++
.../appmessage/ThirdAppPushPolicyEntity.java | 34 ++
.../ThirdAppPushPolicyRelationEntity.java | 28 +
.../ThirdAppRelationSubscriptionEntity.java | 40 ++
.../ThirdAppUserEventSettingEntity.java | 31 ++
.../ThirdAppUserPushSettingEntity.java | 28 +
.../ThirdPushDeliveryAttemptEntity.java | 51 ++
.../appmessage/ThirdPushDeliveryEntity.java | 60 ++
.../ThirdPushDeviceBindingEntity.java | 51 ++
.../appmessage/AppMessageEventOptionVo.java | 21 +
.../vo/appmessage/AppMessageVo.java | 46 ++
.../appmessage/AppPushDeliveryAttemptVo.java | 37 ++
.../vo/appmessage/AppPushDeliveryVo.java | 46 ++
.../vo/appmessage/AppPushPolicyVo.java | 32 ++
.../appmessage/AppRelationEventSettingVo.java | 24 +
.../appmessage/AppRelationSubscriptionVo.java | 36 ++
.../appmessage/AppRelationTypeOptionVo.java | 18 +
.../vo/appmessage/AppUserEventSettingVo.java | 24 +
.../vo/appmessage/AppUserPushSettingVo.java | 20 +
.../vo/appmessage/PushProviderStatusVo.java | 30 +
.../appmessage/AppMessageConstants.java | 90 +++
.../appmessage/ThirdAppMessageController.java | 214 ++++++++
.../ThirdAppMessagePcController.java | 189 +++++++
.../ThirdPushDeviceBindingController.java | 59 ++
.../jg/ThirdJgDeviceController.java | 28 +-
.../job/ThirdAppMessagePushJob.java | 49 ++
.../appmessage/ThirdAppMessageMapper.java | 28 +
.../appmessage/ThirdAppPushPolicyMapper.java | 12 +
.../ThirdAppPushPolicyRelationMapper.java | 24 +
.../ThirdAppRelationSubscriptionMapper.java | 12 +
.../ThirdAppUserEventSettingMapper.java | 12 +
.../ThirdAppUserPushSettingMapper.java | 12 +
.../ThirdPushDeliveryAttemptMapper.java | 28 +
.../appmessage/ThirdPushDeliveryMapper.java | 29 +
.../ThirdPushDeviceBindingMapper.java | 69 +++
.../provider/PushDispatchAttempt.java | 26 +
.../provider/PushDispatchResult.java | 25 +
.../thirdparty/provider/PushProvider.java | 36 ++
.../provider/PushProviderDispatcher.java | 80 +++
.../provider/PushProviderRequest.java | 27 +
.../provider/PushProviderResult.java | 56 ++
.../provider/PushProviderResultStatus.java | 16 +
.../provider/jpush/JPushProvider.java | 95 ++++
.../appmessage/ThirdAppMessageService.java | 71 +++
.../ThirdAppMessageSettingService.java | 116 ++++
.../appmessage/ThirdPushDeliveryService.java | 73 +++
.../ThirdPushDeviceBindingService.java | 42 ++
.../ThirdAppMessageServiceImpl.java | 479 ++++++++++++++++
.../ThirdAppMessageSettingServiceImpl.java | 516 ++++++++++++++++++
.../ThirdPushDeliveryServiceImpl.java | 454 +++++++++++++++
.../ThirdPushDeviceBindingServiceImpl.java | 173 ++++++
.../impl/jg/ThirdThirdJgPushServiceImpl.java | 16 +-
.../push/ThirdPushExecuteServiceImpl.java | 97 +++-
.../push/ThirdPushExecuteServiceImplTest.java | 63 ++-
.../SysUserRelationRecipientQueryDto.java | 32 ++
.../api/feign/RemoteUserRelationService.java | 24 +
.../vo/user/SysUserRelationRecipientVo.java | 37 ++
.../user/SysUserRelationController.java | 26 +
.../admin/service/SysUserRelationService.java | 18 +
.../impl/user/SysUserRelationServiceImpl.java | 119 ++++
.../api/constant/WalletTransactionTag.java | 1 +
.../api/dto/WalletBalanceTransactionDto.java | 17 +
.../api/entity/WalletMessageOutboxEntity.java | 107 ++++
.../com/bsd/pjcloud/wallet/WalletApp.java | 8 +
.../WalletTransactionController.java | 19 +-
.../wallet/job/WalletMessageOutboxJob.java | 49 ++
.../mapper/WalletMessageOutboxMapper.java | 39 ++
.../service/WalletMessageOutboxService.java | 21 +
.../impl/WalletMessageOutboxServiceImpl.java | 303 ++++++++++
.../impl/WalletTransactionServiceImpl.java | 98 +++-
.../support/WalletMessageContextHolder.java | 59 ++
104 files changed, 6734 insertions(+), 88 deletions(-)
create mode 100644 db/20260814_add_app_message_push.sql
create mode 100644 db/20260814_add_hard_car_product_source.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardProductSourceEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardMessageOutboxEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardMessageOutboxMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardMessageOutboxService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardMessageOutboxServiceImpl.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageEventEnum.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageRecipientTypeEnum.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicyItemDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicySaveDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushTestDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionSaveDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserEventSettingDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserPushSettingSaveDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/MealVerifiedEventDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/PushDeviceBindingDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageEventDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/AppMessageBaseEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppMessageEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyRelationEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppRelationSubscriptionEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserEventSettingEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserPushSettingEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryAttemptEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeviceBindingEntity.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageEventOptionVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryAttemptVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushPolicyVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationEventSettingVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationSubscriptionVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationTypeOptionVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserEventSettingVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserPushSettingVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/PushProviderStatusVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdPushDeviceBindingController.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppMessageMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyRelationMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppRelationSubscriptionMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserEventSettingMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserPushSettingMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryAttemptMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeviceBindingMapper.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchAttempt.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchResult.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProvider.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderDispatcher.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderRequest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResult.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResultStatus.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/jpush/JPushProvider.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeviceBindingService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeviceBindingServiceImpl.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationRecipientQueryDto.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationRecipientVo.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/WalletMessageOutboxJob.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletMessageOutboxMapper.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletMessageOutboxService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletMessageContextHolder.java
diff --git a/db/20260814_add_app_message_push.sql b/db/20260814_add_app_message_push.sql
new file mode 100644
index 00000000..9358869a
--- /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 00000000..edbf73ee
--- /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 00000000..cd480deb
--- /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 b0b15fbf..f3a9d6b1 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 4f263728..13b42c0e 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 00000000..09432580
--- /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 e4512ac2..4b8cda9a 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 7cc03382..d1e462e3 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,42 @@ 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;
- // 统计使用
+ /** 统计结果所属商户 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/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
index 1554bd6b..f956218d 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 00000000..784aa8a2
--- /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/HardMessageOutboxMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardMessageOutboxMapper.java
new file mode 100644
index 00000000..3732eee0
--- /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/service/HardMessageOutboxService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardMessageOutboxService.java
new file mode 100644
index 00000000..29a7cd02
--- /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 00000000..7f4d0138
--- /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/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java
index 3bdaa609..1c471c27 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 b8ab385f..4e2aee2b 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;
@@ -41,7 +42,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 +64,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 {
@@ -88,9 +87,18 @@ 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 +124,41 @@ 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);
+ 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 +173,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 +247,10 @@ public class HardCarServiceImpl extends ServiceImpl saveBatchCarts(HardCarBatchSaveOrUpdateDto dto, boolean useDailyMenuProducts) {
if (dto == null || ObjectUtil.isEmpty(dto.getGroups())) {
@@ -234,10 +284,14 @@ public class HardCarServiceImpl extends ServiceImpl productList) {
List productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList();
@@ -289,6 +347,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 +570,14 @@ public class HardCarServiceImpl extends ServiceImpl productList) {
List productIds = productList.stream().map(HardCreateCarDto::getProductId).toList();
@@ -516,6 +592,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 +614,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 +753,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 2c67b667..994f6550 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
@@ -89,6 +89,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 +99,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;
@@ -226,6 +228,10 @@ 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 +2206,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 +3311,9 @@ public class HardOrderServiceImpl extends ServiceImpl reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild);
if (reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) {
throw new RuntimeException("钱包扣款错误");
@@ -3416,7 +3436,63 @@ 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;
+ }
+
+ /**
+ * 在预约订单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;
+ }
}
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
index ef1abb4b..3a11b21c 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
@@ -6,6 +6,7 @@
+
@@ -24,6 +25,7 @@
select a.id,
a.parent_car_id,
a.product_id,
+ a.source_product_id,
a.product_name,
a.product_pic,
a.product_price,
@@ -612,4 +614,4 @@
order by SUM(a.product_count) desc
limit 10;
-
\ No newline at end of file
+
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardParentCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardParentCarMapper.xml
index c33b3dac..6b3a8f16 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardParentCarMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardParentCarMapper.xml
@@ -6,6 +6,7 @@
+
@@ -14,4 +15,4 @@
-
\ No newline at end of file
+
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java
new file mode 100644
index 00000000..5bc80465
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java
@@ -0,0 +1,40 @@
+package com.bsd.pjcloud.thirdparty.api.feign;
+
+import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.feign.annotation.NoToken;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
+import jakarta.validation.Valid;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+/**
+ * App业务消息中心内部Feign契约。
+ *
+ * 来源服务只能提交已在本地事务中持久化的Outbox事件,返回成功后才允许把Outbox标记为完成。
+ */
+@FeignClient(contextId = "remoterThirdAppMessageService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
+public interface RemoterThirdAppMessageService {
+
+ /**
+ * 幂等接收聚合钱包事件并生成本人及符合条件的关系消息。
+ *
+ * @param dto Wallet Outbox聚合后的事件快照
+ * @return true表示消息中心已幂等接收,来源Outbox可以标记成功
+ */
+ @NoToken
+ @PostMapping("/app-message/inner/wallet-event")
+ R receiveWalletEvent(@Valid @RequestBody WalletMessageEventDto dto);
+
+ /**
+ * 幂等接收用餐核销事件并生成本人及符合条件的关系消息。
+ *
+ * @param dto Hardware Outbox保存的核销事件快照
+ * @return true表示消息中心已幂等接收,来源Outbox可以标记成功
+ */
+ @NoToken
+ @PostMapping("/app-message/inner/meal-verified-event")
+ R receiveMealVerifiedEvent(@Valid @RequestBody MealVerifiedEventDto dto);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageEventEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageEventEnum.java
new file mode 100644
index 00000000..1b3fc722
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageEventEnum.java
@@ -0,0 +1,81 @@
+package com.bsd.pjcloud.thirdparty.constant.appmessage;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * App业务消息事件协议。
+ *
+ * 事件编码会持久化到消息、策略、订阅和Outbox表,并由PC与App共同使用,禁止随意改名或复用。
+ */
+@Getter
+@AllArgsConstructor
+public enum AppMessageEventEnum {
+
+ /** 钱包人工更新余额,对应WalletBizEnum编码1。 */
+ WALLET_UPDATE("WALLET_UPDATE", "余额更新", "1", "wallet"),
+
+ /** 钱包批量更新余额,对应WalletBizEnum编码2。 */
+ WALLET_BATCH("WALLET_BATCH", "批量余额更新", "2", "wallet"),
+
+ /** 钱包充值到账,对应WalletBizEnum编码3。 */
+ WALLET_RECHARGE("WALLET_RECHARGE", "充值到账", "3", "wallet"),
+
+ /** 钱包支付或钱包订单支付,对应WalletBizEnum编码4。 */
+ WALLET_PAYMENT("WALLET_PAYMENT", "消费支付", "4", "wallet"),
+
+ /** 钱包退款到账,对应WalletBizEnum编码5。 */
+ WALLET_REFUND("WALLET_REFUND", "退款到账", "5", "wallet"),
+
+ /** 钱包转账支出,对应WalletBizEnum编码6。 */
+ WALLET_TRANSFER("WALLET_TRANSFER", "钱包转账", "6", "wallet"),
+
+ /** 钱包转账退款,对应WalletBizEnum编码7。 */
+ WALLET_TRANSFER_REFUND("WALLET_TRANSFER_REFUND", "转账退款", "7", "wallet"),
+
+ /** 余额退回各自食堂,对应WalletBizEnum编码8。 */
+ WALLET_RETURN_CANTEEN("WALLET_RETURN_CANTEEN", "退还食堂", "8", "wallet"),
+
+ /** 钱包余额重置,对应WalletBizEnum编码9。 */
+ WALLET_RESET("WALLET_RESET", "余额重置", "9", "wallet"),
+
+ /** 预约用餐核销成功,由Hardware真实核销状态转换产生。 */
+ MEAL_VERIFIED("MEAL_VERIFIED", "用餐核销", null, "meal");
+
+ /** 持久化及跨端传输使用的稳定事件编码。 */
+ private final String code;
+
+ /** PC和App展示的事件名称。 */
+ private final String label;
+
+ /** 对应的钱包业务持久化编码,非钱包事件为空。 */
+ private final String walletBizCode;
+
+ /** App展示分组编码,wallet为钱包类,meal为用餐类。 */
+ private final String category;
+
+ /**
+ * 按钱包业务编码解析消息事件。
+ *
+ * @param walletBizCode WalletBizEnum持久化编码
+ * @return 对应消息事件,不支持的编码返回空
+ */
+ public static AppMessageEventEnum fromWalletBizCode(String walletBizCode) {
+ return Arrays.stream(values())
+ .filter(value -> value.walletBizCode != null && value.walletBizCode.equals(walletBizCode))
+ .findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * 按稳定事件编码解析枚举。
+ *
+ * @param code 数据库或接口中的事件编码
+ * @return 对应事件,不支持的编码返回空
+ */
+ public static AppMessageEventEnum fromCode(String code) {
+ return Arrays.stream(values()).filter(value -> value.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageRecipientTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageRecipientTypeEnum.java
new file mode 100644
index 00000000..243f4856
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageRecipientTypeEnum.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.constant.appmessage;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * App业务消息接收类型协议。
+ */
+@Getter
+@AllArgsConstructor
+public enum AppMessageRecipientTypeEnum {
+
+ /** 事件主体本人,收件箱始终创建。 */
+ OWNER("OWNER", "本人"),
+
+ /** 通过有效用户关系和主动订阅生成的关系接收人。 */
+ RELATION("RELATION", "关系人");
+
+ /** 数据库存储和接口传输使用的稳定编码。 */
+ private final String code;
+
+ /** PC投递记录使用的展示名称。 */
+ private final String label;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicyItemDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicyItemDto.java
new file mode 100644
index 00000000..0d5ed8a6
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicyItemDto.java
@@ -0,0 +1,31 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 商户单个业务事件的App消息策略。
+ */
+@Data
+public class AppPushPolicyItemDto implements Serializable {
+
+ /** 固定业务事件编码。 */
+ @NotBlank(message = "消息事件编码不能为空")
+ private String eventCode;
+
+ /** 本人系统Push是否开启;不影响本人收件箱。 */
+ @NotNull(message = "本人Push开关不能为空")
+ private Boolean ownerPushEnabled;
+
+ /** 关系收件箱及系统Push是否开启。 */
+ @NotNull(message = "关系消息开关不能为空")
+ private Boolean relationMessageEnabled;
+
+ /** 关系消息允许的UPMS关系类型编码;关系消息关闭时应为空。 */
+ private List relationCodes = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicySaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicySaveDto.java
new file mode 100644
index 00000000..21be0cec
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushPolicySaveDto.java
@@ -0,0 +1,25 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * PC批量保存指定商户App业务消息策略的请求。
+ */
+@Data
+public class AppPushPolicySaveDto implements Serializable {
+
+ /** 需要独立配置的商户ID。 */
+ @NotNull(message = "商户ID不能为空")
+ private Long mchId;
+
+ /** 全部支持事件的策略;服务端拒绝重复或未知事件编码。 */
+ @Valid
+ @NotEmpty(message = "商户消息策略不能为空")
+ private List policies;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushTestDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushTestDto.java
new file mode 100644
index 00000000..a96e980f
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppPushTestDto.java
@@ -0,0 +1,17 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * PC向当前管理员发送Push测试的请求。
+ */
+@Data
+public class AppPushTestDto implements Serializable {
+
+ /** 当前正在配置的商户ID,仅用于测试文案和权限上下文,不创建收件箱。 */
+ @NotNull(message = "商户ID不能为空")
+ private Long mchId;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionSaveDto.java
new file mode 100644
index 00000000..ac9ee7fd
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionSaveDto.java
@@ -0,0 +1,26 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * App保存当前用户单个关系事件订阅的请求。
+ */
+@Data
+public class AppRelationSubscriptionSaveDto implements Serializable {
+
+ /** UPMS具体关系记录ID,服务端会复核其当前归属和有效期。 */
+ @NotNull(message = "用户关系ID不能为空")
+ private Long relationId;
+
+ /** 固定业务事件编码。 */
+ @NotBlank(message = "消息事件编码不能为空")
+ private String eventCode;
+
+ /** 订阅开关;关闭后不再生成关系收件箱或系统Push。 */
+ @NotNull(message = "关系订阅开关不能为空")
+ private Boolean enabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserEventSettingDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserEventSettingDto.java
new file mode 100644
index 00000000..60e3c91a
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserEventSettingDto.java
@@ -0,0 +1,22 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 当前用户单个本人事件的系统Push偏好。
+ */
+@Data
+public class AppUserEventSettingDto implements Serializable {
+
+ /** 固定业务事件编码。 */
+ @NotBlank(message = "消息事件编码不能为空")
+ private String eventCode;
+
+ /** 本人事件系统Push开关。 */
+ @NotNull(message = "事件Push开关不能为空")
+ private Boolean pushEnabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserPushSettingSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserPushSettingSaveDto.java
new file mode 100644
index 00000000..9f2db417
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppUserPushSettingSaveDto.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * App保存当前用户业务系统Push偏好的请求。
+ */
+@Data
+public class AppUserPushSettingSaveDto implements Serializable {
+
+ /** 业务系统Push总开关;关闭不影响收件箱生成。 */
+ @NotNull(message = "业务Push总开关不能为空")
+ private Boolean pushEnabled;
+
+ /** 本人各事件的系统Push开关。 */
+ @Valid
+ private List eventSettings = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/MealVerifiedEventDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/MealVerifiedEventDto.java
new file mode 100644
index 00000000..1f83ea80
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/MealVerifiedEventDto.java
@@ -0,0 +1,49 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * Hardware服务提交给消息中心的用餐核销事件。
+ */
+@Data
+public class MealVerifiedEventDto implements Serializable {
+
+ /** 跨服务幂等事件ID,由Hardware Outbox主键稳定生成。 */
+ @NotBlank(message = "消息事件ID不能为空")
+ private String eventId;
+
+ /** 固定事件编码,首期必须为MEAL_VERIFIED。 */
+ @NotBlank(message = "消息事件编码不能为空")
+ private String eventCode;
+
+ /** 事件所属租户ID,内部调用时用于恢复租户上下文。 */
+ @NotNull(message = "租户ID不能为空")
+ private Long tenantId;
+
+ /** 实际用餐用户ID,也是本人消息接收人。 */
+ @NotNull(message = "用餐用户ID不能为空")
+ private Long subjectUserId;
+
+ /** 被核销的硬件预约订单ID。 */
+ @NotNull(message = "核销订单ID不能为空")
+ private Long orderId;
+
+ /** 订单实际所属商户ID。 */
+ @NotNull(message = "核销商户ID不能为空")
+ private Long sourceMchId;
+
+ /** 订单商户名称快照;无法取得时允许为空。 */
+ private String sourceMchName;
+
+ /** 餐段、菜谱或核销内容摘要快照;无法取得时使用通用核销模板。 */
+ private String mealName;
+
+ /** 核销状态成功提交的实际时间。 */
+ @NotNull(message = "核销时间不能为空")
+ private LocalDateTime occurredTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/PushDeviceBindingDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/PushDeviceBindingDto.java
new file mode 100644
index 00000000..e765235d
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/PushDeviceBindingDto.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * App通用推送设备绑定或解绑参数。
+ */
+@Data
+public class PushDeviceBindingDto implements Serializable {
+
+ /** App安装实例的稳定逻辑设备ID,不得使用用户ID替代。 */
+ @NotBlank(message = "逻辑设备ID不能为空")
+ private String clientDeviceId;
+
+ /** 推送供应商编码,首期只接受JPUSH。 */
+ @NotBlank(message = "推送供应商编码不能为空")
+ private String providerCode;
+
+ /** 供应商为当前安装实例分配的设备令牌或Registration ID。 */
+ @NotBlank(message = "推送设备令牌不能为空")
+ private String providerRegistrationId;
+
+ /** 客户端应用编码,用于同供应商多应用隔离。 */
+ @NotBlank(message = "客户端应用编码不能为空")
+ private String appCode;
+
+ /** 客户端平台,ANDROID或IOS;H5不会调用本接口。 */
+ @NotBlank(message = "客户端平台不能为空")
+ private String platform;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
new file mode 100644
index 00000000..bd1a6716
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
@@ -0,0 +1,37 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 钱包业务消息中的单个钱包变动快照。
+ */
+@Data
+public class WalletMessageDetailDto implements Serializable {
+
+ /** 钱包流水ID,用于消息详情追溯但不直接暴露为跨服务写入入口。 */
+ @NotNull(message = "钱包流水ID不能为空")
+ private Long walletTransactionId;
+
+ /** 发生变化的钱包ID。 */
+ @NotNull(message = "钱包ID不能为空")
+ private Long walletId;
+
+ /** 钱包名称或别名快照;无法解析时使用“钱包”。 */
+ private String walletName;
+
+ /** 本次变动金额,单位分,收入为正、支出为负。 */
+ @NotNull(message = "钱包变动金额不能为空")
+ private Integer amount;
+
+ /** 本次变动完成后的余额,单位分。 */
+ @NotNull(message = "钱包结果余额不能为空")
+ private Integer balance;
+
+ /** WalletBizEnum持久化编码,用于服务端再次校验聚合明细一致性。 */
+ @NotBlank(message = "钱包业务类型不能为空")
+ private String walletBizType;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageEventDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageEventDto.java
new file mode 100644
index 00000000..1d3635cb
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageEventDto.java
@@ -0,0 +1,61 @@
+package com.bsd.pjcloud.thirdparty.dto.appmessage;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Wallet服务提交给消息中心的聚合钱包事件。
+ */
+@Data
+public class WalletMessageEventDto implements Serializable {
+
+ /** 跨服务幂等事件ID,来源于本批Outbox最小主键,重试期间保持不变。 */
+ @NotBlank(message = "消息事件ID不能为空")
+ private String eventId;
+
+ /** 固定消息事件编码,必须属于九种钱包事件之一。 */
+ @NotBlank(message = "消息事件编码不能为空")
+ private String eventCode;
+
+ /** 事件所属租户ID,内部调用时用于恢复租户上下文。 */
+ @NotNull(message = "租户ID不能为空")
+ private Long tenantId;
+
+ /** 钱包所有人用户ID,也是本人消息接收人。 */
+ @NotNull(message = "钱包用户ID不能为空")
+ private Long subjectUserId;
+
+ /** 来源商户ID快照;为空时消息中心仅生成本人收件箱并跳过系统Push和关系扩散。 */
+ private Long sourceMchId;
+
+ /** 来源商户名称快照;来源商户为空时同步为空。 */
+ private String sourceMchName;
+
+ /** 来源场景,WALLET表示普通钱包变化,ORDER表示钱包订单支付。 */
+ @NotBlank(message = "钱包消息来源场景不能为空")
+ private String sourceScene;
+
+ /** 钱包业务类型编码快照。 */
+ @NotBlank(message = "钱包业务类型不能为空")
+ private String sourceBizType;
+
+ /** 钱包业务ID快照,同一用户和业务ID的多钱包明细会合并。 */
+ @NotBlank(message = "钱包业务ID不能为空")
+ private String sourceBizId;
+
+ /** 业务事件实际发生时间。 */
+ @NotNull(message = "钱包事件时间不能为空")
+ private LocalDateTime occurredTime;
+
+ /** 同一业务操作内的全部钱包变动快照。 */
+ @Valid
+ @NotEmpty(message = "钱包变动明细不能为空")
+ private List details;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgPusDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgPusDto.java
index 7442c8a9..7a823a86 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgPusDto.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgPusDto.java
@@ -2,17 +2,29 @@ package com.bsd.pjcloud.thirdparty.dto.jg;
import lombok.Data;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+/**
+ * 极光通知栏推送参数,兼容既有定时广播并支持业务消息内部路由。
+ */
@Data
public class JgPusDto {
+ /** 通知栏标题。 */
private String title;
+ /** 通知栏副标题或正文。 */
private String subtitle;
+ /** 目标用户ID,服务端按既有约定转换为极光alias。 */
private List userIds;
+ /** 极光标签集合,定时广播可继续按标签发送。 */
private List tagList;
+ /** 通知附加字段,只允许非敏感的消息ID和App内部路由。 */
+ private Map extras = new HashMap<>();
+
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/AppMessageBaseEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/AppMessageBaseEntity.java
new file mode 100644
index 00000000..cbde12fb
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/AppMessageBaseEntity.java
@@ -0,0 +1,49 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * App消息模块租户表通用审计字段。
+ *
+ * 只收敛所有新表都具备的审计语义,不包含任何业务字段或跨表行为。
+ */
+@Data
+public abstract class AppMessageBaseEntity implements Serializable {
+
+ /** 记录创建人;系统异步任务统一写system。 */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /** 记录创建时间。 */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /** 最近修改人。 */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /** 最近修改时间。 */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /** 逻辑删除标记,0有效,1删除。 */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "逻辑删除标记")
+ private String delFlag;
+
+ /** 记录所属租户ID,由租户拦截器负责隔离。 */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppMessageEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppMessageEntity.java
new file mode 100644
index 00000000..3747c937
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppMessageEntity.java
@@ -0,0 +1,92 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * App业务消息收件箱持久化对象,每个接收人保存一条不可变业务快照。
+ */
+@Data
+@TenantTable
+@TableName("third_app_message")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "App业务消息")
+public class ThirdAppMessageEntity extends AppMessageBaseEntity {
+
+ /** 消息主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 跨服务业务事件幂等ID。 */
+ private String eventId;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 收件箱所属用户ID。 */
+ private Long recipientUserId;
+
+ /** 事件主体用户ID。 */
+ private Long subjectUserId;
+
+ /** 接收类型,OWNER本人或RELATION关系人。 */
+ private String recipientType;
+
+ /** 命中的具体关系记录ID,本人消息为空。 */
+ private Long relationId;
+
+ /** 关系类型编码快照,本人消息为空。 */
+ private String relationCode;
+
+ /** 关系类型名称快照,本人消息为空。 */
+ private String relationName;
+
+ /** 事件主体姓名快照。 */
+ private String subjectUserName;
+
+ /** 来源服务编码,首期为WALLET或HARDWARE。 */
+ private String sourceService;
+
+ /** 来源业务类型快照。 */
+ private String sourceBizType;
+
+ /** 来源业务ID快照。 */
+ private String sourceBizId;
+
+ /** 来源商户ID快照;为空表示无法可靠归属商户。 */
+ private Long sourceMchId;
+
+ /** 来源商户名称快照。 */
+ private String sourceMchName;
+
+ /** 服务端固定模板渲染后的标题。 */
+ private String title;
+
+ /** 服务端固定模板渲染后的正文。 */
+ private String content;
+
+ /** 消息详情展示快照JSON,不包含凭证或设备令牌。 */
+ private String payloadJson;
+
+ /** App内部深链路径。 */
+ private String deepLinkPath;
+
+ /** App内部深链参数JSON。 */
+ private String deepLinkParamsJson;
+
+ /** 已读状态,0未读,1已读。 */
+ private String readStatus;
+
+ /** 用户首次标记已读时间,未读时为空。 */
+ private LocalDateTime readTime;
+
+ /** 业务事件实际发生时间。 */
+ private LocalDateTime occurredTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyEntity.java
new file mode 100644
index 00000000..48779ac2
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyEntity.java
@@ -0,0 +1,34 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 商户按业务事件配置的App消息策略。
+ */
+@Data
+@TenantTable
+@TableName("third_app_push_policy")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAppPushPolicyEntity extends AppMessageBaseEntity {
+
+ /** 策略主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 策略所属商户ID,父店和子店独立。 */
+ private Long mchId;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 本人系统Push开关,0关闭,1开启。 */
+ private String ownerPushEnabled;
+
+ /** 关系消息开关,0关闭,1开启。 */
+ private String relationMessageEnabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyRelationEntity.java
new file mode 100644
index 00000000..1cdbcde8
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppPushPolicyRelationEntity.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 商户事件策略允许的UPMS关系类型。
+ */
+@Data
+@TenantTable
+@TableName("third_app_push_policy_relation")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAppPushPolicyRelationEntity extends AppMessageBaseEntity {
+
+ /** 策略关系主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 所属商户事件策略ID。 */
+ private Long policyId;
+
+ /** UPMS关系类型稳定编码。 */
+ private String relationCode;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppRelationSubscriptionEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppRelationSubscriptionEntity.java
new file mode 100644
index 00000000..7e25c0f0
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppRelationSubscriptionEntity.java
@@ -0,0 +1,40 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * App用户按具体关系记录订阅业务事件的持久化对象。
+ */
+@Data
+@TenantTable
+@TableName("third_app_relation_subscription")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAppRelationSubscriptionEntity extends AppMessageBaseEntity {
+
+ /** 订阅主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** UPMS具体关系记录ID,新关系不会继承旧订阅。 */
+ private Long relationId;
+
+ /** 主动订阅用户ID,通常为监护人。 */
+ private Long subscriberUserId;
+
+ /** 被订阅事件主体用户ID,通常为被监护人。 */
+ private Long subjectUserId;
+
+ /** 订阅时的关系类型编码快照。 */
+ private String relationCode;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 订阅开关,0关闭,1开启。 */
+ private String enabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserEventSettingEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserEventSettingEntity.java
new file mode 100644
index 00000000..93462446
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserEventSettingEntity.java
@@ -0,0 +1,31 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * App用户本人事件系统Push设置。
+ */
+@Data
+@TenantTable
+@TableName("third_app_user_event_setting")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAppUserEventSettingEntity extends AppMessageBaseEntity {
+
+ /** 事件设置主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 设置所属用户ID。 */
+ private Long userId;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 本人事件系统Push开关,0关闭,1开启。 */
+ private String pushEnabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserPushSettingEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserPushSettingEntity.java
new file mode 100644
index 00000000..baff97a4
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdAppUserPushSettingEntity.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * App用户业务系统Push总设置。
+ */
+@Data
+@TenantTable
+@TableName("third_app_user_push_setting")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAppUserPushSettingEntity extends AppMessageBaseEntity {
+
+ /** 设置主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 设置所属用户ID。 */
+ private Long userId;
+
+ /** 业务系统Push总开关,0关闭,1开启。 */
+ private String pushEnabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryAttemptEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryAttemptEntity.java
new file mode 100644
index 00000000..90a9fcbf
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryAttemptEntity.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * Push供应商单次调用尝试日志。
+ */
+@Data
+@TenantTable
+@TableName("third_push_delivery_attempt")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdPushDeliveryAttemptEntity extends AppMessageBaseEntity {
+
+ /** 尝试日志主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 所属Push投递任务ID。 */
+ private Long deliveryId;
+
+ /** 投递任务内递增的供应商调用序号。 */
+ private Integer attemptNo;
+
+ /** 本次调用的推送供应商编码。 */
+ private String providerCode;
+
+ /** 调用结果,ACCEPTED、RETRYABLE_FAILED或PERMANENT_FAILED。 */
+ private String resultStatus;
+
+ /** 供应商返回的消息ID。 */
+ private String providerMessageId;
+
+ /** 供应商错误码或系统错误分类。 */
+ private String errorCode;
+
+ /** 脱敏后的失败诊断信息。 */
+ private String errorMessage;
+
+ /** 本次供应商调用开始时间。 */
+ private LocalDateTime startedTime;
+
+ /** 本次供应商调用完成时间。 */
+ private LocalDateTime finishedTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryEntity.java
new file mode 100644
index 00000000..6cc2d31c
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeliveryEntity.java
@@ -0,0 +1,60 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * App业务消息系统Push投递任务。
+ */
+@Data
+@TenantTable
+@TableName("third_push_delivery")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdPushDeliveryEntity extends AppMessageBaseEntity {
+
+ /** 投递任务主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 对应App业务消息ID,一条消息最多一个投递任务。 */
+ private Long messageId;
+
+ /** 系统Push目标用户ID。 */
+ private Long recipientUserId;
+
+ /** 来源商户ID快照。 */
+ private Long sourceMchId;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 投递状态,PENDING、PROCESSING、RETRY、SUCCESS、SKIPPED或FAILED。 */
+ private String status;
+
+ /** 被策略跳过的稳定原因编码,未跳过时为空。 */
+ private String skipReason;
+
+ /** 已执行供应商调用次数,最多8次。 */
+ private Integer attemptCount;
+
+ /** 下次允许重试时间,非RETRY状态为空。 */
+ private LocalDateTime nextRetryTime;
+
+ /** 最终受理消息的供应商编码。 */
+ private String finalProviderCode;
+
+ /** 供应商返回的消息ID。 */
+ private String providerMessageId;
+
+ /** 供应商明确受理时间。 */
+ private LocalDateTime successTime;
+
+ /** 最近一次脱敏后的失败诊断信息。 */
+ private String lastError;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeviceBindingEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeviceBindingEntity.java
new file mode 100644
index 00000000..2a0d48d2
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/appmessage/ThirdPushDeviceBindingEntity.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.thirdparty.entity.appmessage;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 供应商无关的App推送设备绑定。
+ */
+@Data
+@TenantTable
+@TableName("third_push_device_binding")
+@EqualsAndHashCode(callSuper = true)
+public class ThirdPushDeviceBindingEntity extends AppMessageBaseEntity {
+
+ /** 设备绑定主键,由MyBatis-Plus雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 当前绑定用户ID。 */
+ private Long userId;
+
+ /** App安装实例的稳定逻辑设备ID。 */
+ private String clientDeviceId;
+
+ /** 推送供应商编码,首期为JPUSH。 */
+ private String providerCode;
+
+ /** 供应商设备令牌或Registration ID。 */
+ private String providerRegistrationId;
+
+ /** 客户端应用编码。 */
+ private String appCode;
+
+ /** 客户端平台,ANDROID或IOS。 */
+ private String platform;
+
+ /** 绑定有效状态,0停用,1有效。 */
+ private String enabled;
+
+ /** App最近成功上报绑定或活跃时间。 */
+ private LocalDateTime lastActiveTime;
+
+ /** 退出登录、账号切换或令牌失效后的解绑时间。 */
+ private LocalDateTime unbindTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageEventOptionVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageEventOptionVo.java
new file mode 100644
index 00000000..71a22659
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageEventOptionVo.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * PC和App共用的固定消息事件选项。
+ */
+@Data
+public class AppMessageEventOptionVo implements Serializable {
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 事件展示名称。 */
+ private String label;
+
+ /** 事件展示分组,wallet或meal。 */
+ private String category;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java
new file mode 100644
index 00000000..f3e83278
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * App消息列表和详情的安全展示模型。
+ */
+@Data
+public class AppMessageVo implements Serializable {
+
+ /** 消息ID,雪花长整型在前端按字符串处理。 */
+ private Long id;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 事件展示分组,wallet或meal。 */
+ private String category;
+
+ /** 接收类型,OWNER本人或RELATION关系人。 */
+ private String recipientType;
+
+ /** 服务端固定模板渲染后的标题。 */
+ private String title;
+
+ /** 服务端固定模板渲染后的正文。 */
+ private String content;
+
+ /** 事件来源商户名称快照。 */
+ private String sourceMchName;
+
+ /** 事件主体姓名快照,关系消息用于展示被监护人。 */
+ private String subjectUserName;
+
+ /** 消息详情快照JSON。 */
+ private String payloadJson;
+
+ /** 是否已读。 */
+ private Boolean read;
+
+ /** 业务事件实际发生时间。 */
+ private LocalDateTime occurredTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryAttemptVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryAttemptVo.java
new file mode 100644
index 00000000..617c217a
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryAttemptVo.java
@@ -0,0 +1,37 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * PC查看的单次供应商调用诊断详情。
+ */
+@Data
+public class AppPushDeliveryAttemptVo implements Serializable {
+
+ /** 尝试日志ID。 */
+ private Long id;
+
+ /** 投递任务内递增序号。 */
+ private Integer attemptNo;
+
+ /** 本次调用的供应商编码。 */
+ private String providerCode;
+
+ /** 标准化调用结果。 */
+ private String resultStatus;
+
+ /** 供应商错误码或系统错误分类。 */
+ private String errorCode;
+
+ /** 脱敏后的失败摘要。 */
+ private String errorMessage;
+
+ /** 调用开始时间。 */
+ private LocalDateTime startedTime;
+
+ /** 调用完成时间。 */
+ private LocalDateTime finishedTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
new file mode 100644
index 00000000..f8c34ca5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * PC业务消息投递记录展示模型,不包含设备令牌和供应商密钥。
+ */
+@Data
+public class AppPushDeliveryVo implements Serializable {
+
+ /** 投递任务ID,前端按字符串处理。 */
+ private Long id;
+
+ /** 对应收件箱消息ID。 */
+ private Long messageId;
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 目标用户ID。 */
+ private Long recipientUserId;
+
+ /** 投递状态。 */
+ private String status;
+
+ /** 跳过原因编码,未跳过时为空。 */
+ private String skipReason;
+
+ /** 已执行的供应商调用次数。 */
+ private Integer attemptCount;
+
+ /** 最终受理供应商编码。 */
+ private String finalProviderCode;
+
+ /** 最近一次脱敏失败摘要。 */
+ private String lastError;
+
+ /** 投递任务创建时间。 */
+ private LocalDateTime createTime;
+
+ /** 供应商明确受理时间。 */
+ private LocalDateTime successTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushPolicyVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushPolicyVo.java
new file mode 100644
index 00000000..a6b7aac7
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushPolicyVo.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * PC展示的商户单个业务事件策略。
+ */
+@Data
+public class AppPushPolicyVo implements Serializable {
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 事件展示名称。 */
+ private String eventLabel;
+
+ /** 事件展示分组,wallet或meal。 */
+ private String category;
+
+ /** 本人系统Push是否开启。 */
+ private Boolean ownerPushEnabled;
+
+ /** 关系收件箱及系统Push是否开启。 */
+ private Boolean relationMessageEnabled;
+
+ /** 允许接收的关系类型编码。 */
+ private List relationCodes = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationEventSettingVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationEventSettingVo.java
new file mode 100644
index 00000000..4e173d4a
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationEventSettingVo.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * App单个关系事件的订阅状态。
+ */
+@Data
+public class AppRelationEventSettingVo implements Serializable {
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 事件展示名称。 */
+ private String eventLabel;
+
+ /** 事件展示分组,wallet或meal。 */
+ private String category;
+
+ /** 当前关系是否主动订阅该事件,缺少持久化记录时为false。 */
+ private Boolean enabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationSubscriptionVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationSubscriptionVo.java
new file mode 100644
index 00000000..e72b906c
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationSubscriptionVo.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * App按具体有效关系展示的全部事件订阅状态。
+ */
+@Data
+public class AppRelationSubscriptionVo implements Serializable {
+
+ /** UPMS具体关系记录ID。 */
+ private Long relationId;
+
+ /** 被订阅事件主体用户ID。 */
+ private Long subjectUserId;
+
+ /** 被订阅事件主体姓名。 */
+ private String subjectUserName;
+
+ /** 关系类型稳定编码。 */
+ private String relationCode;
+
+ /** 关系类型展示名称。 */
+ private String relationName;
+
+ /** 关系失效时间;为空表示未设置失效时间。 */
+ private LocalDateTime expireTime;
+
+ /** 全部支持事件的订阅状态,默认均为关闭。 */
+ private List eventSettings = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationTypeOptionVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationTypeOptionVo.java
new file mode 100644
index 00000000..5004fc1b
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppRelationTypeOptionVo.java
@@ -0,0 +1,18 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * PC商户消息策略可选择的UPMS稳定关系类型。
+ */
+@Data
+public class AppRelationTypeOptionVo implements Serializable {
+
+ /** UPMS关系稳定编码。 */
+ private String relationCode;
+
+ /** 关系类型展示名称。 */
+ private String relationName;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserEventSettingVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserEventSettingVo.java
new file mode 100644
index 00000000..25617165
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserEventSettingVo.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * App当前用户单个本人事件的系统Push偏好。
+ */
+@Data
+public class AppUserEventSettingVo implements Serializable {
+
+ /** 固定业务事件编码。 */
+ private String eventCode;
+
+ /** 事件展示名称。 */
+ private String eventLabel;
+
+ /** 事件展示分组,wallet或meal。 */
+ private String category;
+
+ /** 本人事件系统Push是否开启,缺少持久化记录时为true。 */
+ private Boolean pushEnabled;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserPushSettingVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserPushSettingVo.java
new file mode 100644
index 00000000..d9cfb91b
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppUserPushSettingVo.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * App当前用户的业务系统Push偏好。
+ */
+@Data
+public class AppUserPushSettingVo implements Serializable {
+
+ /** 业务系统Push总开关,缺少持久化记录时返回true。 */
+ private Boolean pushEnabled;
+
+ /** 全部支持事件及当前用户本人Push开关。 */
+ private List eventSettings = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/PushProviderStatusVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/PushProviderStatusVo.java
new file mode 100644
index 00000000..94b76ccf
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/PushProviderStatusVo.java
@@ -0,0 +1,30 @@
+package com.bsd.pjcloud.thirdparty.vo.appmessage;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * PC只读展示的推送供应商运行状态,不包含任何密钥或设备令牌。
+ */
+@Data
+public class PushProviderStatusVo implements Serializable {
+
+ /** 推送供应商稳定编码。 */
+ private String providerCode;
+
+ /** 供应商展示名称。 */
+ private String providerName;
+
+ /** 部署配置是否启用该供应商。 */
+ private Boolean enabled;
+
+ /** 当前配置是否完整且可用于投递。 */
+ private Boolean available;
+
+ /** 部署级故障切换优先级,数值越小越优先。 */
+ private Integer priority;
+
+ /** 不含敏感信息的状态说明。 */
+ private String statusMessage;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
new file mode 100644
index 00000000..bd2e04e6
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
@@ -0,0 +1,90 @@
+package com.bsd.pjcloud.thirdparty.constant.appmessage;
+
+import lombok.experimental.UtilityClass;
+
+import java.util.List;
+
+/**
+ * App业务消息模块内部稳定状态和路由协议。
+ */
+@UtilityClass
+public class AppMessageConstants {
+
+ /** 数据库存储的开启值。 */
+ public static final String ENABLED = "1";
+
+ /** 数据库存储的关闭值。 */
+ public static final String DISABLED = "0";
+
+ /** 未读消息状态。 */
+ public static final String UNREAD = "0";
+
+ /** 已读消息状态。 */
+ public static final String READ = "1";
+
+ /** 本人消息接收类型。 */
+ public static final String RECIPIENT_OWNER = "OWNER";
+
+ /** 关系消息接收类型。 */
+ public static final String RECIPIENT_RELATION = "RELATION";
+
+ /** 钱包事件来源服务。 */
+ public static final String SOURCE_WALLET = "WALLET";
+
+ /** 用餐核销事件来源服务。 */
+ public static final String SOURCE_HARDWARE = "HARDWARE";
+
+ /** 新建后等待供应商投递的状态。 */
+ public static final String DELIVERY_PENDING = "PENDING";
+
+ /** 正在由任务领取处理的状态。 */
+ public static final String DELIVERY_PROCESSING = "PROCESSING";
+
+ /** 等待下一次重试的状态。 */
+ public static final String DELIVERY_RETRY = "RETRY";
+
+ /** 供应商已经明确受理的终态。 */
+ public static final String DELIVERY_SUCCESS = "SUCCESS";
+
+ /** 因策略或设备状态跳过的终态。 */
+ public static final String DELIVERY_SKIPPED = "SKIPPED";
+
+ /** 超过最大尝试次数的失败终态。 */
+ public static final String DELIVERY_FAILED = "FAILED";
+
+ /** 来源商户无法可靠解析时的跳过原因。 */
+ public static final String SKIP_MERCHANT_UNRESOLVED = "MERCHANT_UNRESOLVED";
+
+ /** 商户未开启本人Push时的跳过原因。 */
+ public static final String SKIP_MERCHANT_DISABLED = "MERCHANT_DISABLED";
+
+ /** 用户关闭业务Push总开关时的跳过原因。 */
+ public static final String SKIP_USER_DISABLED = "USER_DISABLED";
+
+ /** 用户关闭指定本人事件Push时的跳过原因。 */
+ public static final String SKIP_EVENT_DISABLED = "EVENT_DISABLED";
+
+ /** 用户没有有效设备绑定时的跳过原因。 */
+ public static final String SKIP_NO_DEVICE = "NO_DEVICE";
+
+ /** App消息详情内部路由,Push只携带该路径和消息ID。 */
+ public static final String MESSAGE_DETAIL_PATH = "/pages/message/detail";
+
+ /** 首期极光供应商稳定编码。 */
+ public static final String PROVIDER_JPUSH = "JPUSH";
+
+ /** ZAX App在通用设备绑定中的稳定应用编码,旧极光兼容入口也必须使用该值以避免重复绑定。 */
+ public static final String APP_CODE_ZAX = "BIZ_ZAX_APP_CLOUD";
+
+ /** 单个投递任务允许的最大供应商调用次数。 */
+ public static final int MAX_DELIVERY_ATTEMPTS = 8;
+
+ /** 处理中超过该分钟数视为任务失联并允许恢复。 */
+ public static final int PROCESSING_TIMEOUT_MINUTES = 10;
+
+ /** 一次投递扫描领取的最大任务数,控制数据库和供应商压力。 */
+ public static final int DELIVERY_SCAN_LIMIT = 100;
+
+ /** 第1至第8次失败后使用的重试间隔,单位分钟;最后一次达到上限后直接进入失败终态。 */
+ public static final List RETRY_INTERVAL_MINUTES = List.of(1L, 5L, 15L, 60L, 180L, 360L, 720L, 720L);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java
new file mode 100644
index 00000000..dde237a5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java
@@ -0,0 +1,214 @@
+package com.bsd.pjcloud.thirdparty.controller.appmessage;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.common.security.annotation.Inner;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationSubscriptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppUserPushSettingVo;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * App业务消息内部接入和当前用户收件箱接口。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/app-message")
+@Tag(description = "app-message", name = "App业务消息中心")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class ThirdAppMessageController {
+
+ /** 消息事件接入和收件箱服务。 */
+ private final ThirdAppMessageService messageService;
+
+ /** 用户Push偏好和关系订阅服务。 */
+ private final ThirdAppMessageSettingService settingService;
+
+ /**
+ * 内部幂等接收Wallet Outbox聚合事件。
+ *
+ * @param dto Wallet Outbox事件快照
+ * @return true表示消息中心已完整接收
+ */
+ @Inner
+ @PostMapping("/inner/wallet-event")
+ public R receiveWalletEvent(@Valid @RequestBody WalletMessageEventDto dto) {
+ return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> messageService.receiveWalletEvent(dto)));
+ }
+
+ /**
+ * 内部幂等接收Hardware用餐核销Outbox事件。
+ *
+ * @param dto 用餐核销事件快照
+ * @return true表示消息中心已完整接收
+ */
+ @Inner
+ @PostMapping("/inner/meal-verified-event")
+ public R receiveMealVerifiedEvent(@Valid @RequestBody MealVerifiedEventDto dto) {
+ return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> messageService.receiveMealVerifiedEvent(dto)));
+ }
+
+ /**
+ * 分页查询当前用户全部或未读消息。
+ *
+ * @param page 分页参数
+ * @param unreadOnly true时仅查询未读消息
+ * @return 当前用户消息分页
+ */
+ @Operation(summary = "分页查询当前用户业务消息")
+ @ApiCryptoAes
+ @GetMapping("/app/page")
+ public R> page(@ParameterObject Page> page,
+ @RequestParam(defaultValue = "false") boolean unreadOnly) {
+ return R.ok(messageService.page(page, currentUserId(), unreadOnly));
+ }
+
+ /**
+ * 查询当前用户一条消息详情。
+ *
+ * @param messageId 消息ID
+ * @return 当前用户消息详情
+ */
+ @Operation(summary = "查询业务消息详情")
+ @ApiCryptoAes
+ @GetMapping("/app/{messageId}")
+ public R detail(@PathVariable Long messageId) {
+ return R.ok(messageService.detail(messageId, currentUserId()));
+ }
+
+ /**
+ * 查询当前用户未读消息数量。
+ *
+ * @return 未读数量
+ */
+ @Operation(summary = "查询业务消息未读数量")
+ @ApiCryptoAes
+ @GetMapping("/app/unread-count")
+ public R unreadCount() {
+ return R.ok(messageService.unreadCount(currentUserId()));
+ }
+
+ /**
+ * 幂等标记当前用户一条消息为已读。
+ *
+ * @param messageId 消息ID
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "标记一条业务消息已读")
+ @ApiCryptoAes
+ @PostMapping("/app/{messageId}/read")
+ public R markRead(@PathVariable Long messageId) {
+ messageService.markRead(messageId, currentUserId());
+ return R.ok();
+ }
+
+ /**
+ * 标记当前用户全部业务消息为已读。
+ *
+ * @return 实际更新数量
+ */
+ @Operation(summary = "全部业务消息标记已读")
+ @ApiCryptoAes
+ @PostMapping("/app/read-all")
+ public R markAllRead() {
+ return R.ok(messageService.markAllRead(currentUserId()));
+ }
+
+ /**
+ * 查询当前用户业务Push总开关和本人事件开关。
+ *
+ * @return 全事件Push偏好
+ */
+ @Operation(summary = "查询当前用户业务Push偏好")
+ @ApiCryptoAes
+ @GetMapping("/app/settings")
+ public R getSettings() {
+ return R.ok(settingService.getUserSetting(currentUserId()));
+ }
+
+ /**
+ * 保存当前用户业务Push总开关和本人事件开关。
+ *
+ * @param dto 完整Push偏好
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "保存当前用户业务Push偏好")
+ @ApiCryptoAes
+ @PostMapping("/app/settings")
+ public R saveSettings(@Valid @RequestBody AppUserPushSettingSaveDto dto) {
+ settingService.saveUserSetting(currentUserId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 查询当前用户全部有效关系的全事件订阅矩阵。
+ *
+ * @return 关系订阅矩阵
+ */
+ @Operation(summary = "查询当前用户关系消息订阅")
+ @ApiCryptoAes
+ @GetMapping("/app/relation-subscriptions")
+ public R> listRelationSubscriptions() {
+ return R.ok(settingService.listRelationSubscriptions(currentUserId()));
+ }
+
+ /**
+ * 保存当前用户一个具体关系事件订阅。
+ *
+ * @param dto 具体关系事件订阅
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "保存当前用户关系消息订阅")
+ @ApiCryptoAes
+ @PostMapping("/app/relation-subscription")
+ public R saveRelationSubscription(@Valid @RequestBody AppRelationSubscriptionSaveDto dto) {
+ settingService.saveRelationSubscription(currentUserId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 返回App展示用固定事件选项。
+ *
+ * @return 固定事件选项
+ */
+ @Operation(summary = "查询业务消息事件选项")
+ @ApiCryptoAes
+ @GetMapping("/app/event-options")
+ public R> listEventOptions() {
+ return R.ok(settingService.listEventOptions());
+ }
+
+ /**
+ * 获取当前认证用户ID,所有App消息接口均以该值限制数据范围。
+ *
+ * @return 当前登录用户ID
+ */
+ private Long currentUserId() {
+ return SecurityUtils.getUser().getId();
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
new file mode 100644
index 00000000..dd24ba9f
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
@@ -0,0 +1,189 @@
+package com.bsd.pjcloud.thirdparty.controller.appmessage;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
+import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.log.annotation.SysLog;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushTestDto;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryAttemptVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushPolicyVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationTypeOptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.PushProviderStatusVo;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * PC商户App业务消息策略和Push投递诊断接口。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/app-message/pc")
+@Tag(description = "app-message-pc", name = "商户App业务消息配置")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class ThirdAppMessagePcController {
+
+ /** 商户策略服务。 */
+ private final ThirdAppMessageSettingService settingService;
+
+ /** Push投递和供应商状态服务。 */
+ private final ThirdPushDeliveryService deliveryService;
+
+ /** Wallet商户内部服务,用于复核当前管理员可管理的商户数据范围。 */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 查询指定商户全事件策略矩阵。
+ *
+ * @param mchId 商户ID
+ * @return 全事件策略矩阵
+ */
+ @Operation(summary = "查询商户App业务消息策略")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/policies")
+ public R> listPolicies(@RequestParam Long mchId) {
+ requireMerchantAccess(mchId);
+ return R.ok(settingService.listMerchantPolicies(mchId));
+ }
+
+ /**
+ * 批量保存指定商户全事件策略矩阵。
+ *
+ * @param dto 商户全事件策略
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "保存商户App业务消息策略")
+ @SysLog("保存商户App业务消息策略")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @PostMapping("/policies")
+ public R savePolicies(@Valid @RequestBody AppPushPolicySaveDto dto) {
+ requireMerchantAccess(dto.getMchId());
+ settingService.saveMerchantPolicies(dto);
+ return R.ok();
+ }
+
+ /**
+ * 返回PC策略矩阵使用的固定事件选项。
+ *
+ * @return 固定事件选项
+ */
+ @Operation(summary = "查询业务消息事件选项")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/event-options")
+ public R> listEventOptions() {
+ return R.ok(settingService.listEventOptions());
+ }
+
+ /**
+ * 返回商户策略可选择的UPMS稳定关系类型。
+ *
+ * @return 关系类型选项
+ */
+ @Operation(summary = "查询消息策略关系类型选项")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/relation-types")
+ public R> listRelationTypes() {
+ return R.ok(Arrays.stream(UserRelationTypeEnum.values()).map(type -> {
+ AppRelationTypeOptionVo vo = new AppRelationTypeOptionVo();
+ vo.setRelationCode(type.getRelationCode());
+ vo.setRelationName(type.getRelationName());
+ return vo;
+ }).toList());
+ }
+
+ /**
+ * 分页查询指定商户业务Push投递记录。
+ *
+ * @param page 分页参数
+ * @param mchId 商户ID
+ * @param status 可选投递状态
+ * @param eventCode 可选事件编码
+ * @return 商户投递记录分页
+ */
+ @Operation(summary = "分页查询商户业务Push投递记录")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/deliveries")
+ public R> pageDeliveries(@ParameterObject Page> page, @RequestParam Long mchId,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String eventCode) {
+ requireMerchantAccess(mchId);
+ return R.ok(deliveryService.page(page, mchId, status, eventCode));
+ }
+
+ /**
+ * 查询指定商户投递任务的供应商调用详情。
+ *
+ * @param deliveryId 投递任务ID
+ * @param mchId 商户ID
+ * @return 供应商调用详情
+ */
+ @Operation(summary = "查询Push供应商尝试详情")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/deliveries/{deliveryId}/attempts")
+ public R> listAttempts(@PathVariable Long deliveryId, @RequestParam Long mchId) {
+ requireMerchantAccess(mchId);
+ return R.ok(deliveryService.listAttempts(deliveryId, mchId));
+ }
+
+ /**
+ * 查询当前部署Push供应商只读状态。
+ *
+ * @return 供应商状态
+ */
+ @Operation(summary = "查询Push供应商状态")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @GetMapping("/providers")
+ public R> listProviders() {
+ return R.ok(deliveryService.listProviderStatuses());
+ }
+
+ /**
+ * 向当前管理员发送通知栏测试,不写入收件箱。
+ *
+ * @param dto 当前配置商户
+ * @return 是否有供应商明确受理
+ */
+ @Operation(summary = "发送App Push测试")
+ @SysLog("发送App Push测试")
+ @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
+ @PostMapping("/test")
+ public R sendTest(@Valid @RequestBody AppPushTestDto dto) {
+ requireMerchantAccess(dto.getMchId());
+ return R.ok(deliveryService.sendTest(SecurityUtils.getUser().getId(), dto.getMchId()));
+ }
+
+ /**
+ * 通过Wallet现有商户归属规则复核管理员数据范围,阻止仅凭按钮权限访问任意商户ID。
+ *
+ * @param mchId 请求操作的商户ID
+ */
+ private void requireMerchantAccess(Long mchId) {
+ R> result = remoteMchService.getSelfMchIdList(SecurityUtils.getUser().getId(), MerchantTypeEnum.all());
+ if (result == null || !result.isOk() || result.getData() == null || !result.getData().contains(mchId)) {
+ throw new IllegalArgumentException("当前用户无权管理该商户App推送配置");
+ }
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdPushDeviceBindingController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdPushDeviceBindingController.java
new file mode 100644
index 00000000..9fd8c523
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdPushDeviceBindingController.java
@@ -0,0 +1,59 @@
+package com.bsd.pjcloud.thirdparty.controller.appmessage;
+
+import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.PushDeviceBindingDto;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeviceBindingService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * App供应商无关的原生Push设备绑定接口,H5不调用本接口。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/push-device")
+@Tag(description = "push-device", name = "App通用Push设备管理")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class ThirdPushDeviceBindingController {
+
+ /** 通用设备绑定服务。 */
+ private final ThirdPushDeviceBindingService bindingService;
+
+ /**
+ * 将当前App安装实例绑定到当前登录用户。
+ *
+ * @param dto 通用设备绑定参数
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "绑定当前Push设备")
+ @ApiCryptoAes
+ @PostMapping("/bind")
+ public R bind(@Valid @RequestBody PushDeviceBindingDto dto) {
+ bindingService.bind(SecurityUtils.getUser().getId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 精确解绑当前App安装实例与当前登录用户。
+ *
+ * @param dto 通用设备绑定参数
+ * @return 无业务数据成功响应
+ */
+ @Operation(summary = "解绑当前Push设备")
+ @ApiCryptoAes
+ @PostMapping("/unbind")
+ public R unbind(@Valid @RequestBody PushDeviceBindingDto dto) {
+ bindingService.unbind(SecurityUtils.getUser().getId(), dto);
+ return R.ok();
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java
index 866ccd94..203ca90f 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java
@@ -3,8 +3,10 @@ package com.bsd.pjcloud.thirdparty.controller.jg;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.PushDeviceBindingDto;
import com.bsd.pjcloud.thirdparty.dto.jg.JgDeviceRegistrationDto;
-import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeviceBindingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -27,9 +29,9 @@ import org.springframework.web.bind.annotation.RestController;
public class ThirdJgDeviceController {
/**
- * 极光用户设备管理服务。
+ * 通用设备绑定服务;内部继续调用既有极光alias管理,保持旧接口行为兼容。
*/
- private final ThirdJgDeviceService thirdJgDeviceService;
+ private final ThirdPushDeviceBindingService bindingService;
/**
* 将当前 App 安装实例绑定到当前登录用户,并在绑定前清理旧设备。
@@ -41,7 +43,7 @@ public class ThirdJgDeviceController {
@ApiCryptoAes
@PostMapping("/bind")
public R bind(@Valid @RequestBody JgDeviceRegistrationDto dto) {
- thirdJgDeviceService.bind(SecurityUtils.getUser().getId(), dto.getRegistrationId());
+ bindingService.bind(SecurityUtils.getUser().getId(), compatibilityDto(dto.getRegistrationId()));
return R.ok();
}
@@ -55,8 +57,24 @@ public class ThirdJgDeviceController {
@ApiCryptoAes
@PostMapping("/unbind")
public R unbind(@Valid @RequestBody JgDeviceRegistrationDto dto) {
- thirdJgDeviceService.unbind(SecurityUtils.getUser().getId(), dto.getRegistrationId());
+ bindingService.unbind(SecurityUtils.getUser().getId(), compatibilityDto(dto.getRegistrationId()));
return R.ok();
}
+ /**
+ * 将旧极光请求补全为通用设备参数;旧客户端没有逻辑设备ID和平台字段,使用registrationId稳定兼容。
+ *
+ * @param registrationId 极光设备标识
+ * @return 通用设备绑定参数
+ */
+ private PushDeviceBindingDto compatibilityDto(String registrationId) {
+ PushDeviceBindingDto dto = new PushDeviceBindingDto();
+ dto.setClientDeviceId(registrationId);
+ dto.setProviderCode(AppMessageConstants.PROVIDER_JPUSH);
+ dto.setProviderRegistrationId(registrationId);
+ dto.setAppCode(AppMessageConstants.APP_CODE_ZAX);
+ dto.setPlatform("APP");
+ return dto;
+ }
+
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java
new file mode 100644
index 00000000..91cebf3f
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java
@@ -0,0 +1,49 @@
+package com.bsd.pjcloud.thirdparty.job;
+
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * App业务消息Push投递扫描任务,服务多实例时由条件领取避免同一任务并发执行。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ThirdAppMessagePushJob {
+
+ /** 投递、重试和超时恢复服务。 */
+ private final ThirdPushDeliveryService deliveryService;
+
+ /**
+ * 每十秒扫描一次到期任务;每轮限制数量,避免阻塞既有定时广播任务。
+ */
+ @Scheduled(fixedDelay = 10000L, initialDelay = 15000L)
+ public void processDueDeliveries() {
+ try {
+ int processed = deliveryService.processDueDeliveries();
+ if (processed > 0) {
+ log.info("App业务消息Push投递扫描完成,processed:{}", processed);
+ }
+ } catch (Exception exception) {
+ log.error("App业务消息Push投递扫描异常", exception);
+ }
+ }
+
+ /**
+ * 每日凌晨分批清理超过保留期的消息、终态投递和供应商尝试日志。
+ */
+ @Scheduled(cron = "0 10 2 * * ?")
+ public void cleanupExpiredRecords() {
+ try {
+ int deleted = deliveryService.cleanupExpiredRecords();
+ if (deleted > 0) {
+ log.info("App业务消息到期记录清理完成,deleted:{}", deleted);
+ }
+ } catch (Exception exception) {
+ log.error("App业务消息到期记录清理异常", exception);
+ }
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppMessageMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppMessageMapper.java
new file mode 100644
index 00000000..504c2980
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppMessageMapper.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+/**
+ * App业务消息收件箱数据访问入口,租户和逻辑删除条件由公共Mapper拦截器统一处理。
+ */
+@Mapper
+public interface ThirdAppMessageMapper extends PjcloudBaseMapper {
+
+ /**
+ * 跨租户物理删除超过一年的收件箱消息,每次限制数量以控制锁范围。
+ *
+ * @param before 业务发生时间截止值
+ * @param limit 单次最大删除数量
+ * @return 实际删除数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Delete("DELETE FROM third_app_message WHERE occurred_time < #{before} LIMIT #{limit}")
+ int physicalDeleteBefore(@Param("before") LocalDateTime before, @Param("limit") int limit);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyMapper.java
new file mode 100644
index 00000000..3b98880b
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 商户业务消息策略数据访问入口。
+ */
+@Mapper
+public interface ThirdAppPushPolicyMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyRelationMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyRelationMapper.java
new file mode 100644
index 00000000..6a2a47d1
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppPushPolicyRelationMapper.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyRelationEntity;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 商户消息策略允许关系类型的数据访问入口。
+ */
+@Mapper
+public interface ThirdAppPushPolicyRelationMapper extends PjcloudBaseMapper {
+
+ /**
+ * 物理删除当前租户指定策略的关系范围,避免逻辑删除记录与唯一键冲突导致关系类型无法重新启用。
+ *
+ * @param tenantId 当前租户ID
+ * @param policyId 商户事件策略ID
+ * @return 删除行数
+ */
+ @Delete("DELETE FROM third_app_push_policy_relation WHERE tenant_id = #{tenantId} AND policy_id = #{policyId}")
+ int physicalDeleteByPolicyId(@Param("tenantId") Long tenantId, @Param("policyId") Long policyId);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppRelationSubscriptionMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppRelationSubscriptionMapper.java
new file mode 100644
index 00000000..34e0a2c5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppRelationSubscriptionMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppRelationSubscriptionEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * App用户具体关系事件订阅数据访问入口。
+ */
+@Mapper
+public interface ThirdAppRelationSubscriptionMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserEventSettingMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserEventSettingMapper.java
new file mode 100644
index 00000000..c5cae9a7
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserEventSettingMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppUserEventSettingEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * App用户本人事件Push开关数据访问入口。
+ */
+@Mapper
+public interface ThirdAppUserEventSettingMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserPushSettingMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserPushSettingMapper.java
new file mode 100644
index 00000000..a1321811
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdAppUserPushSettingMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppUserPushSettingEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * App用户业务Push总开关数据访问入口。
+ */
+@Mapper
+public interface ThirdAppUserPushSettingMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryAttemptMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryAttemptMapper.java
new file mode 100644
index 00000000..4e739bda
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryAttemptMapper.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeliveryAttemptEntity;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+/**
+ * Push供应商单次调用尝试日志数据访问入口。
+ */
+@Mapper
+public interface ThirdPushDeliveryAttemptMapper extends PjcloudBaseMapper {
+
+ /**
+ * 跨租户物理删除超过90天的供应商尝试日志,每次限制数量以控制锁范围。
+ *
+ * @param before 创建时间截止值
+ * @param limit 单次最大删除数量
+ * @return 实际删除数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Delete("DELETE FROM third_push_delivery_attempt WHERE create_time < #{before} LIMIT #{limit}")
+ int physicalDeleteBefore(@Param("before") LocalDateTime before, @Param("limit") int limit);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryMapper.java
new file mode 100644
index 00000000..2877336d
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeliveryMapper.java
@@ -0,0 +1,29 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeliveryEntity;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+/**
+ * App业务消息Push投递任务数据访问入口。
+ */
+@Mapper
+public interface ThirdPushDeliveryMapper extends PjcloudBaseMapper {
+
+ /**
+ * 跨租户物理删除超过一年的终态投递任务,保留与收件箱一致的排查窗口。
+ *
+ * @param before 创建时间截止值
+ * @param limit 单次最大删除数量
+ * @return 实际删除数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Delete("DELETE FROM third_push_delivery WHERE status IN ('SUCCESS','SKIPPED','FAILED') "
+ + "AND create_time < #{before} LIMIT #{limit}")
+ int physicalDeleteTerminalBefore(@Param("before") LocalDateTime before, @Param("limit") int limit);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeviceBindingMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeviceBindingMapper.java
new file mode 100644
index 00000000..1f03fbc7
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/appmessage/ThirdPushDeviceBindingMapper.java
@@ -0,0 +1,69 @@
+package com.bsd.pjcloud.thirdparty.mapper.appmessage;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeviceBindingEntity;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.time.LocalDateTime;
+
+/**
+ * 供应商无关的App设备绑定数据访问入口。
+ */
+@Mapper
+public interface ThirdPushDeviceBindingMapper extends PjcloudBaseMapper {
+
+ /**
+ * 跨租户按供应商令牌查询唯一绑定,用于同一App安装切换租户或账号时迁移归属。
+ *
+ * @param providerCode 供应商编码
+ * @param appCode 应用编码
+ * @param registrationId 供应商设备令牌
+ * @return 全局唯一令牌绑定,不存在时为空
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("SELECT * FROM third_push_device_binding WHERE provider_code = #{providerCode} AND app_code = #{appCode} "
+ + "AND provider_registration_id = #{registrationId} AND del_flag = '0' LIMIT 1")
+ ThirdPushDeviceBindingEntity selectByProviderToken(@Param("providerCode") String providerCode,
+ @Param("appCode") String appCode,
+ @Param("registrationId") String registrationId);
+
+ /**
+ * 跨租户更新令牌绑定归属和活跃快照,保证账号或租户切换后仍只有一条令牌记录。
+ *
+ * @param id 绑定主键
+ * @param tenantId 新租户ID
+ * @param userId 新用户ID
+ * @param clientDeviceId 新逻辑设备ID
+ * @param providerCode 供应商编码
+ * @param registrationId 供应商设备令牌
+ * @param appCode 应用编码
+ * @param platform 客户端平台
+ * @param activeTime 最近活跃时间
+ * @return 实际更新数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Update("UPDATE third_push_device_binding SET tenant_id = #{tenantId}, user_id = #{userId}, "
+ + "client_device_id = #{clientDeviceId}, provider_code = #{providerCode}, "
+ + "provider_registration_id = #{registrationId}, app_code = #{appCode}, platform = #{platform}, "
+ + "enabled = '1', last_active_time = #{activeTime}, unbind_time = NULL, update_time = #{activeTime} "
+ + "WHERE id = #{id} AND del_flag = '0'")
+ int migrateBinding(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("userId") Long userId,
+ @Param("clientDeviceId") String clientDeviceId, @Param("providerCode") String providerCode,
+ @Param("registrationId") String registrationId, @Param("appCode") String appCode,
+ @Param("platform") String platform, @Param("activeTime") LocalDateTime activeTime);
+
+ /**
+ * 跨租户物理删除被另一条逻辑设备记录吸收的重复令牌绑定。
+ *
+ * @param id 重复绑定主键
+ * @return 实际删除数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Delete("DELETE FROM third_push_device_binding WHERE id = #{id}")
+ int physicalDeleteById(@Param("id") Long id);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchAttempt.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchAttempt.java
new file mode 100644
index 00000000..af76bc1a
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchAttempt.java
@@ -0,0 +1,26 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 调度器对单个供应商的一次调用快照,供投递服务持久化诊断记录。
+ */
+@Data
+@AllArgsConstructor
+public class PushDispatchAttempt {
+
+ /** 被调用的供应商编码。 */
+ private String providerCode;
+
+ /** 标准化调用结果。 */
+ private PushProviderResult result;
+
+ /** 供应商调用开始时间。 */
+ private LocalDateTime startedTime;
+
+ /** 供应商调用完成时间。 */
+ private LocalDateTime finishedTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchResult.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchResult.java
new file mode 100644
index 00000000..948fe529
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushDispatchResult.java
@@ -0,0 +1,25 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 一轮多供应商调度结果。
+ */
+@Data
+public class PushDispatchResult {
+
+ /** 是否已有供应商明确受理。 */
+ private boolean accepted;
+
+ /** 最终受理供应商编码,未成功时为空。 */
+ private String providerCode;
+
+ /** 最终供应商消息ID,供应商未返回时为空。 */
+ private String providerMessageId;
+
+ /** 本轮按优先级产生的供应商调用快照。 */
+ private List attempts = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProvider.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProvider.java
new file mode 100644
index 00000000..5997e593
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProvider.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+/**
+ * 通知栏Push供应商扩展点,新增供应商只需增加适配器并配置部署级优先级。
+ */
+public interface PushProvider {
+
+ /**
+ * 返回跨部署稳定的供应商编码。
+ *
+ * @return 供应商编码
+ */
+ String code();
+
+ /**
+ * 返回部署级调度优先级,数值越小越先尝试。
+ *
+ * @return 调度优先级
+ */
+ int priority();
+
+ /**
+ * 判断当前部署配置是否足以调用供应商。
+ *
+ * @return true表示可以尝试调用
+ */
+ boolean available();
+
+ /**
+ * 调用供应商发送通知栏消息,必须把异常归类为标准结果。
+ *
+ * @param request 供应商无关请求
+ * @return 标准化发送结果
+ */
+ PushProviderResult send(PushProviderRequest request);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderDispatcher.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderDispatcher.java
new file mode 100644
index 00000000..846dc4ff
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderDispatcher.java
@@ -0,0 +1,80 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 按部署级优先级执行Push供应商故障切换的调度器。
+ */
+@Service
+@RequiredArgsConstructor
+public class PushProviderDispatcher {
+
+ /** Spring自动收集的供应商适配器集合。 */
+ private final List providers;
+
+ /**
+ * 按优先级依次尝试可用供应商,任一供应商明确受理后立即停止。
+ *
+ * @param request 供应商无关Push请求
+ * @return 本轮调度结果和全部实际调用快照
+ */
+ public PushDispatchResult dispatch(PushProviderRequest request) {
+ return dispatch(request, Integer.MAX_VALUE);
+ }
+
+ /**
+ * 在给定剩余调用预算内按优先级执行供应商故障切换。
+ *
+ * @param request 供应商无关Push请求
+ * @param maxAttempts 本轮最多允许调用的供应商数量,用于保证跨轮累计调用不超过业务上限
+ * @return 本轮调度结果和全部实际调用快照
+ */
+ public PushDispatchResult dispatch(PushProviderRequest request, int maxAttempts) {
+ PushDispatchResult dispatchResult = new PushDispatchResult();
+ List orderedProviders = providers.stream()
+ .filter(PushProvider::available)
+ .sorted(Comparator.comparingInt(PushProvider::priority))
+ .toList();
+ int remainingAttempts = Math.max(0, maxAttempts);
+ for (PushProvider provider : orderedProviders) {
+ if (remainingAttempts-- <= 0) {
+ break;
+ }
+ LocalDateTime startedTime = LocalDateTime.now();
+ PushProviderResult providerResult;
+ try {
+ providerResult = provider.send(request);
+ if (providerResult == null) {
+ providerResult = PushProviderResult.retryable("PROVIDER_EMPTY_RESULT", "供应商未返回标准化结果");
+ }
+ } catch (Exception exception) {
+ // SPI要求适配器自行归类异常;这里仍做最后兜底,避免单个新适配器阻断后续供应商降级。
+ providerResult = PushProviderResult.retryable("PROVIDER_EXCEPTION",
+ exception.getClass().getSimpleName() + ": " + String.valueOf(exception.getMessage()));
+ }
+ LocalDateTime finishedTime = LocalDateTime.now();
+ dispatchResult.getAttempts().add(new PushDispatchAttempt(provider.code(), providerResult, startedTime, finishedTime));
+ if (providerResult.getStatus() == PushProviderResultStatus.ACCEPTED) {
+ dispatchResult.setAccepted(true);
+ dispatchResult.setProviderCode(provider.code());
+ dispatchResult.setProviderMessageId(providerResult.getProviderMessageId());
+ break;
+ }
+ }
+ return dispatchResult;
+ }
+
+ /**
+ * 返回全部供应商的只读部署状态,保持与实际调度优先级一致。
+ *
+ * @return 按优先级排序的供应商列表
+ */
+ public List orderedProviders() {
+ return providers.stream().sorted(Comparator.comparingInt(PushProvider::priority)).toList();
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderRequest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderRequest.java
new file mode 100644
index 00000000..04633a90
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderRequest.java
@@ -0,0 +1,27 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 供应商无关的通知栏Push请求。
+ */
+@Data
+public class PushProviderRequest {
+
+ /** 通知栏标题,由业务模板或定时广播配置提供。 */
+ private String title;
+
+ /** 通知栏正文,不包含敏感钱包或设备凭证。 */
+ private String content;
+
+ /** 目标用户ID列表,JPush适配器将其解析为既有用户alias。 */
+ private List userIds = new ArrayList<>();
+
+ /** 通知附加字段,只允许消息ID和App内部路由等非敏感字符串。 */
+ private Map extras = new HashMap<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResult.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResult.java
new file mode 100644
index 00000000..21f1ddd0
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResult.java
@@ -0,0 +1,56 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+/**
+ * 单个Push供应商的标准化调用结果。
+ */
+@Data
+@AllArgsConstructor
+public class PushProviderResult {
+
+ /** 标准化结果分类。 */
+ private PushProviderResultStatus status;
+
+ /** 供应商受理后返回的消息ID,未提供时为空。 */
+ private String providerMessageId;
+
+ /** 脱敏后的供应商错误码或系统分类。 */
+ private String errorCode;
+
+ /** 脱敏后的错误摘要,不得保存密钥、令牌和完整请求。 */
+ private String errorMessage;
+
+ /**
+ * 创建供应商已受理结果。
+ *
+ * @param providerMessageId 供应商消息ID,允许为空
+ * @return 已受理标准结果
+ */
+ public static PushProviderResult accepted(String providerMessageId) {
+ return new PushProviderResult(PushProviderResultStatus.ACCEPTED, providerMessageId, null, null);
+ }
+
+ /**
+ * 创建可重试失败结果。
+ *
+ * @param errorCode 错误分类
+ * @param errorMessage 脱敏错误摘要
+ * @return 可重试标准结果
+ */
+ public static PushProviderResult retryable(String errorCode, String errorMessage) {
+ return new PushProviderResult(PushProviderResultStatus.RETRYABLE_FAILED, null, errorCode, errorMessage);
+ }
+
+ /**
+ * 创建永久失败结果。
+ *
+ * @param errorCode 错误分类
+ * @param errorMessage 脱敏错误摘要
+ * @return 永久失败标准结果
+ */
+ public static PushProviderResult permanent(String errorCode, String errorMessage) {
+ return new PushProviderResult(PushProviderResultStatus.PERMANENT_FAILED, null, errorCode, errorMessage);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResultStatus.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResultStatus.java
new file mode 100644
index 00000000..05fe4509
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/PushProviderResultStatus.java
@@ -0,0 +1,16 @@
+package com.bsd.pjcloud.thirdparty.provider;
+
+/**
+ * 单个Push供应商调用结果分类,编码会写入供应商尝试日志。
+ */
+public enum PushProviderResultStatus {
+
+ /** 供应商已明确受理,调度器必须停止后续降级。 */
+ ACCEPTED,
+
+ /** 限流、超时或临时不可用,当前供应商失败后可尝试下一供应商并安排重试。 */
+ RETRYABLE_FAILED,
+
+ /** 参数或目标被供应商明确拒绝,可继续尝试下一供应商但本结果本身不可重试。 */
+ PERMANENT_FAILED
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/jpush/JPushProvider.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/jpush/JPushProvider.java
new file mode 100644
index 00000000..ea0ead9b
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/provider/jpush/JPushProvider.java
@@ -0,0 +1,95 @@
+package com.bsd.pjcloud.thirdparty.provider.jpush;
+
+import cn.hutool.core.util.StrUtil;
+import com.bsd.pjcloud.thirdparty.config.JgConfig;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.dto.jg.JgPusDto;
+import com.bsd.pjcloud.thirdparty.provider.PushProvider;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderRequest;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderResult;
+import com.bsd.pjcloud.thirdparty.service.ThirdJgPushService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 极光Push供应商适配器,沿用现有用户ID alias协议和服务端密钥配置。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JPushProvider implements PushProvider {
+
+ /** 当前极光发送实现。 */
+ private final ThirdJgPushService thirdJgPushService;
+
+ /** Nacos或环境变量注入的极光部署配置。 */
+ private final JgConfig jgConfig;
+
+ /**
+ * 返回极光稳定供应商编码。
+ *
+ * @return JPUSH
+ */
+ @Override
+ public String code() {
+ return AppMessageConstants.PROVIDER_JPUSH;
+ }
+
+ /**
+ * 极光作为首期主供应商使用默认最高优先级。
+ *
+ * @return 优先级100
+ */
+ @Override
+ public int priority() {
+ return 100;
+ }
+
+ /**
+ * 仅在AppKey与MasterSecret均已由部署环境提供时标记可用。
+ *
+ * @return 极光配置是否完整
+ */
+ @Override
+ public boolean available() {
+ JgConfig.JgPush config = jgConfig.getJgPush();
+ return config != null && StrUtil.isNotBlank(config.getAppKey()) && StrUtil.isNotBlank(config.getMasterSecret());
+ }
+
+ /**
+ * 将通用请求转换为现有极光alias请求;SDK未抛异常即视为已受理。
+ *
+ * @param request 供应商无关Push请求
+ * @return 标准化极光调用结果
+ */
+ @Override
+ public PushProviderResult send(PushProviderRequest request) {
+ try {
+ JgPusDto dto = new JgPusDto();
+ dto.setTitle(request.getTitle());
+ dto.setSubtitle(request.getContent());
+ dto.setUserIds(request.getUserIds());
+ dto.setExtras(request.getExtras());
+ thirdJgPushService.push(dto);
+ return PushProviderResult.accepted(null);
+ } catch (IllegalArgumentException exception) {
+ log.warn("极光Push请求被拒绝,errorType:{}", exception.getClass().getSimpleName());
+ return PushProviderResult.permanent("INVALID_REQUEST", safeMessage(exception));
+ } catch (Exception exception) {
+ log.warn("极光Push调用失败,errorType:{}", exception.getClass().getSimpleName());
+ return PushProviderResult.retryable("PROVIDER_ERROR", safeMessage(exception));
+ }
+ }
+
+ /**
+ * 截断异常摘要,防止第三方响应无限写入诊断表。
+ *
+ * @param exception 供应商调用异常
+ * @return 最多500字符的非空错误摘要
+ */
+ private String safeMessage(Exception exception) {
+ String message = StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName());
+ return StrUtil.sub(message, 0, 500);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageService.java
new file mode 100644
index 00000000..87df1832
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageService.java
@@ -0,0 +1,71 @@
+package com.bsd.pjcloud.thirdparty.service.appmessage;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageVo;
+
+/**
+ * App业务消息事件接入和当前用户收件箱服务。
+ */
+public interface ThirdAppMessageService {
+
+ /**
+ * 幂等接收聚合钱包事件并在事务中生成消息与Push决策。
+ *
+ * @param dto Wallet Outbox事件快照
+ * @return true表示事件已完整接收或此前已幂等完成
+ */
+ boolean receiveWalletEvent(WalletMessageEventDto dto);
+
+ /**
+ * 幂等接收用餐核销事件并在事务中生成消息与Push决策。
+ *
+ * @param dto Hardware Outbox事件快照
+ * @return true表示事件已完整接收或此前已幂等完成
+ */
+ boolean receiveMealVerifiedEvent(MealVerifiedEventDto dto);
+
+ /**
+ * 分页查询当前用户消息,结果按业务发生时间倒序。
+ *
+ * @param page 分页参数
+ * @param userId 当前登录用户ID
+ * @param unreadOnly true时仅返回未读消息
+ * @return 当前用户安全消息分页
+ */
+ Page page(Page> page, Long userId, boolean unreadOnly);
+
+ /**
+ * 查询当前用户消息详情,禁止越权读取其他用户消息。
+ *
+ * @param messageId 消息ID
+ * @param userId 当前登录用户ID
+ * @return 消息详情,不存在或不属于当前用户时抛出业务异常
+ */
+ AppMessageVo detail(Long messageId, Long userId);
+
+ /**
+ * 查询当前用户未读消息数量。
+ *
+ * @param userId 当前登录用户ID
+ * @return 未读数量
+ */
+ long unreadCount(Long userId);
+
+ /**
+ * 将当前用户一条消息标记为已读,重复调用保持幂等。
+ *
+ * @param messageId 消息ID
+ * @param userId 当前登录用户ID
+ */
+ void markRead(Long messageId, Long userId);
+
+ /**
+ * 将当前用户全部未读消息批量标记为已读。
+ *
+ * @param userId 当前登录用户ID
+ * @return 实际更新数量
+ */
+ int markAllRead(Long userId);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java
new file mode 100644
index 00000000..8b1559b8
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java
@@ -0,0 +1,116 @@
+package com.bsd.pjcloud.thirdparty.service.appmessage;
+
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyEntity;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushPolicyVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationSubscriptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppUserPushSettingVo;
+
+import java.util.List;
+
+/**
+ * 商户策略、用户Push偏好和具体关系订阅服务。
+ */
+public interface ThirdAppMessageSettingService {
+
+ /**
+ * 返回指定商户全部支持事件的策略矩阵,缺少配置按关闭展示。
+ *
+ * @param mchId 商户ID
+ * @return 全事件策略矩阵
+ */
+ List listMerchantPolicies(Long mchId);
+
+ /**
+ * 批量保存指定商户策略,父子商户互不继承。
+ *
+ * @param dto 全事件策略请求
+ */
+ void saveMerchantPolicies(AppPushPolicySaveDto dto);
+
+ /**
+ * 查询消息处理时使用的单个商户事件策略。
+ *
+ * @param mchId 来源商户ID
+ * @param eventCode 固定事件编码
+ * @return 已持久化策略,不存在时返回空并按全部关闭处理
+ */
+ ThirdAppPushPolicyEntity findPolicy(Long mchId, String eventCode);
+
+ /**
+ * 查询指定策略允许的关系类型编码。
+ *
+ * @param policyId 商户事件策略ID
+ * @return 去重后的稳定关系编码
+ */
+ List listPolicyRelationCodes(Long policyId);
+
+ /**
+ * 查询当前用户业务Push总开关和本人事件开关,缺少记录按开启。
+ *
+ * @param userId 当前登录用户ID
+ * @return 全事件用户偏好
+ */
+ AppUserPushSettingVo getUserSetting(Long userId);
+
+ /**
+ * 保存当前用户业务Push总开关和本人事件开关。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 完整用户偏好
+ */
+ void saveUserSetting(Long userId, AppUserPushSettingSaveDto dto);
+
+ /**
+ * 判断指定用户业务Push总开关是否开启,缺少记录按开启。
+ *
+ * @param userId 用户ID
+ * @return 是否允许系统Push
+ */
+ boolean isUserPushEnabled(Long userId);
+
+ /**
+ * 判断指定用户本人事件Push开关是否开启,缺少记录按开启。
+ *
+ * @param userId 用户ID
+ * @param eventCode 固定事件编码
+ * @return 是否允许该本人事件系统Push
+ */
+ boolean isOwnerEventPushEnabled(Long userId, String eventCode);
+
+ /**
+ * 查询当前用户全部有效关系的全事件订阅矩阵。
+ *
+ * @param userId 当前登录用户ID
+ * @return 具体关系订阅矩阵
+ */
+ List listRelationSubscriptions(Long userId);
+
+ /**
+ * 保存当前用户一个具体关系事件订阅,并复核关系当前归属和有效期。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 具体关系事件订阅
+ */
+ void saveRelationSubscription(Long userId, AppRelationSubscriptionSaveDto dto);
+
+ /**
+ * 判断具体关系和事件是否已由接收用户主动订阅。
+ *
+ * @param relationId UPMS具体关系ID
+ * @param recipientUserId 关系消息接收用户ID
+ * @param eventCode 固定事件编码
+ * @return 是否开启订阅
+ */
+ boolean isRelationSubscribed(Long relationId, Long recipientUserId, String eventCode);
+
+ /**
+ * 返回三端共同使用的固定事件选项。
+ *
+ * @return 按协议声明顺序排列的事件选项
+ */
+ List listEventOptions();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
new file mode 100644
index 00000000..0bc601a0
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
@@ -0,0 +1,73 @@
+package com.bsd.pjcloud.thirdparty.service.appmessage;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryAttemptVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.PushProviderStatusVo;
+
+import java.util.List;
+
+/**
+ * App业务消息Push决策、投递、重试和PC诊断服务。
+ */
+public interface ThirdPushDeliveryService {
+
+ /**
+ * 为一条收件箱消息创建唯一Push决策;跳过原因为空时进入待投递状态。
+ *
+ * @param message 已持久化的收件箱消息
+ * @param skipReason 稳定跳过原因,允许为空
+ */
+ void createDelivery(ThirdAppMessageEntity message, String skipReason);
+
+ /**
+ * 扫描并处理所有租户到期的投递任务,供轻量定时任务调用。
+ *
+ * @return 本轮成功领取的任务数量
+ */
+ int processDueDeliveries();
+
+ /**
+ * 分页查询指定商户的业务消息投递记录。
+ *
+ * @param page 分页参数
+ * @param mchId 商户ID
+ * @param status 可选投递状态
+ * @param eventCode 可选事件编码
+ * @return 商户投递记录分页
+ */
+ Page page(Page> page, Long mchId, String status, String eventCode);
+
+ /**
+ * 查询指定商户投递任务的供应商调用详情。
+ *
+ * @param deliveryId 投递任务ID
+ * @param mchId 当前商户ID
+ * @return 按尝试序号排列的诊断记录
+ */
+ List listAttempts(Long deliveryId, Long mchId);
+
+ /**
+ * 返回供应商只读健康状态,不包含部署密钥。
+ *
+ * @return 按调度优先级排列的供应商状态
+ */
+ List listProviderStatuses();
+
+ /**
+ * 向当前管理员发送测试通知,不创建收件箱消息。
+ *
+ * @param userId 当前管理员用户ID
+ * @param mchId 当前配置商户ID
+ * @return 是否有供应商明确受理
+ */
+ boolean sendTest(Long userId, Long mchId);
+
+ /**
+ * 分批物理清理超过保留期的收件箱、终态投递和供应商尝试日志。
+ *
+ * @return 本轮实际清理记录数量
+ */
+ int cleanupExpiredRecords();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeviceBindingService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeviceBindingService.java
new file mode 100644
index 00000000..8d84ff72
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeviceBindingService.java
@@ -0,0 +1,42 @@
+package com.bsd.pjcloud.thirdparty.service.appmessage;
+
+import com.bsd.pjcloud.thirdparty.dto.appmessage.PushDeviceBindingDto;
+
+/**
+ * 供应商无关的App设备绑定服务,并负责兼容既有极光alias入口。
+ */
+public interface ThirdPushDeviceBindingService {
+
+ /**
+ * 绑定当前用户的App安装实例和供应商令牌。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 通用设备绑定参数
+ */
+ void bind(Long userId, PushDeviceBindingDto dto);
+
+ /**
+ * 解绑当前用户的指定App安装实例和供应商令牌。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 通用设备绑定参数
+ */
+ void unbind(Long userId, PushDeviceBindingDto dto);
+
+ /**
+ * 判断用户是否至少存在一个有效App设备绑定。
+ *
+ * @param userId 用户ID
+ * @return 是否存在有效绑定
+ */
+ boolean hasActiveBinding(Long userId);
+
+ /**
+ * 供应商明确设备令牌失效时停用用户在该供应商下的绑定。
+ *
+ * @param userId 用户ID
+ * @param providerCode 供应商编码
+ * @return 停用数量
+ */
+ int disableInvalidBindings(Long userId, String providerCode);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
new file mode 100644
index 00000000..a20fa27e
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
@@ -0,0 +1,479 @@
+package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationRecipientQueryDto;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageDetailDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyEntity;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageVo;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.BeanUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * App业务消息事件接入和收件箱实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
+
+ /** App业务消息Mapper。 */
+ private final ThirdAppMessageMapper messageMapper;
+
+ /** 商户策略、用户偏好和关系订阅服务。 */
+ private final ThirdAppMessageSettingService settingService;
+
+ /** Push决策和投递任务服务。 */
+ private final ThirdPushDeliveryService deliveryService;
+
+ /** UPMS双时间有效关系内部查询客户端。 */
+ private final RemoteUserRelationService remoteUserRelationService;
+
+ /** 项目统一Jackson序列化器。 */
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 幂等接收聚合钱包事件并生成本人及符合授权条件的关系消息。
+ *
+ * @param dto Wallet Outbox事件快照
+ * @return true表示消息中心已经完整接收
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean receiveWalletEvent(WalletMessageEventDto dto) {
+ AppMessageEventEnum event = requireWalletEvent(dto);
+ if (ownerMessageExists(dto.getEventId(), dto.getSubjectUserId())) {
+ return true;
+ }
+ String ownerTitle = ownerTitle(event);
+ String ownerContent = walletContent(event, dto.getDetails());
+ String payloadJson = serializeWalletPayload(dto);
+ ThirdAppMessageEntity ownerMessage = buildBaseMessage(
+ dto.getTenantId(), dto.getEventId(), event.getCode(), dto.getSubjectUserId(), dto.getSubjectUserId(),
+ AppMessageConstants.RECIPIENT_OWNER, AppMessageConstants.SOURCE_WALLET, dto.getSourceBizType(),
+ dto.getSourceBizId(), dto.getSourceMchId(), dto.getSourceMchName(), ownerTitle, ownerContent,
+ payloadJson, dto.getOccurredTime());
+ messageMapper.insert(ownerMessage);
+ ThirdAppPushPolicyEntity policy = settingService.findPolicy(dto.getSourceMchId(), event.getCode());
+ deliveryService.createDelivery(ownerMessage, ownerSkipReason(dto.getSourceMchId(), policy, dto.getSubjectUserId(), event.getCode()));
+ createRelationMessages(ownerMessage, policy, payloadJson, dto.getOccurredTime());
+ return true;
+ }
+
+ /**
+ * 幂等接收用餐核销事件并生成本人及符合授权条件的关系消息。
+ *
+ * @param dto Hardware Outbox事件快照
+ * @return true表示消息中心已经完整接收
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean receiveMealVerifiedEvent(MealVerifiedEventDto dto) {
+ AppMessageEventEnum event = requireMealEvent(dto);
+ if (ownerMessageExists(dto.getEventId(), dto.getSubjectUserId())) {
+ return true;
+ }
+ String mealName = StrUtil.blankToDefault(dto.getMealName(), "本次预约用餐");
+ String payloadJson = serialize(Map.of(
+ "orderId", dto.getOrderId(),
+ "mealName", mealName));
+ ThirdAppMessageEntity ownerMessage = buildBaseMessage(
+ dto.getTenantId(), dto.getEventId(), event.getCode(), dto.getSubjectUserId(), dto.getSubjectUserId(),
+ AppMessageConstants.RECIPIENT_OWNER, AppMessageConstants.SOURCE_HARDWARE, event.getCode(),
+ String.valueOf(dto.getOrderId()), dto.getSourceMchId(), dto.getSourceMchName(), "用餐核销成功",
+ mealName + "已核销,祝您用餐愉快。", payloadJson, dto.getOccurredTime());
+ messageMapper.insert(ownerMessage);
+ ThirdAppPushPolicyEntity policy = settingService.findPolicy(dto.getSourceMchId(), event.getCode());
+ deliveryService.createDelivery(ownerMessage, ownerSkipReason(dto.getSourceMchId(), policy, dto.getSubjectUserId(), event.getCode()));
+ createRelationMessages(ownerMessage, policy, payloadJson, dto.getOccurredTime());
+ return true;
+ }
+
+ /**
+ * 分页查询当前用户消息。
+ *
+ * @param page 分页参数
+ * @param userId 当前登录用户ID
+ * @param unreadOnly true时仅返回未读消息
+ * @return 当前用户消息分页
+ */
+ @Override
+ public Page page(Page> page, Long userId, boolean unreadOnly) {
+ Page entityPage = messageMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()),
+ Wrappers.lambdaQuery()
+ .eq(ThirdAppMessageEntity::getRecipientUserId, userId)
+ .eq(unreadOnly, ThirdAppMessageEntity::getReadStatus, AppMessageConstants.UNREAD)
+ .orderByDesc(ThirdAppMessageEntity::getOccurredTime)
+ .orderByDesc(ThirdAppMessageEntity::getId));
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(entityPage.getRecords().stream().map(this::toVo).toList());
+ return result;
+ }
+
+ /**
+ * 查询当前用户消息详情。
+ *
+ * @param messageId 消息ID
+ * @param userId 当前登录用户ID
+ * @return 当前用户消息详情
+ */
+ @Override
+ public AppMessageVo detail(Long messageId, Long userId) {
+ ThirdAppMessageEntity message = findOwnedMessage(messageId, userId);
+ return toVo(message);
+ }
+
+ /**
+ * 查询当前用户未读数量。
+ *
+ * @param userId 当前登录用户ID
+ * @return 未读数量
+ */
+ @Override
+ public long unreadCount(Long userId) {
+ return messageMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(ThirdAppMessageEntity::getRecipientUserId, userId)
+ .eq(ThirdAppMessageEntity::getReadStatus, AppMessageConstants.UNREAD));
+ }
+
+ /**
+ * 幂等标记当前用户一条消息为已读。
+ *
+ * @param messageId 消息ID
+ * @param userId 当前登录用户ID
+ */
+ @Override
+ public void markRead(Long messageId, Long userId) {
+ findOwnedMessage(messageId, userId);
+ ThirdAppMessageEntity update = new ThirdAppMessageEntity();
+ update.setReadStatus(AppMessageConstants.READ);
+ update.setReadTime(LocalDateTime.now());
+ messageMapper.update(update, Wrappers.lambdaUpdate()
+ .eq(ThirdAppMessageEntity::getId, messageId)
+ .eq(ThirdAppMessageEntity::getRecipientUserId, userId)
+ .eq(ThirdAppMessageEntity::getReadStatus, AppMessageConstants.UNREAD));
+ }
+
+ /**
+ * 批量标记当前用户全部消息为已读。
+ *
+ * @param userId 当前登录用户ID
+ * @return 实际更新数量
+ */
+ @Override
+ public int markAllRead(Long userId) {
+ ThirdAppMessageEntity update = new ThirdAppMessageEntity();
+ update.setReadStatus(AppMessageConstants.READ);
+ update.setReadTime(LocalDateTime.now());
+ return messageMapper.update(update, Wrappers.lambdaUpdate()
+ .eq(ThirdAppMessageEntity::getRecipientUserId, userId)
+ .eq(ThirdAppMessageEntity::getReadStatus, AppMessageConstants.UNREAD));
+ }
+
+ /**
+ * 根据商户策略、有效关系和用户具体订阅创建关系收件箱及Push决策。
+ *
+ * @param ownerMessage 已持久化本人消息
+ * @param policy 来源商户事件策略
+ * @param payloadJson 消息详情快照JSON
+ * @param occurredTime 业务事件发生时间
+ */
+ private void createRelationMessages(ThirdAppMessageEntity ownerMessage, ThirdAppPushPolicyEntity policy,
+ String payloadJson, LocalDateTime occurredTime) {
+ if (policy == null || !AppMessageConstants.ENABLED.equals(policy.getRelationMessageEnabled())) {
+ return;
+ }
+ List relationCodes = settingService.listPolicyRelationCodes(policy.getId());
+ if (relationCodes.isEmpty()) {
+ return;
+ }
+ SysUserRelationRecipientQueryDto query = new SysUserRelationRecipientQueryDto();
+ query.setSubjectUserId(ownerMessage.getSubjectUserId());
+ query.setRelationCodes(relationCodes);
+ query.setOccurredTime(occurredTime);
+ query.setProcessingTime(LocalDateTime.now());
+ R> result = remoteUserRelationService.listActiveRecipients(query);
+ if (result == null || !result.isOk()) {
+ throw new IllegalStateException("查询消息关系接收人失败");
+ }
+ Set createdRecipientIds = new LinkedHashSet<>();
+ List relations = result.getData() == null ? List.of() : result.getData();
+ for (SysUserRelationRecipientVo relation : relations) {
+ if (relation.getRecipientUserId() == null || !createdRecipientIds.add(relation.getRecipientUserId())) {
+ continue;
+ }
+ if (!settingService.isRelationSubscribed(relation.getRelationId(), relation.getRecipientUserId(), ownerMessage.getEventCode())) {
+ createdRecipientIds.remove(relation.getRecipientUserId());
+ continue;
+ }
+ String subjectName = StrUtil.blankToDefault(relation.getSubjectUserName(), "被监护人");
+ ThirdAppMessageEntity relationMessage = buildBaseMessage(
+ ownerMessage.getTenantId(), ownerMessage.getEventId(), ownerMessage.getEventCode(),
+ relation.getRecipientUserId(), ownerMessage.getSubjectUserId(), AppMessageConstants.RECIPIENT_RELATION,
+ ownerMessage.getSourceService(), ownerMessage.getSourceBizType(), ownerMessage.getSourceBizId(),
+ ownerMessage.getSourceMchId(), ownerMessage.getSourceMchName(), subjectName + "的" + ownerMessage.getTitle(),
+ subjectName + ":" + ownerMessage.getContent(), payloadJson, occurredTime);
+ relationMessage.setRelationId(relation.getRelationId());
+ relationMessage.setRelationCode(relation.getRelationCode());
+ relationMessage.setRelationName(relation.getRelationName());
+ relationMessage.setSubjectUserName(subjectName);
+ messageMapper.insert(relationMessage);
+ String skipReason = settingService.isUserPushEnabled(relation.getRecipientUserId())
+ ? null : AppMessageConstants.SKIP_USER_DISABLED;
+ deliveryService.createDelivery(relationMessage, skipReason);
+ }
+ }
+
+ /**
+ * 计算本人消息系统Push跳过原因,返回空表示允许创建待投递任务。
+ *
+ * @param sourceMchId 来源商户ID
+ * @param policy 商户事件策略
+ * @param userId 本人用户ID
+ * @param eventCode 固定事件编码
+ * @return 稳定跳过原因或空
+ */
+ private String ownerSkipReason(Long sourceMchId, ThirdAppPushPolicyEntity policy, Long userId, String eventCode) {
+ if (sourceMchId == null) {
+ return AppMessageConstants.SKIP_MERCHANT_UNRESOLVED;
+ }
+ if (policy == null || !AppMessageConstants.ENABLED.equals(policy.getOwnerPushEnabled())) {
+ return AppMessageConstants.SKIP_MERCHANT_DISABLED;
+ }
+ if (!settingService.isUserPushEnabled(userId)) {
+ return AppMessageConstants.SKIP_USER_DISABLED;
+ }
+ if (!settingService.isOwnerEventPushEnabled(userId, eventCode)) {
+ return AppMessageConstants.SKIP_EVENT_DISABLED;
+ }
+ return null;
+ }
+
+ /**
+ * 构造本人或关系消息的公共不可变业务快照。
+ *
+ * @param tenantId 租户ID
+ * @param eventId 业务事件幂等ID
+ * @param eventCode 固定事件编码
+ * @param recipientUserId 收件人用户ID
+ * @param subjectUserId 事件主体用户ID
+ * @param recipientType 接收类型
+ * @param sourceService 来源服务
+ * @param sourceBizType 来源业务类型
+ * @param sourceBizId 来源业务ID
+ * @param sourceMchId 来源商户ID
+ * @param sourceMchName 来源商户名称
+ * @param title 消息标题
+ * @param content 消息正文
+ * @param payloadJson 详情快照JSON
+ * @param occurredTime 事件发生时间
+ * @return 待持久化消息实体
+ */
+ private ThirdAppMessageEntity buildBaseMessage(Long tenantId, String eventId, String eventCode,
+ Long recipientUserId, Long subjectUserId, String recipientType,
+ String sourceService, String sourceBizType, String sourceBizId,
+ Long sourceMchId, String sourceMchName, String title, String content,
+ String payloadJson, LocalDateTime occurredTime) {
+ ThirdAppMessageEntity message = new ThirdAppMessageEntity();
+ message.setTenantId(tenantId);
+ message.setEventId(eventId);
+ message.setEventCode(eventCode);
+ message.setRecipientUserId(recipientUserId);
+ message.setSubjectUserId(subjectUserId);
+ message.setRecipientType(recipientType);
+ message.setSourceService(sourceService);
+ message.setSourceBizType(sourceBizType);
+ message.setSourceBizId(sourceBizId);
+ message.setSourceMchId(sourceMchId);
+ message.setSourceMchName(sourceMchName);
+ message.setTitle(title);
+ message.setContent(content);
+ message.setPayloadJson(payloadJson);
+ message.setDeepLinkPath(AppMessageConstants.MESSAGE_DETAIL_PATH);
+ message.setReadStatus(AppMessageConstants.UNREAD);
+ message.setOccurredTime(occurredTime);
+ return message;
+ }
+
+ /**
+ * 校验钱包事件编码和全部明细业务类型一致。
+ *
+ * @param dto Wallet Outbox事件快照
+ * @return 对应固定钱包事件
+ */
+ private AppMessageEventEnum requireWalletEvent(WalletMessageEventDto dto) {
+ AppMessageEventEnum event = AppMessageEventEnum.fromCode(dto.getEventCode());
+ if (event == null || event.getWalletBizCode() == null) {
+ throw new IllegalArgumentException("不支持的钱包消息事件编码");
+ }
+ boolean detailMismatch = dto.getDetails().stream()
+ .anyMatch(detail -> !event.getWalletBizCode().equals(detail.getWalletBizType()));
+ if (detailMismatch || !event.getWalletBizCode().equals(dto.getSourceBizType())) {
+ throw new IllegalArgumentException("钱包事件与流水业务类型不一致");
+ }
+ return event;
+ }
+
+ /**
+ * 校验Hardware只提交固定用餐核销事件。
+ *
+ * @param dto Hardware Outbox事件快照
+ * @return 用餐核销事件枚举
+ */
+ private AppMessageEventEnum requireMealEvent(MealVerifiedEventDto dto) {
+ AppMessageEventEnum event = AppMessageEventEnum.fromCode(dto.getEventCode());
+ if (event != AppMessageEventEnum.MEAL_VERIFIED) {
+ throw new IllegalArgumentException("不支持的用餐消息事件编码");
+ }
+ return event;
+ }
+
+ /**
+ * 判断本人消息是否已存在,作为来源Outbox重试的端到端幂等边界。
+ *
+ * @param eventId 业务事件幂等ID
+ * @param subjectUserId 事件主体用户ID
+ * @return 是否已完成本人消息持久化
+ */
+ private boolean ownerMessageExists(String eventId, Long subjectUserId) {
+ return messageMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(ThirdAppMessageEntity::getEventId, eventId)
+ .eq(ThirdAppMessageEntity::getRecipientUserId, subjectUserId)) > 0;
+ }
+
+ /**
+ * 返回钱包事件本人标题。
+ *
+ * @param event 钱包事件
+ * @return 固定模板标题
+ */
+ private String ownerTitle(AppMessageEventEnum event) {
+ return switch (event) {
+ case WALLET_PAYMENT -> "下单成功,钱包已扣款";
+ case WALLET_REFUND, WALLET_TRANSFER_REFUND -> "退款已到账";
+ case WALLET_RECHARGE -> "充值已到账";
+ case WALLET_RESET -> "钱包余额已重置";
+ default -> event.getLabel();
+ };
+ }
+
+ /**
+ * 渲染钱包多明细正文,金额和结果余额统一按分转换为两位元展示。
+ *
+ * @param event 钱包事件
+ * @param details 同一业务操作的钱包明细
+ * @return 固定模板正文
+ */
+ private String walletContent(AppMessageEventEnum event, List details) {
+ StringBuilder content = new StringBuilder(event.getLabel()).append(":");
+ for (int index = 0; index < details.size(); index++) {
+ WalletMessageDetailDto detail = details.get(index);
+ if (index > 0) {
+ content.append(";");
+ }
+ content.append(StrUtil.blankToDefault(detail.getWalletName(), "钱包"))
+ .append(detail.getAmount() >= 0 ? "增加" : "减少")
+ .append(formatFen(Math.abs(detail.getAmount())))
+ .append("元,余额")
+ .append(formatFen(detail.getBalance()))
+ .append("元");
+ }
+ return content.toString();
+ }
+
+ /**
+ * 序列化钱包详情展示快照。
+ *
+ * @param dto Wallet Outbox事件快照
+ * @return 非敏感JSON字符串
+ */
+ private String serializeWalletPayload(WalletMessageEventDto dto) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("sourceScene", dto.getSourceScene());
+ payload.put("sourceBizId", dto.getSourceBizId());
+ payload.put("details", dto.getDetails());
+ return serialize(payload);
+ }
+
+ /**
+ * 使用项目Jackson配置序列化展示快照,失败时中止事务避免生成缺少详情的半成品消息。
+ *
+ * @param payload 非敏感展示数据
+ * @return JSON字符串
+ */
+ private String serialize(Object payload) {
+ try {
+ return objectMapper.writeValueAsString(payload);
+ } catch (JsonProcessingException exception) {
+ throw new IllegalStateException("序列化业务消息快照失败", exception);
+ }
+ }
+
+ /**
+ * 将整数分转换为两位小数元字符串。
+ *
+ * @param amountFen 金额,单位分
+ * @return 金额元字符串
+ */
+ private String formatFen(int amountFen) {
+ return BigDecimal.valueOf(amountFen).divide(BigDecimal.valueOf(100), 2, RoundingMode.UNNECESSARY).toPlainString();
+ }
+
+ /**
+ * 查询并校验消息属于当前登录用户。
+ *
+ * @param messageId 消息ID
+ * @param userId 当前登录用户ID
+ * @return 当前用户消息实体
+ */
+ private ThirdAppMessageEntity findOwnedMessage(Long messageId, Long userId) {
+ ThirdAppMessageEntity message = messageMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ThirdAppMessageEntity::getId, messageId)
+ .eq(ThirdAppMessageEntity::getRecipientUserId, userId)
+ .last("limit 1"));
+ if (message == null) {
+ throw new IllegalArgumentException("消息不存在");
+ }
+ return message;
+ }
+
+ /**
+ * 将消息实体转换为App安全展示模型。
+ *
+ * @param entity 消息实体
+ * @return App消息模型
+ */
+ private AppMessageVo toVo(ThirdAppMessageEntity entity) {
+ AppMessageVo vo = new AppMessageVo();
+ BeanUtils.copyProperties(entity, vo);
+ AppMessageEventEnum event = AppMessageEventEnum.fromCode(entity.getEventCode());
+ vo.setCategory(event == null ? null : event.getCategory());
+ vo.setRead(AppMessageConstants.READ.equals(entity.getReadStatus()));
+ return vo;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java
new file mode 100644
index 00000000..f9c8d15c
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java
@@ -0,0 +1,516 @@
+package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
+
+import cn.hutool.core.collection.CollUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicyItemDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserEventSettingDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyRelationEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppRelationSubscriptionEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppUserEventSettingEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppUserPushSettingEntity;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppPushPolicyMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppPushPolicyRelationMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppRelationSubscriptionMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppUserEventSettingMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppUserPushSettingMapper;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushPolicyVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationEventSettingVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationSubscriptionVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppUserEventSettingVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppUserPushSettingVo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 商户策略、用户Push偏好和具体关系订阅实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSettingService {
+
+ /** 商户事件策略Mapper。 */
+ private final ThirdAppPushPolicyMapper policyMapper;
+
+ /** 商户策略关系范围Mapper。 */
+ private final ThirdAppPushPolicyRelationMapper policyRelationMapper;
+
+ /** 用户业务Push总开关Mapper。 */
+ private final ThirdAppUserPushSettingMapper userPushSettingMapper;
+
+ /** 用户本人事件开关Mapper。 */
+ private final ThirdAppUserEventSettingMapper userEventSettingMapper;
+
+ /** 用户具体关系事件订阅Mapper。 */
+ private final ThirdAppRelationSubscriptionMapper relationSubscriptionMapper;
+
+ /** UPMS有效关系内部查询客户端。 */
+ private final RemoteUserRelationService remoteUserRelationService;
+
+ /**
+ * 返回指定商户全事件策略矩阵,缺少记录按两个开关均关闭。
+ *
+ * @param mchId 商户ID
+ * @return 全事件策略矩阵
+ */
+ @Override
+ public List listMerchantPolicies(Long mchId) {
+ List policies = policyMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ThirdAppPushPolicyEntity::getMchId, mchId));
+ Map policyMap = policies.stream()
+ .collect(Collectors.toMap(ThirdAppPushPolicyEntity::getEventCode, Function.identity(), (first, second) -> first));
+ Set policyIds = policies.stream().map(ThirdAppPushPolicyEntity::getId).collect(Collectors.toSet());
+ Map> relationCodeMap = policyIds.isEmpty() ? Map.of()
+ : policyRelationMapper.selectList(Wrappers.lambdaQuery()
+ .in(ThirdAppPushPolicyRelationEntity::getPolicyId, policyIds))
+ .stream().collect(Collectors.groupingBy(ThirdAppPushPolicyRelationEntity::getPolicyId,
+ Collectors.mapping(ThirdAppPushPolicyRelationEntity::getRelationCode, Collectors.toList())));
+ return Arrays.stream(AppMessageEventEnum.values()).map(event -> {
+ AppPushPolicyVo vo = new AppPushPolicyVo();
+ vo.setEventCode(event.getCode());
+ vo.setEventLabel(event.getLabel());
+ vo.setCategory(event.getCategory());
+ ThirdAppPushPolicyEntity policy = policyMap.get(event.getCode());
+ vo.setOwnerPushEnabled(policy != null && AppMessageConstants.ENABLED.equals(policy.getOwnerPushEnabled()));
+ vo.setRelationMessageEnabled(policy != null && AppMessageConstants.ENABLED.equals(policy.getRelationMessageEnabled()));
+ vo.setRelationCodes(policy == null ? new ArrayList<>()
+ : new ArrayList<>(relationCodeMap.getOrDefault(policy.getId(), List.of())));
+ return vo;
+ }).toList();
+ }
+
+ /**
+ * 在事务中覆盖保存商户全事件策略和关系范围。
+ *
+ * @param dto 全事件策略请求
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveMerchantPolicies(AppPushPolicySaveDto dto) {
+ validateCompletePolicies(dto.getPolicies());
+ Long tenantId = requireTenantId();
+ Map existingMap = policyMapper.selectList(
+ Wrappers.lambdaQuery().eq(ThirdAppPushPolicyEntity::getMchId, dto.getMchId()))
+ .stream().collect(Collectors.toMap(ThirdAppPushPolicyEntity::getEventCode, Function.identity()));
+ for (AppPushPolicyItemDto item : dto.getPolicies()) {
+ ThirdAppPushPolicyEntity policy = existingMap.get(item.getEventCode());
+ if (policy == null) {
+ policy = new ThirdAppPushPolicyEntity();
+ policy.setTenantId(tenantId);
+ policy.setMchId(dto.getMchId());
+ policy.setEventCode(item.getEventCode());
+ policy.setOwnerPushEnabled(flag(item.getOwnerPushEnabled()));
+ policy.setRelationMessageEnabled(flag(item.getRelationMessageEnabled()));
+ policyMapper.insert(policy);
+ } else {
+ policy.setOwnerPushEnabled(flag(item.getOwnerPushEnabled()));
+ policy.setRelationMessageEnabled(flag(item.getRelationMessageEnabled()));
+ policyMapper.updateById(policy);
+ }
+ policyRelationMapper.physicalDeleteByPolicyId(tenantId, policy.getId());
+ if (Boolean.TRUE.equals(item.getRelationMessageEnabled())) {
+ for (String relationCode : normalizeRelationCodes(item.getRelationCodes())) {
+ ThirdAppPushPolicyRelationEntity relation = new ThirdAppPushPolicyRelationEntity();
+ relation.setTenantId(tenantId);
+ relation.setPolicyId(policy.getId());
+ relation.setRelationCode(relationCode);
+ policyRelationMapper.insert(relation);
+ }
+ }
+ }
+ }
+
+ /**
+ * 查询消息处理时使用的单个商户事件策略。
+ *
+ * @param mchId 来源商户ID
+ * @param eventCode 固定事件编码
+ * @return 已持久化策略,不存在时为空
+ */
+ @Override
+ public ThirdAppPushPolicyEntity findPolicy(Long mchId, String eventCode) {
+ if (mchId == null) {
+ return null;
+ }
+ return policyMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ThirdAppPushPolicyEntity::getMchId, mchId)
+ .eq(ThirdAppPushPolicyEntity::getEventCode, eventCode)
+ .last("limit 1"));
+ }
+
+ /**
+ * 查询指定策略允许的关系类型编码。
+ *
+ * @param policyId 商户事件策略ID
+ * @return 去重关系编码
+ */
+ @Override
+ public List listPolicyRelationCodes(Long policyId) {
+ return policyRelationMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ThirdAppPushPolicyRelationEntity::getPolicyId, policyId)
+ .orderByAsc(ThirdAppPushPolicyRelationEntity::getId))
+ .stream().map(ThirdAppPushPolicyRelationEntity::getRelationCode).distinct().toList();
+ }
+
+ /**
+ * 查询用户全事件Push偏好,数据库缺省值映射为开启。
+ *
+ * @param userId 当前用户ID
+ * @return 用户Push偏好
+ */
+ @Override
+ public AppUserPushSettingVo getUserSetting(Long userId) {
+ ThirdAppUserPushSettingEntity total = findUserPushSetting(userId);
+ Map eventMap = userEventSettingMapper.selectList(
+ Wrappers.lambdaQuery().eq(ThirdAppUserEventSettingEntity::getUserId, userId))
+ .stream().collect(Collectors.toMap(ThirdAppUserEventSettingEntity::getEventCode, Function.identity(), (first, second) -> first));
+ AppUserPushSettingVo vo = new AppUserPushSettingVo();
+ vo.setPushEnabled(total == null || AppMessageConstants.ENABLED.equals(total.getPushEnabled()));
+ vo.setEventSettings(Arrays.stream(AppMessageEventEnum.values()).map(event -> {
+ AppUserEventSettingVo item = new AppUserEventSettingVo();
+ item.setEventCode(event.getCode());
+ item.setEventLabel(event.getLabel());
+ item.setCategory(event.getCategory());
+ ThirdAppUserEventSettingEntity setting = eventMap.get(event.getCode());
+ item.setPushEnabled(setting == null || AppMessageConstants.ENABLED.equals(setting.getPushEnabled()));
+ return item;
+ }).toList());
+ return vo;
+ }
+
+ /**
+ * 在事务中保存用户总开关和全部本人事件开关。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 完整用户偏好
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveUserSetting(Long userId, AppUserPushSettingSaveDto dto) {
+ validateEventSettings(dto.getEventSettings());
+ Long tenantId = requireTenantId();
+ ThirdAppUserPushSettingEntity total = findUserPushSetting(userId);
+ if (total == null) {
+ total = new ThirdAppUserPushSettingEntity();
+ total.setTenantId(tenantId);
+ total.setUserId(userId);
+ total.setPushEnabled(flag(dto.getPushEnabled()));
+ userPushSettingMapper.insert(total);
+ } else {
+ total.setPushEnabled(flag(dto.getPushEnabled()));
+ userPushSettingMapper.updateById(total);
+ }
+ Map existingMap = userEventSettingMapper.selectList(
+ Wrappers.lambdaQuery().eq(ThirdAppUserEventSettingEntity::getUserId, userId))
+ .stream().collect(Collectors.toMap(ThirdAppUserEventSettingEntity::getEventCode, Function.identity()));
+ for (AppUserEventSettingDto item : dto.getEventSettings()) {
+ ThirdAppUserEventSettingEntity setting = existingMap.get(item.getEventCode());
+ if (setting == null) {
+ setting = new ThirdAppUserEventSettingEntity();
+ setting.setTenantId(tenantId);
+ setting.setUserId(userId);
+ setting.setEventCode(item.getEventCode());
+ setting.setPushEnabled(flag(item.getPushEnabled()));
+ userEventSettingMapper.insert(setting);
+ } else {
+ setting.setPushEnabled(flag(item.getPushEnabled()));
+ userEventSettingMapper.updateById(setting);
+ }
+ }
+ }
+
+ /**
+ * 判断用户业务Push总开关,缺少记录默认开启。
+ *
+ * @param userId 用户ID
+ * @return 是否开启
+ */
+ @Override
+ public boolean isUserPushEnabled(Long userId) {
+ ThirdAppUserPushSettingEntity setting = findUserPushSetting(userId);
+ return setting == null || AppMessageConstants.ENABLED.equals(setting.getPushEnabled());
+ }
+
+ /**
+ * 判断用户本人事件Push开关,缺少记录默认开启。
+ *
+ * @param userId 用户ID
+ * @param eventCode 固定事件编码
+ * @return 是否开启
+ */
+ @Override
+ public boolean isOwnerEventPushEnabled(Long userId, String eventCode) {
+ ThirdAppUserEventSettingEntity setting = userEventSettingMapper.selectOne(
+ Wrappers.lambdaQuery()
+ .eq(ThirdAppUserEventSettingEntity::getUserId, userId)
+ .eq(ThirdAppUserEventSettingEntity::getEventCode, eventCode)
+ .last("limit 1"));
+ return setting == null || AppMessageConstants.ENABLED.equals(setting.getPushEnabled());
+ }
+
+ /**
+ * 查询当前用户有效关系并合并数据库订阅,所有事件缺省为关闭。
+ *
+ * @param userId 当前登录用户ID
+ * @return 具体关系订阅矩阵
+ */
+ @Override
+ public List listRelationSubscriptions(Long userId) {
+ List relations = listCurrentRelations(userId);
+ if (relations.isEmpty()) {
+ return List.of();
+ }
+ Set relationIds = relations.stream().map(SysUserRelationRecipientVo::getRelationId).collect(Collectors.toSet());
+ Map subscriptionMap = relationSubscriptionMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(ThirdAppRelationSubscriptionEntity::getSubscriberUserId, userId)
+ .in(ThirdAppRelationSubscriptionEntity::getRelationId, relationIds))
+ .stream().collect(Collectors.toMap(
+ item -> subscriptionKey(item.getRelationId(), item.getEventCode()), Function.identity(), (first, second) -> first));
+ return relations.stream().map(relation -> {
+ AppRelationSubscriptionVo vo = new AppRelationSubscriptionVo();
+ vo.setRelationId(relation.getRelationId());
+ vo.setSubjectUserId(relation.getSubjectUserId());
+ vo.setSubjectUserName(relation.getSubjectUserName());
+ vo.setRelationCode(relation.getRelationCode());
+ vo.setRelationName(relation.getRelationName());
+ vo.setExpireTime(relation.getExpireTime());
+ vo.setEventSettings(Arrays.stream(AppMessageEventEnum.values()).map(event -> {
+ AppRelationEventSettingVo item = new AppRelationEventSettingVo();
+ item.setEventCode(event.getCode());
+ item.setEventLabel(event.getLabel());
+ item.setCategory(event.getCategory());
+ ThirdAppRelationSubscriptionEntity subscription = subscriptionMap.get(subscriptionKey(relation.getRelationId(), event.getCode()));
+ item.setEnabled(subscription != null && AppMessageConstants.ENABLED.equals(subscription.getEnabled()));
+ return item;
+ }).toList());
+ return vo;
+ }).toList();
+ }
+
+ /**
+ * 保存当前用户具体关系事件订阅,关系失效或不再归属当前用户时拒绝保存。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 具体关系事件订阅
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveRelationSubscription(Long userId, AppRelationSubscriptionSaveDto dto) {
+ AppMessageEventEnum event = requireEvent(dto.getEventCode());
+ SysUserRelationRecipientVo relation = listCurrentRelations(userId).stream()
+ .filter(item -> item.getRelationId().equals(dto.getRelationId()))
+ .findFirst().orElseThrow(() -> new IllegalArgumentException("用户关系已失效或不属于当前用户"));
+ ThirdAppRelationSubscriptionEntity subscription = relationSubscriptionMapper.selectOne(
+ Wrappers.lambdaQuery()
+ .eq(ThirdAppRelationSubscriptionEntity::getRelationId, dto.getRelationId())
+ .eq(ThirdAppRelationSubscriptionEntity::getEventCode, event.getCode())
+ .last("limit 1"));
+ if (subscription == null) {
+ subscription = new ThirdAppRelationSubscriptionEntity();
+ subscription.setTenantId(requireTenantId());
+ subscription.setRelationId(relation.getRelationId());
+ subscription.setSubscriberUserId(userId);
+ subscription.setSubjectUserId(relation.getSubjectUserId());
+ subscription.setRelationCode(relation.getRelationCode());
+ subscription.setEventCode(event.getCode());
+ subscription.setEnabled(flag(dto.getEnabled()));
+ relationSubscriptionMapper.insert(subscription);
+ } else {
+ if (!userId.equals(subscription.getSubscriberUserId())) {
+ throw new IllegalArgumentException("用户关系订阅不属于当前用户");
+ }
+ subscription.setSubjectUserId(relation.getSubjectUserId());
+ subscription.setRelationCode(relation.getRelationCode());
+ subscription.setEnabled(flag(dto.getEnabled()));
+ relationSubscriptionMapper.updateById(subscription);
+ }
+ }
+
+ /**
+ * 判断关系接收人是否已主动开启具体事件订阅。
+ *
+ * @param relationId 具体关系ID
+ * @param recipientUserId 接收用户ID
+ * @param eventCode 固定事件编码
+ * @return 是否开启订阅
+ */
+ @Override
+ public boolean isRelationSubscribed(Long relationId, Long recipientUserId, String eventCode) {
+ return relationSubscriptionMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(ThirdAppRelationSubscriptionEntity::getRelationId, relationId)
+ .eq(ThirdAppRelationSubscriptionEntity::getSubscriberUserId, recipientUserId)
+ .eq(ThirdAppRelationSubscriptionEntity::getEventCode, eventCode)
+ .eq(ThirdAppRelationSubscriptionEntity::getEnabled, AppMessageConstants.ENABLED)) > 0;
+ }
+
+ /**
+ * 返回稳定事件选项。
+ *
+ * @return 事件选项
+ */
+ @Override
+ public List listEventOptions() {
+ return Arrays.stream(AppMessageEventEnum.values()).map(event -> {
+ AppMessageEventOptionVo vo = new AppMessageEventOptionVo();
+ vo.setEventCode(event.getCode());
+ vo.setLabel(event.getLabel());
+ vo.setCategory(event.getCategory());
+ return vo;
+ }).toList();
+ }
+
+ /**
+ * 查询用户总开关持久化记录。
+ *
+ * @param userId 用户ID
+ * @return 总开关记录,不存在时为空
+ */
+ private ThirdAppUserPushSettingEntity findUserPushSetting(Long userId) {
+ return userPushSettingMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ThirdAppUserPushSettingEntity::getUserId, userId).last("limit 1"));
+ }
+
+ /**
+ * 通过UPMS内部接口读取当前用户仍有效的具体关系。
+ *
+ * @param userId 当前关系主语用户ID
+ * @return 当前有效关系,远程失败时抛出异常避免误保存订阅
+ */
+ private List listCurrentRelations(Long userId) {
+ R> result = remoteUserRelationService.listActiveGuardianWardRelations(userId);
+ if (result == null || !result.isOk()) {
+ throw new IllegalStateException("查询当前有效用户关系失败");
+ }
+ return result.getData() == null ? List.of() : result.getData();
+ }
+
+ /**
+ * 校验商户请求覆盖且只覆盖全部固定事件。
+ *
+ * @param policies 商户策略集合
+ */
+ private void validateCompletePolicies(List policies) {
+ Set actualCodes = policies.stream().map(AppPushPolicyItemDto::getEventCode).collect(Collectors.toSet());
+ Set expectedCodes = Arrays.stream(AppMessageEventEnum.values()).map(AppMessageEventEnum::getCode).collect(Collectors.toSet());
+ if (actualCodes.size() != policies.size() || !actualCodes.equals(expectedCodes)) {
+ throw new IllegalArgumentException("商户消息策略必须完整且事件编码不能重复");
+ }
+ policies.forEach(item -> {
+ requireEvent(item.getEventCode());
+ if (!Boolean.TRUE.equals(item.getRelationMessageEnabled()) && CollUtil.isNotEmpty(item.getRelationCodes())) {
+ throw new IllegalArgumentException("关系消息关闭时不能保留关系类型");
+ }
+ normalizeRelationCodes(item.getRelationCodes());
+ });
+ }
+
+ /**
+ * 校验用户事件开关不存在重复或未知编码;允许只保存客户端本次提交的事件子集。
+ *
+ * @param settings 用户事件开关集合
+ */
+ private void validateEventSettings(List settings) {
+ Set codes = new HashSet<>();
+ for (AppUserEventSettingDto setting : settings) {
+ requireEvent(setting.getEventCode());
+ if (!codes.add(setting.getEventCode())) {
+ throw new IllegalArgumentException("用户事件Push设置不能重复");
+ }
+ }
+ }
+
+ /**
+ * 校验并去重关系编码,关系编码必须属于UPMS稳定关系协议。
+ *
+ * @param relationCodes 待保存关系编码
+ * @return 保持首次出现顺序的去重编码
+ */
+ private List normalizeRelationCodes(List relationCodes) {
+ if (CollUtil.isEmpty(relationCodes)) {
+ return List.of();
+ }
+ Set supportedCodes = Arrays.stream(UserRelationTypeEnum.values())
+ .map(UserRelationTypeEnum::getRelationCode).collect(Collectors.toSet());
+ LinkedHashMap normalized = new LinkedHashMap<>();
+ for (String relationCode : relationCodes) {
+ if (!supportedCodes.contains(relationCode)) {
+ throw new IllegalArgumentException("不支持的用户关系类型:" + relationCode);
+ }
+ normalized.putIfAbsent(relationCode, relationCode);
+ }
+ return new ArrayList<>(normalized.values());
+ }
+
+ /**
+ * 校验并返回固定事件枚举。
+ *
+ * @param eventCode 接口事件编码
+ * @return 固定事件枚举
+ */
+ private AppMessageEventEnum requireEvent(String eventCode) {
+ AppMessageEventEnum event = AppMessageEventEnum.fromCode(eventCode);
+ if (event == null) {
+ throw new IllegalArgumentException("不支持的消息事件编码:" + eventCode);
+ }
+ return event;
+ }
+
+ /**
+ * 将布尔开关转换为数据库稳定编码。
+ *
+ * @param enabled 布尔开关
+ * @return 1开启或0关闭
+ */
+ private String flag(Boolean enabled) {
+ return Boolean.TRUE.equals(enabled) ? AppMessageConstants.ENABLED : AppMessageConstants.DISABLED;
+ }
+
+ /**
+ * 获取当前租户ID,缺少租户上下文时拒绝写入,避免产生无法隔离的数据。
+ *
+ * @return 当前租户ID
+ */
+ private Long requireTenantId() {
+ Long tenantId = TenantContextHolder.getTenantId();
+ if (tenantId == null) {
+ throw new IllegalStateException("缺少租户上下文");
+ }
+ return tenantId;
+ }
+
+ /**
+ * 构造关系ID和事件编码的内存映射键。
+ *
+ * @param relationId 关系ID
+ * @param eventCode 事件编码
+ * @return 仅用于当前请求内存映射的键
+ */
+ private String subscriptionKey(Long relationId, String eventCode) {
+ return relationId + ":" + eventCode;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
new file mode 100644
index 00000000..2cd47554
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
@@ -0,0 +1,454 @@
+package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.constant.push.ThirdPushBizTypeEnum;
+import com.bsd.pjcloud.thirdparty.entity.ThirdPushRecordEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeliveryAttemptEntity;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeliveryEntity;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdPushDeliveryAttemptMapper;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdPushDeliveryMapper;
+import com.bsd.pjcloud.thirdparty.provider.PushDispatchAttempt;
+import com.bsd.pjcloud.thirdparty.provider.PushDispatchResult;
+import com.bsd.pjcloud.thirdparty.provider.PushProvider;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderDispatcher;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderRequest;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderResultStatus;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeviceBindingService;
+import com.bsd.pjcloud.thirdparty.service.push.ThirdPushRecordService;
+import com.bsd.pjcloud.thirdparty.service.push.ThirdPushRecordUserService;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryAttemptVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryVo;
+import com.bsd.pjcloud.thirdparty.vo.appmessage.PushProviderStatusVo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * App业务消息Push投递实现,负责决策留痕、多供应商调度、故障恢复和PC诊断。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
+
+ /** 测试Push在既有广播记录表中使用的虚拟配置ID,不对应真实定时配置。 */
+ private static final long TEST_PUSH_CONF_ID = -1L;
+
+ /** 单轮清理每类记录的最大数量。 */
+ private static final int CLEANUP_BATCH_SIZE = 1000;
+
+ /** 收件箱与终态投递记录保留天数。 */
+ private static final long MESSAGE_RETENTION_DAYS = 365L;
+
+ /** 供应商单次尝试日志保留天数。 */
+ private static final long ATTEMPT_RETENTION_DAYS = 90L;
+
+ /** Push投递任务Mapper。 */
+ private final ThirdPushDeliveryMapper deliveryMapper;
+
+ /** Push供应商尝试日志Mapper。 */
+ private final ThirdPushDeliveryAttemptMapper attemptMapper;
+
+ /** App收件箱消息Mapper。 */
+ private final ThirdAppMessageMapper messageMapper;
+
+ /** 多供应商优先级调度器。 */
+ private final PushProviderDispatcher providerDispatcher;
+
+ /** 通用设备绑定服务。 */
+ private final ThirdPushDeviceBindingService deviceBindingService;
+
+ /** 既有Push批次记录服务,测试Push复用该表留痕但不创建收件箱。 */
+ private final ThirdPushRecordService pushRecordService;
+
+ /** 既有Push用户明细服务,记录测试接收管理员和最终状态。 */
+ private final ThirdPushRecordUserService pushRecordUserService;
+
+ /** 事务模板,保证任务领取、供应商尝试留痕与最终投递状态在同一数据库事务中提交。 */
+ private final TransactionTemplate transactionTemplate;
+
+ /**
+ * 创建一条唯一Push决策;没有有效设备时把原本待投递任务记录为跳过。
+ *
+ * @param message 已持久化的收件箱消息
+ * @param skipReason 业务策略跳过原因,允许为空
+ */
+ @Override
+ public void createDelivery(ThirdAppMessageEntity message, String skipReason) {
+ if (deliveryMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(ThirdPushDeliveryEntity::getMessageId, message.getId())) > 0) {
+ return;
+ }
+ String resolvedSkipReason = skipReason;
+ if (resolvedSkipReason == null && !deviceBindingService.hasActiveBinding(message.getRecipientUserId())) {
+ resolvedSkipReason = AppMessageConstants.SKIP_NO_DEVICE;
+ }
+ ThirdPushDeliveryEntity delivery = new ThirdPushDeliveryEntity();
+ delivery.setTenantId(message.getTenantId());
+ delivery.setMessageId(message.getId());
+ delivery.setRecipientUserId(message.getRecipientUserId());
+ delivery.setSourceMchId(message.getSourceMchId());
+ delivery.setEventCode(message.getEventCode());
+ delivery.setStatus(resolvedSkipReason == null
+ ? AppMessageConstants.DELIVERY_PENDING : AppMessageConstants.DELIVERY_SKIPPED);
+ delivery.setSkipReason(resolvedSkipReason);
+ delivery.setAttemptCount(0);
+ deliveryMapper.insert(delivery);
+ }
+
+ /**
+ * 扫描到期和超时任务,并逐条切换到其持久化租户上下文处理。
+ *
+ * @return 成功领取的任务数量
+ */
+ @Override
+ public int processDueDeliveries() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime processingCutoff = now.minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES);
+ List candidates = deliveryMapper.selectList(
+ Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper
+ .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PENDING)
+ .or(current -> current.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_RETRY)
+ .le(ThirdPushDeliveryEntity::getNextRetryTime, now))
+ .or(current -> current.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING)
+ .le(ThirdPushDeliveryEntity::getUpdateTime, processingCutoff)))
+ .orderByAsc(ThirdPushDeliveryEntity::getCreateTime)
+ .last("limit " + AppMessageConstants.DELIVERY_SCAN_LIMIT));
+ int claimedCount = 0;
+ for (ThirdPushDeliveryEntity candidate : candidates) {
+ if (candidate.getTenantId() == null) {
+ log.warn("Push投递任务缺少租户ID,deliveryId:{}", candidate.getId());
+ continue;
+ }
+ try {
+ Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), tenantId ->
+ transactionTemplate.execute(status -> processOne(candidate.getId())));
+ if (Boolean.TRUE.equals(claimed)) {
+ claimedCount++;
+ }
+ } catch (Exception exception) {
+ log.error("处理Push投递任务失败,deliveryId:{},tenantId:{}", candidate.getId(), candidate.getTenantId(), exception);
+ }
+ }
+ return claimedCount;
+ }
+
+ /**
+ * 分页查询指定商户的投递记录。
+ *
+ * @param page 分页参数
+ * @param mchId 商户ID
+ * @param status 可选投递状态
+ * @param eventCode 可选事件编码
+ * @return PC投递记录分页
+ */
+ @Override
+ public Page page(Page> page, Long mchId, String status, String eventCode) {
+ Page entityPage = deliveryMapper.selectPage(
+ new Page<>(page.getCurrent(), page.getSize()),
+ Wrappers.lambdaQuery()
+ .eq(ThirdPushDeliveryEntity::getSourceMchId, mchId)
+ .eq(StrUtil.isNotBlank(status), ThirdPushDeliveryEntity::getStatus, status)
+ .eq(StrUtil.isNotBlank(eventCode), ThirdPushDeliveryEntity::getEventCode, eventCode)
+ .orderByDesc(ThirdPushDeliveryEntity::getCreateTime)
+ .orderByDesc(ThirdPushDeliveryEntity::getId));
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(entityPage.getRecords().stream().map(this::toDeliveryVo).toList());
+ return result;
+ }
+
+ /**
+ * 查询指定商户投递任务的供应商调用详情,先校验任务确实属于该商户。
+ *
+ * @param deliveryId 投递任务ID
+ * @param mchId 当前商户ID
+ * @return 供应商调用详情
+ */
+ @Override
+ public List listAttempts(Long deliveryId, Long mchId) {
+ ThirdPushDeliveryEntity delivery = deliveryMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ThirdPushDeliveryEntity::getId, deliveryId)
+ .eq(ThirdPushDeliveryEntity::getSourceMchId, mchId)
+ .last("limit 1"));
+ if (delivery == null) {
+ throw new IllegalArgumentException("投递记录不存在或不属于当前商户");
+ }
+ return attemptMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ThirdPushDeliveryAttemptEntity::getDeliveryId, deliveryId)
+ .orderByAsc(ThirdPushDeliveryAttemptEntity::getAttemptNo))
+ .stream().map(this::toAttemptVo).toList();
+ }
+
+ /**
+ * 返回全部供应商只读状态。
+ *
+ * @return 供应商状态列表
+ */
+ @Override
+ public List listProviderStatuses() {
+ return providerDispatcher.orderedProviders().stream().map(provider -> {
+ PushProviderStatusVo vo = new PushProviderStatusVo();
+ vo.setProviderCode(provider.code());
+ vo.setProviderName(AppMessageConstants.PROVIDER_JPUSH.equals(provider.code()) ? "极光推送" : provider.code());
+ vo.setEnabled(true);
+ vo.setAvailable(provider.available());
+ vo.setPriority(provider.priority());
+ vo.setStatusMessage(provider.available() ? "配置完整,可参与投递" : "部署配置不完整,调度时自动跳过");
+ return vo;
+ }).toList();
+ }
+
+ /**
+ * 向当前管理员发送无收件箱测试通知。
+ *
+ * @param userId 当前管理员用户ID
+ * @param mchId 当前配置商户ID
+ * @return 是否有供应商明确受理
+ */
+ @Override
+ public boolean sendTest(Long userId, Long mchId) {
+ String batchNo = "TEST-" + IdUtil.fastSimpleUUID();
+ ThirdPushRecordEntity record = createTestRecord(mchId, batchNo);
+ Boolean savedUser = pushRecordUserService.saveUsersByUserIds(record.getId(), TEST_PUSH_CONF_ID, batchNo, List.of(userId),
+ ThirdPushBizTypeEnum.MCH_PUSH.getType(), String.valueOf(mchId), "INIT");
+ if (!Boolean.TRUE.equals(savedUser)) {
+ pushRecordService.markBatchFail(record.getId(), "写入测试Push用户明细失败", "测试Push失败");
+ throw new IllegalStateException("写入测试Push用户明细失败");
+ }
+ PushProviderRequest request = new PushProviderRequest();
+ request.setTitle("App推送测试");
+ request.setContent("商户 " + mchId + " 的消息推送通道测试成功。");
+ request.setUserIds(List.of(userId));
+ request.setExtras(Map.of("test", "true"));
+ PushDispatchResult result = providerDispatcher.dispatch(request);
+ if (result.isAccepted()) {
+ pushRecordService.markBatchSuccess(record.getId(), 1, 0,
+ "测试Push已由" + result.getProviderCode() + "受理");
+ pushRecordUserService.updateStatusByRecordId(record.getId(), "SUCCESS", null);
+ return true;
+ }
+ String error = result.getAttempts().isEmpty() ? "当前没有可用Push供应商"
+ : StrUtil.blankToDefault(result.getAttempts().get(result.getAttempts().size() - 1).getResult().getErrorMessage(),
+ result.getAttempts().get(result.getAttempts().size() - 1).getResult().getErrorCode());
+ pushRecordService.markBatchFail(record.getId(), StrUtil.sub(error, 0, 512), "测试Push失败");
+ pushRecordUserService.updateStatusByRecordId(record.getId(), "FAIL", StrUtil.sub(error, 0, 512));
+ return false;
+ }
+
+ /**
+ * 分批物理清理供应商尝试、终态投递和收件箱消息。
+ *
+ * @return 本轮实际清理记录数量
+ */
+ @Override
+ public int cleanupExpiredRecords() {
+ LocalDateTime now = LocalDateTime.now();
+ int attempts = attemptMapper.physicalDeleteBefore(now.minusDays(ATTEMPT_RETENTION_DAYS), CLEANUP_BATCH_SIZE);
+ int deliveries = deliveryMapper.physicalDeleteTerminalBefore(now.minusDays(MESSAGE_RETENTION_DAYS), CLEANUP_BATCH_SIZE);
+ int messages = messageMapper.physicalDeleteBefore(now.minusDays(MESSAGE_RETENTION_DAYS), CLEANUP_BATCH_SIZE);
+ return attempts + deliveries + messages;
+ }
+
+ /**
+ * 在既有广播记录表创建不关联定时配置的测试Push留痕。
+ *
+ * @param mchId 当前测试商户ID
+ * @param batchNo 测试批次号
+ * @return 已持久化测试记录
+ */
+ private ThirdPushRecordEntity createTestRecord(Long mchId, String batchNo) {
+ ThirdPushRecordEntity record = new ThirdPushRecordEntity();
+ record.setConfId(TEST_PUSH_CONF_ID);
+ record.setBatchNo(batchNo);
+ record.setBizType(ThirdPushBizTypeEnum.MCH_PUSH.getType());
+ record.setBizId(String.valueOf(mchId));
+ record.setTitle("App推送测试");
+ record.setContent("商户 " + mchId + " 的消息推送通道测试成功。");
+ record.setInformType(0);
+ record.setInformTime(LocalTime.now());
+ record.setReceiverCount(1);
+ record.setSuccessCount(0);
+ record.setFailCount(0);
+ record.setSendTime(LocalDateTime.now());
+ record.setStatus("INIT");
+ if (!pushRecordService.save(record)) {
+ throw new IllegalStateException("创建测试Push记录失败");
+ }
+ return record;
+ }
+
+ /**
+ * 领取并处理单条投递任务,条件更新保证多实例只有一个节点获得处理权。
+ *
+ * @param deliveryId 投递任务ID
+ * @return 是否成功领取任务
+ */
+ private boolean processOne(Long deliveryId) {
+ ThirdPushDeliveryEntity current = deliveryMapper.selectById(deliveryId);
+ if (current == null || current.getAttemptCount() >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) {
+ return false;
+ }
+ ThirdPushDeliveryEntity claim = new ThirdPushDeliveryEntity();
+ claim.setStatus(AppMessageConstants.DELIVERY_PROCESSING);
+ claim.setNextRetryTime(null);
+ claim.setUpdateTime(LocalDateTime.now());
+ var claimWrapper = Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeliveryEntity::getId, deliveryId)
+ .eq(ThirdPushDeliveryEntity::getStatus, current.getStatus());
+ if (AppMessageConstants.DELIVERY_PROCESSING.equals(current.getStatus())) {
+ // 超时任务仍保持PROCESSING状态,必须附加旧更新时间条件,防止多个实例同时把同一任务领取成功。
+ claimWrapper.le(ThirdPushDeliveryEntity::getUpdateTime,
+ LocalDateTime.now().minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES));
+ }
+ int claimed = deliveryMapper.update(claim, claimWrapper);
+ if (claimed == 0) {
+ return false;
+ }
+ ThirdAppMessageEntity message = messageMapper.selectById(current.getMessageId());
+ if (message == null) {
+ markTerminalFailure(current, "MESSAGE_NOT_FOUND", 1);
+ return true;
+ }
+ PushProviderRequest request = new PushProviderRequest();
+ request.setTitle(message.getTitle());
+ request.setContent(message.getContent());
+ request.setUserIds(List.of(current.getRecipientUserId()));
+ request.setExtras(Map.of(
+ "messageId", String.valueOf(message.getId()),
+ "path", AppMessageConstants.MESSAGE_DETAIL_PATH));
+ int remainingAttempts = AppMessageConstants.MAX_DELIVERY_ATTEMPTS - current.getAttemptCount();
+ PushDispatchResult dispatchResult = providerDispatcher.dispatch(request, remainingAttempts);
+ int consumedAttempts = persistAttempts(current, dispatchResult);
+ int newAttemptCount = current.getAttemptCount() + Math.max(consumedAttempts, 1);
+ if (dispatchResult.isAccepted()) {
+ ThirdPushDeliveryEntity success = new ThirdPushDeliveryEntity();
+ success.setStatus(AppMessageConstants.DELIVERY_SUCCESS);
+ success.setAttemptCount(newAttemptCount);
+ success.setFinalProviderCode(dispatchResult.getProviderCode());
+ success.setProviderMessageId(dispatchResult.getProviderMessageId());
+ success.setSuccessTime(LocalDateTime.now());
+ success.setLastError(null);
+ deliveryMapper.update(success, Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeliveryEntity::getId, current.getId())
+ .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
+ return true;
+ }
+ PushDispatchAttempt lastAttempt = dispatchResult.getAttempts().isEmpty()
+ ? null : dispatchResult.getAttempts().get(dispatchResult.getAttempts().size() - 1);
+ String error = lastAttempt == null ? "NO_PROVIDER_AVAILABLE"
+ : StrUtil.blankToDefault(lastAttempt.getResult().getErrorMessage(), lastAttempt.getResult().getErrorCode());
+ if (lastAttempt != null && "INVALID_DEVICE".equals(lastAttempt.getResult().getErrorCode())) {
+ deviceBindingService.disableInvalidBindings(current.getRecipientUserId(), lastAttempt.getProviderCode());
+ }
+ if (newAttemptCount >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) {
+ markTerminalFailure(current, error, newAttemptCount);
+ } else {
+ ThirdPushDeliveryEntity retry = new ThirdPushDeliveryEntity();
+ retry.setStatus(AppMessageConstants.DELIVERY_RETRY);
+ retry.setAttemptCount(newAttemptCount);
+ retry.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(newAttemptCount)));
+ retry.setLastError(StrUtil.sub(error, 0, 2000));
+ deliveryMapper.update(retry, Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeliveryEntity::getId, current.getId())
+ .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
+ }
+ return true;
+ }
+
+ /**
+ * 保存本轮所有实际供应商调用尝试。
+ *
+ * @param delivery 当前投递任务
+ * @param dispatchResult 本轮调度结果
+ * @return 实际供应商调用次数
+ */
+ private int persistAttempts(ThirdPushDeliveryEntity delivery, PushDispatchResult dispatchResult) {
+ int attemptNo = delivery.getAttemptCount();
+ for (PushDispatchAttempt dispatchAttempt : dispatchResult.getAttempts()) {
+ attemptNo++;
+ ThirdPushDeliveryAttemptEntity entity = new ThirdPushDeliveryAttemptEntity();
+ entity.setTenantId(delivery.getTenantId());
+ entity.setDeliveryId(delivery.getId());
+ entity.setAttemptNo(attemptNo);
+ entity.setProviderCode(dispatchAttempt.getProviderCode());
+ entity.setResultStatus(dispatchAttempt.getResult().getStatus().name());
+ entity.setProviderMessageId(dispatchAttempt.getResult().getProviderMessageId());
+ entity.setErrorCode(dispatchAttempt.getResult().getErrorCode());
+ entity.setErrorMessage(StrUtil.sub(dispatchAttempt.getResult().getErrorMessage(), 0, 2000));
+ entity.setStartedTime(dispatchAttempt.getStartedTime());
+ entity.setFinishedTime(dispatchAttempt.getFinishedTime());
+ attemptMapper.insert(entity);
+ }
+ return dispatchResult.getAttempts().size();
+ }
+
+ /**
+ * 将任务标记为达到尝试上限后的失败终态。
+ *
+ * @param delivery 当前投递任务
+ * @param error 脱敏错误摘要
+ * @param attemptCount 最终尝试次数
+ */
+ private void markTerminalFailure(ThirdPushDeliveryEntity delivery, String error, int attemptCount) {
+ ThirdPushDeliveryEntity failed = new ThirdPushDeliveryEntity();
+ failed.setStatus(AppMessageConstants.DELIVERY_FAILED);
+ failed.setAttemptCount(attemptCount);
+ failed.setNextRetryTime(null);
+ failed.setLastError(StrUtil.sub(error, 0, 2000));
+ deliveryMapper.update(failed, Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeliveryEntity::getId, delivery.getId())
+ .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
+ }
+
+ /**
+ * 根据累计尝试次数取得下一次退避分钟数。
+ *
+ * @param attemptCount 已累计供应商调用次数
+ * @return 下一次重试间隔分钟数
+ */
+ private long retryMinutes(int attemptCount) {
+ int index = Math.max(0, Math.min(attemptCount - 1, AppMessageConstants.RETRY_INTERVAL_MINUTES.size() - 1));
+ return AppMessageConstants.RETRY_INTERVAL_MINUTES.get(index);
+ }
+
+ /**
+ * 将投递实体转换为PC安全展示模型。
+ *
+ * @param entity 投递实体
+ * @return PC展示模型
+ */
+ private AppPushDeliveryVo toDeliveryVo(ThirdPushDeliveryEntity entity) {
+ AppPushDeliveryVo vo = new AppPushDeliveryVo();
+ BeanUtils.copyProperties(entity, vo);
+ return vo;
+ }
+
+ /**
+ * 将尝试实体转换为PC安全展示模型。
+ *
+ * @param entity 尝试日志实体
+ * @return PC展示模型
+ */
+ private AppPushDeliveryAttemptVo toAttemptVo(ThirdPushDeliveryAttemptEntity entity) {
+ AppPushDeliveryAttemptVo vo = new AppPushDeliveryAttemptVo();
+ BeanUtils.copyProperties(entity, vo);
+ return vo;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeviceBindingServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeviceBindingServiceImpl.java
new file mode 100644
index 00000000..fa2ef6a8
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeviceBindingServiceImpl.java
@@ -0,0 +1,173 @@
+package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.PushDeviceBindingDto;
+import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeviceBindingEntity;
+import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdPushDeviceBindingMapper;
+import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService;
+import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeviceBindingService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Locale;
+
+/**
+ * 通用App设备绑定实现,首期把JPUSH绑定同步到既有极光alias服务。
+ */
+@Service
+@RequiredArgsConstructor
+public class ThirdPushDeviceBindingServiceImpl implements ThirdPushDeviceBindingService {
+
+ /** 通用设备绑定Mapper。 */
+ private final ThirdPushDeviceBindingMapper bindingMapper;
+
+ /** 既有极光alias设备管理服务。 */
+ private final ThirdJgDeviceService thirdJgDeviceService;
+
+ /**
+ * 绑定当前用户设备;同一供应商令牌在账号切换时迁移到新用户,不复制令牌记录。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 通用设备绑定参数
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void bind(Long userId, PushDeviceBindingDto dto) {
+ String providerCode = normalizeProvider(dto.getProviderCode());
+ String platform = normalizePlatform(dto.getPlatform());
+ Long tenantId = requireTenantId();
+ ThirdPushDeviceBindingEntity tokenBinding = bindingMapper.selectByProviderToken(
+ providerCode, dto.getAppCode(), dto.getProviderRegistrationId());
+ ThirdPushDeviceBindingEntity deviceBinding = bindingMapper.selectOne(
+ Wrappers.lambdaQuery()
+ .eq(ThirdPushDeviceBindingEntity::getUserId, userId)
+ .eq(ThirdPushDeviceBindingEntity::getClientDeviceId, dto.getClientDeviceId())
+ .eq(ThirdPushDeviceBindingEntity::getProviderCode, providerCode)
+ .last("limit 1"));
+ ThirdPushDeviceBindingEntity binding = deviceBinding == null ? tokenBinding : deviceBinding;
+ if (tokenBinding != null && deviceBinding != null && !tokenBinding.getId().equals(deviceBinding.getId())) {
+ bindingMapper.physicalDeleteById(tokenBinding.getId());
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (binding == null) {
+ binding = new ThirdPushDeviceBindingEntity();
+ binding.setTenantId(tenantId);
+ binding.setUserId(userId);
+ binding.setClientDeviceId(dto.getClientDeviceId());
+ binding.setProviderCode(providerCode);
+ binding.setProviderRegistrationId(dto.getProviderRegistrationId());
+ binding.setAppCode(dto.getAppCode());
+ binding.setPlatform(platform);
+ binding.setEnabled(AppMessageConstants.ENABLED);
+ binding.setLastActiveTime(now);
+ bindingMapper.insert(binding);
+ } else {
+ int updated = bindingMapper.migrateBinding(binding.getId(), tenantId, userId, dto.getClientDeviceId(),
+ providerCode, dto.getProviderRegistrationId(), dto.getAppCode(), platform, now);
+ if (updated != 1) {
+ throw new IllegalStateException("更新Push设备绑定失败");
+ }
+ }
+ if (AppMessageConstants.PROVIDER_JPUSH.equals(providerCode)) {
+ thirdJgDeviceService.bind(userId, dto.getProviderRegistrationId());
+ }
+ }
+
+ /**
+ * 精确解绑当前用户设备,同时解除既有极光alias关系。
+ *
+ * @param userId 当前登录用户ID
+ * @param dto 通用设备绑定参数
+ */
+ @Override
+ public void unbind(Long userId, PushDeviceBindingDto dto) {
+ String providerCode = normalizeProvider(dto.getProviderCode());
+ if (AppMessageConstants.PROVIDER_JPUSH.equals(providerCode)) {
+ thirdJgDeviceService.unbind(userId, dto.getProviderRegistrationId());
+ }
+ ThirdPushDeviceBindingEntity update = new ThirdPushDeviceBindingEntity();
+ update.setEnabled(AppMessageConstants.DISABLED);
+ update.setUnbindTime(LocalDateTime.now());
+ bindingMapper.update(update, Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeviceBindingEntity::getUserId, userId)
+ .eq(ThirdPushDeviceBindingEntity::getProviderCode, providerCode)
+ .eq(ThirdPushDeviceBindingEntity::getAppCode, dto.getAppCode())
+ .eq(ThirdPushDeviceBindingEntity::getProviderRegistrationId, dto.getProviderRegistrationId()));
+ }
+
+ /**
+ * 判断用户是否存在有效设备绑定。
+ *
+ * @param userId 用户ID
+ * @return 是否存在有效绑定
+ */
+ @Override
+ public boolean hasActiveBinding(Long userId) {
+ return bindingMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(ThirdPushDeviceBindingEntity::getUserId, userId)
+ .eq(ThirdPushDeviceBindingEntity::getEnabled, AppMessageConstants.ENABLED)) > 0;
+ }
+
+ /**
+ * 停用供应商明确判定失效的用户设备绑定。
+ *
+ * @param userId 用户ID
+ * @param providerCode 供应商编码
+ * @return 停用数量
+ */
+ @Override
+ public int disableInvalidBindings(Long userId, String providerCode) {
+ ThirdPushDeviceBindingEntity update = new ThirdPushDeviceBindingEntity();
+ update.setEnabled(AppMessageConstants.DISABLED);
+ update.setUnbindTime(LocalDateTime.now());
+ return bindingMapper.update(update, Wrappers.lambdaUpdate()
+ .eq(ThirdPushDeviceBindingEntity::getUserId, userId)
+ .eq(ThirdPushDeviceBindingEntity::getProviderCode, providerCode)
+ .eq(ThirdPushDeviceBindingEntity::getEnabled, AppMessageConstants.ENABLED));
+ }
+
+ /**
+ * 规范化并校验供应商编码,首期只接受JPUSH。
+ *
+ * @param providerCode 客户端供应商编码
+ * @return 大写稳定编码
+ */
+ private String normalizeProvider(String providerCode) {
+ String normalized = providerCode.toUpperCase(Locale.ROOT);
+ if (!AppMessageConstants.PROVIDER_JPUSH.equals(normalized)) {
+ throw new IllegalArgumentException("暂不支持该推送供应商");
+ }
+ return normalized;
+ }
+
+ /**
+ * 规范化并校验App平台,H5不允许建立原生Push绑定。
+ *
+ * @param platform 客户端平台
+ * @return ANDROID或IOS
+ */
+ private String normalizePlatform(String platform) {
+ String normalized = platform.toUpperCase(Locale.ROOT);
+ if (!"ANDROID".equals(normalized) && !"IOS".equals(normalized) && !"APP".equals(normalized)) {
+ throw new IllegalArgumentException("推送设备平台必须为ANDROID或IOS");
+ }
+ return normalized;
+ }
+
+ /**
+ * 获取当前租户ID,缺少租户上下文时拒绝建立设备归属。
+ *
+ * @return 当前租户ID
+ */
+ private Long requireTenantId() {
+ Long tenantId = TenantContextHolder.getTenantId();
+ if (tenantId == null) {
+ throw new IllegalStateException("缺少租户上下文");
+ }
+ return tenantId;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdThirdJgPushServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdThirdJgPushServiceImpl.java
index b150e402..d6b63349 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdThirdJgPushServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdThirdJgPushServiceImpl.java
@@ -28,12 +28,20 @@ import java.util.Map;
@RequiredArgsConstructor
public class ThirdThirdJgPushServiceImpl implements ThirdJgPushService {
- private final String TAG = "极光推送";
+ /** 极光推送日志标签。 */
+ private static final String TAG = "极光推送";
+ /** 极光服务端Push API。 */
private final PushApi pushApi;
+ /** 极光应用和通知图标配置。 */
private final JgConfig jgConfig;
+ /**
+ * 按用户alias或标签发送通知栏消息,并透传业务消息内部路由字段。
+ *
+ * @param jgPusDto 极光通知栏发送参数
+ */
@Override
public void push(JgPusDto jgPusDto) {
PushSendParam param = new PushSendParam();
@@ -45,6 +53,9 @@ public class ThirdThirdJgPushServiceImpl implements ThirdJgPushService {
android.setTitle(jgPusDto.getSubtitle());
android.setLargeIcon(jgPush.getLargeIcon());
android.setSmallIcon(jgPush.getSmallIconUri());
+ if (ObjectUtil.isNotEmpty(jgPusDto.getExtras())) {
+ android.setExtras(new HashMap<>(jgPusDto.getExtras()));
+ }
// end 通知内容 android end
// start 通知内容 ios start
@@ -55,6 +66,9 @@ public class ThirdThirdJgPushServiceImpl implements ThirdJgPushService {
// 通知级别
iOSAlert.put("interruption-level", "active");
iOS.setAlert(iOSAlert);
+ if (ObjectUtil.isNotEmpty(jgPusDto.getExtras())) {
+ iOS.setExtras(new HashMap<>(jgPusDto.getExtras()));
+ }
// end 通知内容 ios end
NotificationMessage notificationMessage = new NotificationMessage();
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImpl.java
index cd5d5dec..1608828e 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImpl.java
@@ -8,10 +8,11 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.constant.push.ThirdPushBizTypeEnum;
-import com.bsd.pjcloud.thirdparty.dto.jg.JgPusDto;
import com.bsd.pjcloud.thirdparty.entity.ThirdPushConfEntity;
import com.bsd.pjcloud.thirdparty.entity.ThirdPushRecordEntity;
-import com.bsd.pjcloud.thirdparty.service.ThirdJgPushService;
+import com.bsd.pjcloud.thirdparty.provider.PushDispatchResult;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderDispatcher;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderRequest;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushConfService;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushExecuteService;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushRecordService;
@@ -33,20 +34,45 @@ import java.util.List;
@RequiredArgsConstructor
public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
+ /** Quartz任务日志使用的成功状态编码,属于既有任务执行契约。 */
private static final String JOB_LOG_STATUS_SUCCESS = "0";
+
+ /** Quartz任务日志使用的失败状态编码,属于既有任务执行契约。 */
private static final String JOB_LOG_STATUS_FAIL = "1";
+
+ /** 单次供应商请求最大用户数,避免定时广播请求体过大。 */
private static final int PUSH_BATCH_SIZE = 500;
+
+ /** 单个广播分组的最大调用次数,沿用旧广播的即时重试行为。 */
private static final int PUSH_MAX_RETRY_TIMES = 3;
+
+ /** 广播分组失败后的固定等待时间,单位毫秒。 */
private static final long PUSH_RETRY_INTERVAL_MILLIS = 1000L;
+ /** 定时广播配置服务。 */
private final ThirdPushConfService thirdPushConfService;
- private final ThirdPushRecordService thirdPushRecordService;
- private final ThirdPushRecordUserService thirdPushRecordUserService;
- private final ThirdJgPushService thirdJgPushService;
- private final RemoteUserService remoteUserService;
- private final RemoteMchService remoteMchService;
- ;
+ /** 定时广播批次记录服务。 */
+ private final ThirdPushRecordService thirdPushRecordService;
+
+ /** 定时广播接收用户明细服务。 */
+ private final ThirdPushRecordUserService thirdPushRecordUserService;
+
+ /** 供应商无关的Push调度器,极光仍为首期主供应商。 */
+ private final PushProviderDispatcher providerDispatcher;
+
+ /** UPMS用户内部查询客户端,用于将目标部门解析为用户ID。 */
+ private final RemoteUserService remoteUserService;
+
+ /** Wallet商户内部查询客户端,用于将商户解析为目标部门。 */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 按配置ID执行一次既有定时广播并记录批次结果。
+ *
+ * @param confId Quartz传入的配置ID字符串
+ * @return 既有任务日志状态编码,0成功、1失败
+ */
@Override
public String executeByConfId(String confId) {
Long pushConfId = parseConfId(confId);
@@ -98,7 +124,10 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 查询需要推送的部门标签
+ * 查询需要推送的部门标签。
+ *
+ * @param conf 商户定时广播配置
+ * @return 去重后的目标部门ID,无法解析时返回空列表
*/
private List listTargetDeptIds(ThirdPushConfEntity conf) {
if (ObjectUtil.equals(conf.getBizType(), ThirdPushBizTypeEnum.MCH_PUSH.getType())) {
@@ -116,7 +145,10 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 根据部门解析实际推送用户
+ * 根据部门解析实际推送用户。
+ *
+ * @param deptIds 目标部门ID
+ * @return 去重后的有效用户ID,远程查询失败时返回空列表
*/
private List listTargetUserIds(List deptIds) {
if (CollUtil.isEmpty(deptIds)) {
@@ -134,7 +166,12 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 按用户ID分组分批推送
+ * 按用户ID分组分批推送。
+ *
+ * @param pushConfId 推送配置ID
+ * @param batchNo 当前执行批次号
+ * @param conf 定时广播配置
+ * @param targetUserIds 全部目标用户ID
*/
private void pushByUserIdsInBatches(Long pushConfId, String batchNo, ThirdPushConfEntity conf, List targetUserIds) {
List> userIdGroups = splitList(targetUserIds, PUSH_BATCH_SIZE);
@@ -157,20 +194,30 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 推送单个分组,失败后自动重试
+ * 推送单个分组,失败后自动重试。
+ *
+ * @param pushConfId 推送配置ID
+ * @param batchNo 当前执行批次号
+ * @param conf 定时广播配置
+ * @param userIdGroup 当前分组用户ID
+ * @param groupNo 当前分组序号,从1开始
+ * @param totalGroupCount 总分组数量
*/
private void pushSingleGroupWithRetry(Long pushConfId, String batchNo, ThirdPushConfEntity conf, List userIdGroup,
int groupNo, int totalGroupCount) {
for (int attempt = 1; attempt <= PUSH_MAX_RETRY_TIMES; attempt++) {
try {
- JgPusDto dto = new JgPusDto();
- dto.setTitle(conf.getTitle());
- dto.setSubtitle(conf.getContent());
- dto.setUserIds(userIdGroup);
+ PushProviderRequest request = new PushProviderRequest();
+ request.setTitle(conf.getTitle());
+ request.setContent(conf.getContent());
+ request.setUserIds(userIdGroup);
log.info("开始推送分组 confId={} batchNo={} groupNo={}/{} groupDeviceCount={} attempt={}",
pushConfId, batchNo, groupNo, totalGroupCount, userIdGroup.size(), attempt);
- thirdJgPushService.push(dto);
+ PushDispatchResult result = providerDispatcher.dispatch(request);
+ if (!result.isAccepted()) {
+ throw new IllegalStateException("当前没有Push供应商受理定时广播");
+ }
log.info("推送分组成功 confId={} batchNo={} groupNo={}/{} groupDeviceCount={} attempt={}",
pushConfId, batchNo, groupNo, totalGroupCount, userIdGroup.size(), attempt);
return;
@@ -186,7 +233,11 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 列表按固定大小分片
+ * 列表按固定大小分片。
+ *
+ * @param sourceList 原始用户ID列表
+ * @param batchSize 单片最大数量
+ * @return 保持原顺序的列表分片
*/
private List> splitList(List sourceList, int batchSize) {
if (CollUtil.isEmpty(sourceList) || batchSize <= 0) {
@@ -201,7 +252,7 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
}
/**
- * 分组失败后的重试间隔
+ * 等待一次分组重试间隔;中断时恢复线程中断标记并终止本次广播。
*/
protected void sleepBeforeRetry() {
try {
@@ -211,8 +262,12 @@ public class ThirdPushExecuteServiceImpl implements ThirdPushExecuteService {
throw new RuntimeException("分组推送重试等待被中断", e);
}
}
-
-
+ /**
+ * 解析Quartz传入的配置ID。
+ *
+ * @param confId 配置ID字符串
+ * @return 合法Long值,解析失败时返回null
+ */
private Long parseConfId(String confId) {
try {
return Long.valueOf(confId);
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImplTest.java
index e0c260ca..93dc7798 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImplTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/push/ThirdPushExecuteServiceImplTest.java
@@ -5,10 +5,11 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.constant.push.ThirdPushBizTypeEnum;
-import com.bsd.pjcloud.thirdparty.dto.jg.JgPusDto;
import com.bsd.pjcloud.thirdparty.entity.ThirdPushConfEntity;
import com.bsd.pjcloud.thirdparty.entity.ThirdPushRecordEntity;
-import com.bsd.pjcloud.thirdparty.service.ThirdJgPushService;
+import com.bsd.pjcloud.thirdparty.provider.PushDispatchResult;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderDispatcher;
+import com.bsd.pjcloud.thirdparty.provider.PushProviderRequest;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushConfService;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushRecordService;
import com.bsd.pjcloud.thirdparty.service.push.ThirdPushRecordUserService;
@@ -16,7 +17,6 @@ import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -47,7 +47,7 @@ class ThirdPushExecuteServiceImplTest {
private ThirdPushRecordUserService thirdPushRecordUserService;
@Mock
- private ThirdJgPushService thirdJgPushService;
+ private PushProviderDispatcher providerDispatcher;
@Mock
private RemoteUserService remoteUserService;
@@ -58,11 +58,10 @@ class ThirdPushExecuteServiceImplTest {
@Test
void executeByConfIdShouldPushByUserIdsAndKeepDeptLogicAsFallbackSource() {
ThirdPushExecuteServiceImpl thirdPushExecuteService = newTestService();
- List pushDtos = executeAndCapturePushDtos(thirdPushExecuteService, 2, 0);
+ List pushRequests = executeAndCapturePushRequests(thirdPushExecuteService, 2, 0);
- Assertions.assertEquals(1, pushDtos.size());
- Assertions.assertEquals(List.of(1L, 2L), pushDtos.get(0).getUserIds());
- Assertions.assertTrue(pushDtos.get(0).getTagList() == null || pushDtos.get(0).getTagList().isEmpty());
+ Assertions.assertEquals(1, pushRequests.size());
+ Assertions.assertEquals(List.of(1L, 2L), pushRequests.get(0).getUserIds());
verify(thirdPushRecordUserService).saveUsersByUserIds(anyLong(), anyLong(), anyString(), anyList(), anyString(), anyString(), anyString());
verify(thirdPushRecordUserService, never()).saveUsers(anyLong(), anyLong(), anyString(), anyList(), anyString(), anyString(), anyString());
}
@@ -70,38 +69,38 @@ class ThirdPushExecuteServiceImplTest {
@Test
void executeByConfIdShouldSplitHundredUsersIntoOneGroup() {
ThirdPushExecuteServiceImpl thirdPushExecuteService = newTestService();
- List pushDtos = executeAndCapturePushDtos(thirdPushExecuteService, 100, 0);
+ List pushRequests = executeAndCapturePushRequests(thirdPushExecuteService, 100, 0);
- Assertions.assertEquals(1, pushDtos.size());
- Assertions.assertEquals(100, pushDtos.get(0).getUserIds().size());
+ Assertions.assertEquals(1, pushRequests.size());
+ Assertions.assertEquals(100, pushRequests.get(0).getUserIds().size());
}
@Test
void executeByConfIdShouldSplitThousandUsersIntoTwoGroups() {
ThirdPushExecuteServiceImpl thirdPushExecuteService = newTestService();
- List pushDtos = executeAndCapturePushDtos(thirdPushExecuteService, 1000, 0);
+ List pushRequests = executeAndCapturePushRequests(thirdPushExecuteService, 1000, 0);
- Assertions.assertEquals(2, pushDtos.size());
- Assertions.assertTrue(pushDtos.stream().allMatch(item -> item.getUserIds().size() <= 500));
- Assertions.assertEquals(1000, pushDtos.stream().mapToInt(item -> item.getUserIds().size()).sum());
+ Assertions.assertEquals(2, pushRequests.size());
+ Assertions.assertTrue(pushRequests.stream().allMatch(item -> item.getUserIds().size() <= 500));
+ Assertions.assertEquals(1000, pushRequests.stream().mapToInt(item -> item.getUserIds().size()).sum());
}
@Test
void executeByConfIdShouldSplitTenThousandUsersIntoTwentyGroups() {
ThirdPushExecuteServiceImpl thirdPushExecuteService = newTestService();
- List pushDtos = executeAndCapturePushDtos(thirdPushExecuteService, 10000, 0);
+ List pushRequests = executeAndCapturePushRequests(thirdPushExecuteService, 10000, 0);
- Assertions.assertEquals(20, pushDtos.size());
- Assertions.assertTrue(pushDtos.stream().allMatch(item -> item.getUserIds().size() <= 500));
- Assertions.assertEquals(10000, pushDtos.stream().mapToInt(item -> item.getUserIds().size()).sum());
+ Assertions.assertEquals(20, pushRequests.size());
+ Assertions.assertTrue(pushRequests.stream().allMatch(item -> item.getUserIds().size() <= 500));
+ Assertions.assertEquals(10000, pushRequests.stream().mapToInt(item -> item.getUserIds().size()).sum());
}
@Test
void executeByConfIdShouldRetryFailedGroupAndThenSucceed() {
ThirdPushExecuteServiceImpl thirdPushExecuteService = newTestService();
- List pushDtos = executeAndCapturePushDtos(thirdPushExecuteService, 1000, 2);
+ List pushRequests = executeAndCapturePushRequests(thirdPushExecuteService, 1000, 2);
- Assertions.assertEquals(2, pushDtos.size());
+ Assertions.assertEquals(2, pushRequests.size());
}
private ThirdPushExecuteServiceImpl newTestService() {
@@ -109,7 +108,7 @@ class ThirdPushExecuteServiceImplTest {
thirdPushConfService,
thirdPushRecordService,
thirdPushRecordUserService,
- thirdJgPushService,
+ providerDispatcher,
remoteUserService,
remoteMchService
) {
@@ -120,7 +119,8 @@ class ThirdPushExecuteServiceImplTest {
};
}
- private List executeAndCapturePushDtos(ThirdPushExecuteServiceImpl thirdPushExecuteService, int userCount, int failTimes) {
+ private List executeAndCapturePushRequests(ThirdPushExecuteServiceImpl thirdPushExecuteService,
+ int userCount, int failTimes) {
ThirdPushConfEntity conf = new ThirdPushConfEntity();
conf.setId(10L);
conf.setTitle("测试标题");
@@ -141,22 +141,25 @@ class ThirdPushExecuteServiceImplTest {
when(thirdPushRecordService.markBatchSuccess(99L, userCount, 0, "推送成功")).thenReturn(true);
when(thirdPushRecordUserService.updateStatusByRecordId(99L, "SUCCESS", null)).thenReturn(true);
- List pushDtos = new ArrayList<>();
+ List pushRequests = new ArrayList<>();
AtomicInteger counter = new AtomicInteger();
doAnswer(invocation -> {
- JgPusDto dto = invocation.getArgument(0);
+ PushProviderRequest request = invocation.getArgument(0);
int current = counter.getAndIncrement();
+ PushDispatchResult result = new PushDispatchResult();
if (current < failTimes) {
- throw new RuntimeException("模拟推送失败");
+ return result;
}
- pushDtos.add(dto);
- return null;
- }).when(thirdJgPushService).push(any(JgPusDto.class));
+ result.setAccepted(true);
+ result.setProviderCode("TEST");
+ pushRequests.add(request);
+ return result;
+ }).when(providerDispatcher).dispatch(any(PushProviderRequest.class));
String result = thirdPushExecuteService.executeByConfId("10");
Assertions.assertEquals("0", result);
- return pushDtos;
+ return pushRequests;
}
private List buildUsers(int count) {
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationRecipientQueryDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationRecipientQueryDto.java
new file mode 100644
index 00000000..dbbf16f9
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationRecipientQueryDto.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.admin.api.dto.user;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 业务消息按事件主体反向查询有效关系接收人的内部参数。
+ */
+@Data
+public class SysUserRelationRecipientQueryDto implements Serializable {
+
+ /** 事件主体用户ID,例如发生用餐或钱包变化的被监护人。 */
+ @NotNull(message = "关系事件主体用户ID不能为空")
+ private Long subjectUserId;
+
+ /** 商户策略允许的关系类型编码集合。 */
+ @NotEmpty(message = "允许的关系类型不能为空")
+ private List relationCodes;
+
+ /** 业务事件发生时间,关系必须在该时间点已生效且未过期。 */
+ @NotNull(message = "关系事件发生时间不能为空")
+ private LocalDateTime occurredTime;
+
+ /** 关系消息实际生成时间,关系在处理延迟期间过期时禁止继续扩散。 */
+ @NotNull(message = "关系消息处理时间不能为空")
+ private LocalDateTime processingTime;
+}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java
index 50cddcdb..a68020b9 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java
@@ -3,8 +3,12 @@ package com.bsd.pjcloud.admin.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationRecipientQueryDto;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@@ -15,6 +19,26 @@ import java.util.List;
@FeignClient(contextId = "remoteUserRelationService", value = ServiceNameConstants.UPMS_SERVICE)
public interface RemoteUserRelationService {
+ /**
+ * 按事件主体和允许关系类型反向查询在事件时间与处理时间都有效的关系接收人。
+ *
+ * @param dto 事件主体、关系类型和双时间点约束
+ * @return 有效关系接收人及关系快照
+ */
+ @NoToken
+ @PostMapping("/sysUserRelation/inner/active-recipients")
+ R> listActiveRecipients(@RequestBody SysUserRelationRecipientQueryDto dto);
+
+ /**
+ * 查询指定监护人当前有效的完整关系记录,用于App配置按关系ID保存订阅。
+ *
+ * @param guardianUserId 当前监护人用户ID
+ * @return 当前有效关系及被监护人展示快照
+ */
+ @NoToken
+ @GetMapping("/sysUserRelation/inner/guardian-ward/relations")
+ R> listActiveGuardianWardRelations(@RequestParam("guardianUserId") Long guardianUserId);
+
/**
* 查询指定监护人当前有效的被监护人用户ID。
*
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationRecipientVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationRecipientVo.java
new file mode 100644
index 00000000..e09e4d14
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationRecipientVo.java
@@ -0,0 +1,37 @@
+package com.bsd.pjcloud.admin.api.vo.user;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 有效关系接收人及关系快照。
+ */
+@Data
+public class SysUserRelationRecipientVo implements Serializable {
+
+ /** UPMS具体关系记录ID,订阅绑定该ID以避免新关系继承旧授权。 */
+ private Long relationId;
+
+ /** 关系主语用户ID,监护关系中为监护人。 */
+ private Long recipientUserId;
+
+ /** 关系对象用户ID,监护关系中为被监护人。 */
+ private Long subjectUserId;
+
+ /** 关系对象用户姓名快照。 */
+ private String subjectUserName;
+
+ /** 关系类型稳定编码。 */
+ private String relationCode;
+
+ /** 关系类型展示名称。 */
+ private String relationName;
+
+ /** 关系生效时间;为空表示创建后立即生效。 */
+ private LocalDateTime effectiveTime;
+
+ /** 关系失效时间;为空表示未设置失效时间。 */
+ private LocalDateTime expireTime;
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java
index 1ac15b4b..4464d577 100755
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java
@@ -5,10 +5,12 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationRecipientQueryDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationUpdateDto;
import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity;
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.common.core.util.R;
@@ -39,6 +41,30 @@ public class SysUserRelationController {
private final SysUserRelationService sysUserRelationService;
+ /**
+ * 内部反向查询在事件时间和处理时间都有效的关系消息接收人。
+ *
+ * @param dto 事件主体、允许关系编码和双时间点约束
+ * @return 有效关系接收人及关系快照
+ */
+ @Inner
+ @PostMapping("/inner/active-recipients")
+ public R> listActiveRecipients(@Valid @RequestBody SysUserRelationRecipientQueryDto dto) {
+ return R.ok(sysUserRelationService.listActiveRecipients(dto));
+ }
+
+ /**
+ * 内部查询指定关系主语当前有效的关系记录,供App订阅设置使用。
+ *
+ * @param guardianUserId 关系主语用户ID
+ * @return 当前有效关系及关系对象快照
+ */
+ @Inner
+ @GetMapping("/inner/guardian-ward/relations")
+ public R> listActiveGuardianWardRelations(@RequestParam Long guardianUserId) {
+ return R.ok(sysUserRelationService.listActiveGuardianWardRelations(guardianUserId));
+ }
+
/**
* 查询当前用户可代办业务的有效被监护人。
*
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java
index b347d3f8..56c7009d 100755
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java
@@ -3,15 +3,33 @@ package com.bsd.pjcloud.admin.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationRecipientQueryDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationUpdateDto;
import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity;
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
import java.util.List;
public interface SysUserRelationService extends IService {
+ /**
+ * 按事件主体反向查询在事件发生和消息处理两个时间点均有效的关系接收人。
+ *
+ * @param dto 事件主体、允许关系编码和双时间点约束
+ * @return 以关系主语为接收人的有效关系快照,按关系ID稳定排序
+ */
+ List listActiveRecipients(SysUserRelationRecipientQueryDto dto);
+
+ /**
+ * 查询指定关系主语当前有效的关系记录,供App按具体关系ID配置订阅。
+ *
+ * @param guardianUserId 当前登录的关系主语用户ID
+ * @return 当前有效关系及关系对象展示快照,按关系ID稳定排序
+ */
+ List listActiveGuardianWardRelations(Long guardianUserId);
+
/**
* 查询用户关系图谱
*
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java
index 2254d0b5..ae7447f7 100755
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationRecipientQueryDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationUpdateDto;
import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
import com.bsd.pjcloud.admin.api.entity.SysUser;
@@ -14,6 +15,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity;
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo;
+import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo;
import com.bsd.pjcloud.admin.mapper.user.SysUserRelationMapper;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
@@ -57,6 +59,123 @@ public class SysUserRelationServiceImpl extends ServiceImpl业务消息只沿有向关系的“主语 -> 对象”反向通知主语,例如被监护人发生事件时通知监护人;
+ * 不对无向关系自动双向扩散,避免扩大消息授权范围。
+ *
+ * @param dto 事件主体、允许关系编码和双时间点约束
+ * @return 有效关系接收人快照
+ */
+ @Override
+ public List listActiveRecipients(SysUserRelationRecipientQueryDto dto) {
+ Map relationTypeMap = listActiveRelationTypes(dto.getRelationCodes());
+ if (relationTypeMap.isEmpty()) {
+ return List.of();
+ }
+ List relations = list(Wrappers.lambdaQuery()
+ .eq(SysUserRelationEntity::getObjectUserId, dto.getSubjectUserId())
+ .in(SysUserRelationEntity::getRelationTypeId, relationTypeMap.keySet())
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getEffectiveTime)
+ .or().le(SysUserRelationEntity::getEffectiveTime, dto.getOccurredTime()))
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getExpireTime)
+ .or().ge(SysUserRelationEntity::getExpireTime, dto.getOccurredTime()))
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getEffectiveTime)
+ .or().le(SysUserRelationEntity::getEffectiveTime, dto.getProcessingTime()))
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getExpireTime)
+ .or().ge(SysUserRelationEntity::getExpireTime, dto.getProcessingTime()))
+ .orderByAsc(SysUserRelationEntity::getRelationId));
+ if (relations.isEmpty()) {
+ return List.of();
+ }
+ SysUser subjectUser = sysUserService.getById(dto.getSubjectUserId());
+ String subjectUserName = subjectUser == null ? null : subjectUser.getName();
+ return relations.stream()
+ .map(relation -> buildRelationRecipient(relation, relationTypeMap.get(relation.getRelationTypeId()), subjectUserName))
+ .toList();
+ }
+
+ /**
+ * 查询关系主语当前可配置的有效关系记录。
+ *
+ * @param guardianUserId 当前登录的关系主语用户ID
+ * @return 当前有效关系及关系对象展示快照
+ */
+ @Override
+ public List listActiveGuardianWardRelations(Long guardianUserId) {
+ java.time.LocalDateTime now = java.time.LocalDateTime.now();
+ List relations = list(Wrappers.lambdaQuery()
+ .eq(SysUserRelationEntity::getSubjectUserId, guardianUserId)
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getEffectiveTime)
+ .or().le(SysUserRelationEntity::getEffectiveTime, now))
+ .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getExpireTime)
+ .or().ge(SysUserRelationEntity::getExpireTime, now))
+ .orderByAsc(SysUserRelationEntity::getRelationId));
+ if (relations.isEmpty()) {
+ return List.of();
+ }
+ Set relationTypeIds = relations.stream().map(SysUserRelationEntity::getRelationTypeId)
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ Map relationTypeMap = relationTypeIds.isEmpty() ? Map.of()
+ : sysUserRelationTypeService.list(Wrappers.lambdaQuery()
+ .in(SysUserRelationTypeEntity::getRelationTypeId, relationTypeIds)
+ .eq(SysUserRelationTypeEntity::getStatus, CommonConstants.STATUS_NORMAL))
+ .stream().collect(Collectors.toMap(SysUserRelationTypeEntity::getRelationTypeId, Function.identity()));
+ Set objectUserIds = relations.stream().map(SysUserRelationEntity::getObjectUserId)
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ Map objectUserMap = objectUserIds.isEmpty() ? Map.of()
+ : sysUserService.listByIds(objectUserIds).stream()
+ .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first));
+ return relations.stream()
+ .filter(relation -> relationTypeMap.containsKey(relation.getRelationTypeId()))
+ .map(relation -> {
+ SysUser objectUser = objectUserMap.get(relation.getObjectUserId());
+ String objectUserName = objectUser == null ? null : objectUser.getName();
+ return buildRelationRecipient(relation, relationTypeMap.get(relation.getRelationTypeId()), objectUserName);
+ })
+ .toList();
+ }
+
+ /**
+ * 查询启用且编码在允许范围内的关系类型。
+ *
+ * @param relationCodes 商户策略允许的稳定关系编码
+ * @return 以关系类型ID为键的启用关系类型
+ */
+ private Map listActiveRelationTypes(List relationCodes) {
+ if (CollUtil.isEmpty(relationCodes)) {
+ return Map.of();
+ }
+ return sysUserRelationTypeService.list(Wrappers.lambdaQuery()
+ .in(SysUserRelationTypeEntity::getRelationCode, relationCodes)
+ .eq(SysUserRelationTypeEntity::getStatus, CommonConstants.STATUS_NORMAL))
+ .stream().collect(Collectors.toMap(SysUserRelationTypeEntity::getRelationTypeId, Function.identity()));
+ }
+
+ /**
+ * 将关系记录转换为不包含账号、手机号等敏感字段的消息关系快照。
+ *
+ * @param relation 具体关系记录
+ * @param relationType 启用的关系类型
+ * @param subjectUserName 关系对象姓名快照
+ * @return 消息中心可使用的关系接收人
+ */
+ private SysUserRelationRecipientVo buildRelationRecipient(SysUserRelationEntity relation,
+ SysUserRelationTypeEntity relationType,
+ String subjectUserName) {
+ SysUserRelationRecipientVo recipient = new SysUserRelationRecipientVo();
+ recipient.setRelationId(relation.getRelationId());
+ recipient.setRecipientUserId(relation.getSubjectUserId());
+ recipient.setSubjectUserId(relation.getObjectUserId());
+ recipient.setSubjectUserName(subjectUserName);
+ recipient.setRelationCode(relationType.getRelationCode());
+ recipient.setRelationName(relationType.getRelationName());
+ recipient.setEffectiveTime(relation.getEffectiveTime());
+ recipient.setExpireTime(relation.getExpireTime());
+ return recipient;
+ }
+
/**
* 查询入口用户所在的完整关系图谱,并按有向关系选择业务根节点生成树。
*
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java
index 0eb4aeef..4ad36306 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java
@@ -14,6 +14,7 @@ import java.util.Arrays;
public enum WalletTransactionTag {
BJZ_CZ("1", "不记账充值"),
+ CS_CZ("2", "测试充值"),
;
private final String code;
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
index 5faf255e..b4ef5ef6 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
@@ -10,25 +10,33 @@ import lombok.Data;
import java.io.Serializable;
+/**
+ * 钱包余额变动参数;订单调用方可附带实际商户快照,账户级操作不传时由钱包服务按用户当前食堂解析。
+ */
@Data
public class WalletBalanceTransactionDto {
+ /** 发生余额变化的钱包ID。 */
@NotNull(message = "钱包id不能为空")
@JsonProperty("walletId")
private Long walletId;
+ /** 操作金额,单位分,接口调用统一传正数。 */
@NotNull(message = "操作金额不能为空")
@JsonProperty("price")
private Integer price;
+ /** 业务关联ID,订单场景为实际订单ID。 */
@NotEmpty(message = "业务类型关联id不能为空")
@JsonProperty("bizId")
private String bizId;
+ /** 钱包业务类型,持久化编码由WalletBizEnum定义。 */
@NotNull(message = "业务类型不能为空")
@JsonProperty("bizType")
private WalletBizEnum bizType;
+ /** 是否允许透支扣款,批量订单支付固定为false。 */
@NotNull(message = "是否暴力消费")
private Boolean force = false;
@@ -37,4 +45,13 @@ public class WalletBalanceTransactionDto {
*/
private String tags;
+ /** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */
+ private Long sourceMchId;
+
+ /** 订单商户名称快照;为空时钱包服务可按sourceMchId补充。 */
+ private String sourceMchName;
+
+ /** 消息来源场景,订单调用传ORDER,未传时按WALLET处理。 */
+ private String sourceScene;
+
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
new file mode 100644
index 00000000..9d09615c
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
@@ -0,0 +1,107 @@
+package com.bsd.pjcloud.wallet.api.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.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;
+
+/**
+ * 钱包流水业务消息Transactional Outbox,每条新钱包流水保存一行不可变展示快照。
+ */
+@Data
+@TenantTable
+@TableName("wallet_message_outbox")
+public class WalletMessageOutboxEntity implements Serializable {
+
+ /** Outbox主键,由MyBatis-Plus雪花算法生成并参与稳定事件ID。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 对应wallet_transaction流水ID,一条流水只允许一行Outbox。 */
+ private Long walletTransactionId;
+
+ /** 同一业务多钱包归并键,由租户、用户、业务类型、业务ID和来源商户组成。 */
+ private String eventGroupKey;
+
+ /** Thirdparty固定消息事件编码。 */
+ private String eventCode;
+
+ /** 钱包所有人用户ID。 */
+ private Long subjectUserId;
+
+ /** 来源商户ID快照,无法解析时为空。 */
+ private Long sourceMchId;
+
+ /** 来源商户名称快照,无法解析时为空。 */
+ private String sourceMchName;
+
+ /** 来源场景,WALLET账户级变化或ORDER钱包订单。 */
+ private String sourceScene;
+
+ /** WalletBizEnum持久化编码快照。 */
+ private String sourceBizType;
+
+ /** 钱包流水业务ID快照。 */
+ private String sourceBizId;
+
+ /** 发生变化的钱包ID。 */
+ private Long walletId;
+
+ /** 钱包类型名称快照,无法解析时为“钱包”。 */
+ private String walletName;
+
+ /** 本次钱包变动金额,单位分,收入为正、支出为负。 */
+ private Integer amount;
+
+ /** 本次变动完成后的余额,单位分。 */
+ private Integer balance;
+
+ /** 钱包流水实际创建时间。 */
+ 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创建时间,用于2秒归并窗口和保留清理。 */
+ @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-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/WalletApp.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/WalletApp.java
index ae6fa88d..ad641754 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/WalletApp.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/WalletApp.java
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* @author archetype auto
@@ -16,8 +17,15 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnablePjcloudFeignClients
@EnableDiscoveryClient
@EnablePjcloudResourceServer
+@EnableScheduling
@SpringBootApplication
public class WalletApp {
+
+ /**
+ * 启动钱包服务,并启用包括消息Outbox在内的模块定时任务。
+ *
+ * @param args JVM启动参数
+ */
public static void main(String[] args) {
SpringApplication.run(WalletApp.class, args);
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
index 4cfe639c..45b70862 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo;
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
+import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -56,7 +57,8 @@ public class WalletTransactionController {
@Inner
@PostMapping("/add-balance")
public R addWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) {
- WalletTransactionEntity walletTransaction = walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags());
+ WalletTransactionEntity walletTransaction = WalletMessageContextHolder.supplyWith(List.of(dto),
+ () -> walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags()));
if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("增加钱包余额错误");
return R.ok(walletTransaction);
}
@@ -64,7 +66,8 @@ public class WalletTransactionController {
@Inner
@PostMapping("/reduce-balance")
public R reduceWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) {
- WalletTransactionEntity walletTransaction = walletTransactionService.reduceWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce());
+ WalletTransactionEntity walletTransaction = WalletMessageContextHolder.supplyWith(List.of(dto),
+ () -> walletTransactionService.reduceWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce()));
if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("扣减钱包余额");
return R.ok(walletTransaction);
}
@@ -75,7 +78,8 @@ public class WalletTransactionController {
@Inner
@PostMapping("/reduce-balance-idempotent")
public R reduceWalletBalanceIdempotent(@Valid @RequestBody WalletBalanceTransactionDto dto) {
- return R.ok(walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce()));
+ return R.ok(WalletMessageContextHolder.supplyWith(List.of(dto),
+ () -> walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce())));
}
/**
@@ -88,7 +92,8 @@ public class WalletTransactionController {
@PostMapping("/reduce-balance-batch-idempotent")
public R> reduceWalletBalanceBatchIdempotent(
@Valid @RequestBody List dtoList) {
- return R.ok(walletTransactionService.reduceWalletBalanceBatchIdempotent(dtoList));
+ return R.ok(WalletMessageContextHolder.supplyWith(dtoList,
+ () -> walletTransactionService.reduceWalletBalanceBatchIdempotent(dtoList)));
}
/**
@@ -97,7 +102,8 @@ public class WalletTransactionController {
@Inner
@PostMapping("/compensate-payment")
public R compensatePayment(@Valid @RequestBody WalletBalanceTransactionDto dto) {
- return R.ok(walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId()));
+ return R.ok(WalletMessageContextHolder.supplyWith(List.of(dto),
+ () -> walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId())));
}
/**
@@ -109,7 +115,8 @@ public class WalletTransactionController {
@Inner
@PostMapping("/compensate-payment-batch")
public R compensatePaymentBatch(@Valid @RequestBody List dtoList) {
- return R.ok(walletTransactionService.compensatePaymentBatch(dtoList));
+ return R.ok(WalletMessageContextHolder.supplyWith(dtoList,
+ () -> walletTransactionService.compensatePaymentBatch(dtoList)));
}
@Inner
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/WalletMessageOutboxJob.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/WalletMessageOutboxJob.java
new file mode 100644
index 00000000..1ecfe7f7
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/WalletMessageOutboxJob.java
@@ -0,0 +1,49 @@
+package com.bsd.pjcloud.wallet.job;
+
+import com.bsd.pjcloud.wallet.service.WalletMessageOutboxService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 钱包业务消息Outbox调度器,周期触发投递恢复和保留期清理。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WalletMessageOutboxJob {
+
+ /** 钱包Outbox处理服务。 */
+ private final WalletMessageOutboxService 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 20 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-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletMessageOutboxMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletMessageOutboxMapper.java
new file mode 100644
index 00000000..369f3732
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletMessageOutboxMapper.java
@@ -0,0 +1,39 @@
+package com.bsd.pjcloud.wallet.mapper;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.wallet.api.entity.WalletMessageOutboxEntity;
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+/**
+ * 钱包业务消息Outbox数据访问入口。
+ */
+@Mapper
+public interface WalletMessageOutboxMapper extends PjcloudBaseMapper {
+
+ /**
+ * 跨租户物理删除超过30天的成功Outbox,每次限制数量以控制锁范围。
+ *
+ * @param before 成功处理时间截止值
+ * @param limit 单次最大删除数量
+ * @return 实际删除数量
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Delete("DELETE FROM wallet_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 wallet_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-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletMessageOutboxService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletMessageOutboxService.java
new file mode 100644
index 00000000..5b6409df
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletMessageOutboxService.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.wallet.service;
+
+/**
+ * 钱包业务消息Outbox处理服务,负责跨租户扫描、归并投递、失败恢复和到期清理。
+ */
+public interface WalletMessageOutboxService {
+
+ /**
+ * 扫描并处理已经超过归并窗口的到期Outbox事件。
+ *
+ * @return 本轮成功领取的聚合事件数量
+ */
+ int processDueEvents();
+
+ /**
+ * 分批物理清理超过保留期的成功和失败终态Outbox。
+ *
+ * @return 本轮实际清理记录数量
+ */
+ int cleanupExpiredEvents();
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
new file mode 100644
index 00000000..3abebb90
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
@@ -0,0 +1,303 @@
+package com.bsd.pjcloud.wallet.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.thirdparty.api.feign.RemoterThirdAppMessageService;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageDetailDto;
+import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
+import com.bsd.pjcloud.wallet.api.entity.WalletMessageOutboxEntity;
+import com.bsd.pjcloud.wallet.mapper.WalletMessageOutboxMapper;
+import com.bsd.pjcloud.wallet.service.WalletMessageOutboxService;
+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.Comparator;
+import java.util.List;
+
+/**
+ * 钱包业务消息Outbox处理实现,以稳定聚合事件ID向Thirdparty进行至少一次投递。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WalletMessageOutboxServiceImpl implements WalletMessageOutboxService {
+
+ /** 待处理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";
+
+ /** 钱包同一业务多流水等待归并的稳定窗口,单位秒。 */
+ private static final long MERGE_WINDOW_SECONDS = 2L;
+
+ /** 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);
+
+ /** 钱包Outbox数据访问入口。 */
+ private final WalletMessageOutboxMapper outboxMapper;
+
+ /** Thirdparty业务消息中心内部Feign客户端。 */
+ private final RemoterThirdAppMessageService appMessageService;
+
+ /** 事务模板,保证条件领取、远程结果和本组状态变更形成清晰的本地事务边界。 */
+ private final TransactionTemplate transactionTemplate;
+
+ /**
+ * 扫描到期候选,并按每条记录持久化的租户恢复数据库与Feign上下文。
+ *
+ * @return 本轮成功领取的聚合事件数量
+ */
+ @Override
+ public int processDueEvents() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime mergeCutoff = now.minusSeconds(MERGE_WINDOW_SECONDS);
+ LocalDateTime processingCutoff = now.minusMinutes(PROCESSING_TIMEOUT_MINUTES);
+ List candidates = outboxMapper.selectList(
+ Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper
+ .eq(WalletMessageOutboxEntity::getStatus, STATUS_PENDING)
+ .le(WalletMessageOutboxEntity::getCreateTime, mergeCutoff)
+ .or(current -> current.eq(WalletMessageOutboxEntity::getStatus, STATUS_RETRY)
+ .le(WalletMessageOutboxEntity::getNextRetryTime, now))
+ .or(current -> current.eq(WalletMessageOutboxEntity::getStatus, STATUS_PROCESSING)
+ .le(WalletMessageOutboxEntity::getUpdateTime, processingCutoff)))
+ .orderByAsc(WalletMessageOutboxEntity::getCreateTime)
+ .last("limit " + SCAN_LIMIT));
+ int processed = 0;
+ for (WalletMessageOutboxEntity 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 -> processGroup(candidate.getId(), mergeCutoff, 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();
+ int successCount = outboxMapper.physicalDeleteSuccessBefore(now.minusDays(SUCCESS_RETENTION_DAYS), CLEANUP_BATCH_SIZE);
+ int failedCount = outboxMapper.physicalDeleteFailedBefore(now.minusDays(FAILED_RETENTION_DAYS), CLEANUP_BATCH_SIZE);
+ return successCount + failedCount;
+ }
+
+ /**
+ * 领取候选所属完整聚合组并提交Thirdparty;稳定事件ID取组内最小Outbox主键,崩溃重试不会变化。
+ *
+ * @param candidateId 触发本次处理的候选Outbox主键
+ * @param mergeCutoff 本轮2秒归并截止时间
+ * @param processingCutoff PROCESSING超时恢复截止时间
+ * @return 是否成功领取并执行了该聚合组
+ */
+ private boolean processGroup(Long candidateId, LocalDateTime mergeCutoff, LocalDateTime processingCutoff) {
+ WalletMessageOutboxEntity candidate = outboxMapper.selectById(candidateId);
+ if (candidate == null || candidate.getAttemptCount() >= MAX_ATTEMPTS || !isDue(candidate, mergeCutoff, processingCutoff)) {
+ return false;
+ }
+ WalletMessageOutboxEntity latest = outboxMapper.selectOne(
+ Wrappers.lambdaQuery()
+ .eq(WalletMessageOutboxEntity::getEventGroupKey, candidate.getEventGroupKey())
+ .in(WalletMessageOutboxEntity::getStatus, STATUS_PENDING, STATUS_RETRY, STATUS_PROCESSING)
+ .orderByDesc(WalletMessageOutboxEntity::getCreateTime)
+ .orderByDesc(WalletMessageOutboxEntity::getId)
+ .last("limit 1"));
+ if (latest == null || latest.getCreateTime() == null || latest.getCreateTime().isAfter(mergeCutoff)) {
+ return false;
+ }
+ long activeProcessing = outboxMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(WalletMessageOutboxEntity::getEventGroupKey, candidate.getEventGroupKey())
+ .eq(WalletMessageOutboxEntity::getStatus, STATUS_PROCESSING)
+ .gt(WalletMessageOutboxEntity::getUpdateTime, processingCutoff));
+ if (activeProcessing > 0) {
+ return false;
+ }
+ WalletMessageOutboxEntity claim = new WalletMessageOutboxEntity();
+ claim.setStatus(STATUS_PROCESSING);
+ claim.setNextRetryTime(null);
+ int claimedRows = outboxMapper.update(claim, Wrappers.lambdaUpdate()
+ .eq(WalletMessageOutboxEntity::getEventGroupKey, candidate.getEventGroupKey())
+ .and(wrapper -> wrapper
+ .eq(WalletMessageOutboxEntity::getStatus, STATUS_PENDING)
+ .or(current -> current.eq(WalletMessageOutboxEntity::getStatus, STATUS_RETRY)
+ .le(WalletMessageOutboxEntity::getNextRetryTime, LocalDateTime.now()))
+ .or(current -> current.eq(WalletMessageOutboxEntity::getStatus, STATUS_PROCESSING)
+ .le(WalletMessageOutboxEntity::getUpdateTime, processingCutoff))));
+ if (claimedRows == 0) {
+ return false;
+ }
+
+ List group = outboxMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(WalletMessageOutboxEntity::getEventGroupKey, candidate.getEventGroupKey())
+ .eq(WalletMessageOutboxEntity::getStatus, STATUS_PROCESSING)
+ .orderByAsc(WalletMessageOutboxEntity::getOccurredTime)
+ .orderByAsc(WalletMessageOutboxEntity::getId));
+ if (group.isEmpty()) {
+ return false;
+ }
+ int newAttemptCount = group.stream().map(WalletMessageOutboxEntity::getAttemptCount)
+ .filter(value -> value != null).max(Comparator.naturalOrder()).orElse(0) + 1;
+ try {
+ R result = appMessageService.receiveWalletEvent(buildEvent(group));
+ if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) {
+ throw new IllegalStateException(result == null ? "Thirdparty无响应"
+ : StrUtil.blankToDefault(result.getMsg(), "Thirdparty未确认接收钱包消息事件"));
+ }
+ WalletMessageOutboxEntity success = new WalletMessageOutboxEntity();
+ success.setStatus(STATUS_SUCCESS);
+ success.setAttemptCount(newAttemptCount);
+ success.setProcessedTime(LocalDateTime.now());
+ success.setNextRetryTime(null);
+ success.setLastError(null);
+ outboxMapper.update(success, Wrappers.lambdaUpdate()
+ .eq(WalletMessageOutboxEntity::getEventGroupKey, candidate.getEventGroupKey())
+ .eq(WalletMessageOutboxEntity::getStatus, STATUS_PROCESSING));
+ } catch (Exception exception) {
+ markFailure(candidate.getEventGroupKey(), newAttemptCount, exception);
+ }
+ return true;
+ }
+
+ /**
+ * 判断候选当前是否满足首次、重试或超时恢复条件。
+ *
+ * @param candidate 当前候选记录
+ * @param mergeCutoff 首次处理归并截止时间
+ * @param processingCutoff 超时恢复截止时间
+ * @return 是否允许领取
+ */
+ private boolean isDue(WalletMessageOutboxEntity candidate, LocalDateTime mergeCutoff, LocalDateTime processingCutoff) {
+ LocalDateTime now = LocalDateTime.now();
+ if (STATUS_PENDING.equals(candidate.getStatus())) {
+ return candidate.getCreateTime() != null && !candidate.getCreateTime().isAfter(mergeCutoff);
+ }
+ if (STATUS_RETRY.equals(candidate.getStatus())) {
+ return candidate.getNextRetryTime() != null && !candidate.getNextRetryTime().isAfter(now);
+ }
+ return STATUS_PROCESSING.equals(candidate.getStatus()) && candidate.getUpdateTime() != null
+ && !candidate.getUpdateTime().isAfter(processingCutoff);
+ }
+
+ /**
+ * 将Outbox聚合组转换为类型明确的跨服务事件快照。
+ *
+ * @param group 同一租户、用户、业务类型、业务ID和商户的Outbox明细
+ * @return Thirdparty钱包消息事件
+ */
+ private WalletMessageEventDto buildEvent(List group) {
+ WalletMessageOutboxEntity first = group.get(0);
+ long eventSourceId = group.stream().map(WalletMessageOutboxEntity::getId).min(Long::compareTo).orElse(first.getId());
+ WalletMessageEventDto event = new WalletMessageEventDto();
+ event.setEventId("WALLET:" + eventSourceId);
+ event.setEventCode(first.getEventCode());
+ event.setTenantId(first.getTenantId());
+ event.setSubjectUserId(first.getSubjectUserId());
+ event.setSourceMchId(first.getSourceMchId());
+ event.setSourceMchName(first.getSourceMchName());
+ event.setSourceScene(first.getSourceScene());
+ event.setSourceBizType(first.getSourceBizType());
+ event.setSourceBizId(first.getSourceBizId());
+ event.setOccurredTime(group.stream().map(WalletMessageOutboxEntity::getOccurredTime)
+ .filter(value -> value != null).max(LocalDateTime::compareTo).orElse(first.getOccurredTime()));
+ event.setDetails(group.stream().map(this::buildDetail).toList());
+ return event;
+ }
+
+ /**
+ * 将单条钱包Outbox快照转换为跨服务钱包变动明细。
+ *
+ * @param outbox 单条钱包流水Outbox
+ * @return 钱包消息明细
+ */
+ private WalletMessageDetailDto buildDetail(WalletMessageOutboxEntity outbox) {
+ WalletMessageDetailDto detail = new WalletMessageDetailDto();
+ detail.setWalletTransactionId(outbox.getWalletTransactionId());
+ detail.setWalletId(outbox.getWalletId());
+ detail.setWalletName(outbox.getWalletName());
+ detail.setAmount(outbox.getAmount());
+ detail.setBalance(outbox.getBalance());
+ detail.setWalletBizType(outbox.getSourceBizType());
+ return detail;
+ }
+
+ /**
+ * 将聚合组安排退避重试或标记失败终态,错误摘要严格截断且不保存请求报文。
+ *
+ * @param eventGroupKey 聚合键
+ * @param attemptCount 本次完成后的累计尝试次数
+ * @param exception 本轮处理异常
+ */
+ private void markFailure(String eventGroupKey, int attemptCount, Exception exception) {
+ WalletMessageOutboxEntity failure = new WalletMessageOutboxEntity();
+ 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(WalletMessageOutboxEntity::getEventGroupKey, eventGroupKey)
+ .eq(WalletMessageOutboxEntity::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-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
index 5abd6f81..10143153 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
@@ -4,6 +4,7 @@ import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
@@ -25,6 +26,8 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
@@ -34,13 +37,16 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
+import com.bsd.pjcloud.wallet.api.entity.WalletMessageOutboxEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
+import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.*;
import com.bsd.pjcloud.wallet.api.vo.mch.WalletMchDeptViewVo;
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
+import com.bsd.pjcloud.wallet.mapper.WalletMessageOutboxMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper;
@@ -48,6 +54,7 @@ import com.bsd.pjcloud.wallet.service.MchManageService;
import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.WalletThirdZyService;
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
+import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -120,6 +127,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl 0;
+ boolean saved = baseMapper.insert(entity) > 0;
+ if (saved) {
+ createMessageOutbox(entity);
+ }
+ return saved;
+ }
+
+ /**
+ * 根据新钱包流水和当前调用上下文创建不可变消息快照;来源商户无法解析时保留空值,由消息中心仅入本人收件箱。
+ *
+ * @param transaction 已成功插入的新钱包流水
+ */
+ private void createMessageOutbox(WalletTransactionEntity transaction) {
+ AppMessageEventEnum event = AppMessageEventEnum.fromWalletBizCode(transaction.getBizType());
+ if (event == null) {
+ return;
+ }
+ WalletManageEntity wallet = walletManageMapper.selectById(transaction.getWalletId());
+ if (wallet == null) {
+ throw new IllegalStateException("钱包流水缺少对应钱包,无法创建消息Outbox");
+ }
+ WalletBalanceTransactionDto context = WalletMessageContextHolder.find(
+ transaction.getWalletId(), transaction.getBizId(), transaction.getBizType());
+ MchManageEntity merchant = resolveSourceMerchant(context, transaction.getDeptId());
+ Long sourceMchId = context != null && context.getSourceMchId() != null
+ ? context.getSourceMchId() : merchant == null ? null : merchant.getId();
+ String sourceMchName = context != null && ObjectUtil.isNotEmpty(context.getSourceMchName())
+ ? context.getSourceMchName() : merchant == null ? null : merchant.getName();
+ String sourceScene = context != null && ObjectUtil.isNotEmpty(context.getSourceScene())
+ ? context.getSourceScene() : context != null && context.getSourceMchId() != null ? "ORDER" : "WALLET";
+ WalletTypeEntity walletType = walletTypeMapper.selectById(wallet.getWalletTypeId());
+ Long tenantId = transaction.getTenantId() == null ? TenantContextHolder.getTenantId() : transaction.getTenantId();
+ if (tenantId == null) {
+ throw new IllegalStateException("钱包消息Outbox缺少租户上下文");
+ }
+
+ WalletMessageOutboxEntity outbox = new WalletMessageOutboxEntity();
+ outbox.setTenantId(tenantId);
+ outbox.setWalletTransactionId(transaction.getId());
+ outbox.setEventCode(event.getCode());
+ outbox.setSubjectUserId(transaction.getUserId());
+ outbox.setSourceMchId(sourceMchId);
+ outbox.setSourceMchName(sourceMchName);
+ outbox.setSourceScene(sourceScene);
+ outbox.setSourceBizType(transaction.getBizType());
+ outbox.setSourceBizId(transaction.getBizId());
+ outbox.setWalletId(transaction.getWalletId());
+ outbox.setWalletName(walletType == null ? "钱包" : StrUtil.blankToDefault(walletType.getName(), "钱包"));
+ outbox.setAmount(transaction.getPrice());
+ outbox.setBalance(transaction.getBalance());
+ outbox.setOccurredTime(transaction.getCreateTime() == null ? java.time.LocalDateTime.now() : transaction.getCreateTime());
+ outbox.setStatus("PENDING");
+ outbox.setAttemptCount(0);
+ outbox.setEventGroupKey(String.join(":", String.valueOf(tenantId), String.valueOf(transaction.getUserId()),
+ transaction.getBizType(), transaction.getBizId(), String.valueOf(sourceMchId == null ? 0L : sourceMchId)));
+ if (walletMessageOutboxMapper.insert(outbox) != 1) {
+ throw new IllegalStateException("创建钱包消息Outbox失败");
+ }
+ }
+
+ /**
+ * 解析钱包事件来源商户:订单优先使用调用方实际商户,账户级事件按钱包所有人部门取当前食堂。
+ *
+ * @param context 当前远程调用的可选订单上下文
+ * @param walletOwnerDeptId 钱包所有人部门ID
+ * @return 来源商户,无法可靠解析时为空
+ */
+ private MchManageEntity resolveSourceMerchant(WalletBalanceTransactionDto context, Long walletOwnerDeptId) {
+ if (context != null && context.getSourceMchId() != null) {
+ return mchManageService.getById(context.getSourceMchId());
+ }
+ if (walletOwnerDeptId == null) {
+ return null;
+ }
+ try {
+ return mchManageService.getViewsCanteenMchByDeptId(walletOwnerDeptId);
+ } catch (Exception exception) {
+ log.warn("|{}|钱包消息来源食堂解析失败,deptId:|{}|,errorType:|{}|", TAG, walletOwnerDeptId,
+ exception.getClass().getSimpleName());
+ return null;
+ }
}
@Override
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletMessageContextHolder.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletMessageContextHolder.java
new file mode 100644
index 00000000..f99f2a49
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletMessageContextHolder.java
@@ -0,0 +1,59 @@
+package com.bsd.pjcloud.wallet.support;
+
+import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
+import lombok.experimental.UtilityClass;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+/**
+ * 单次钱包远程调用的订单消息上下文,只在当前线程调用栈内补充实际商户,不参与持久化事务状态。
+ */
+@UtilityClass
+public class WalletMessageContextHolder {
+
+ /** 当前调用携带的订单钱包明细,调用结束后必须恢复或清理。 */
+ private static final ThreadLocal> CONTEXT = new ThreadLocal<>();
+
+ /**
+ * 在给定钱包消息上下文中执行调用,并在finally恢复外层上下文。
+ *
+ * @param contexts 当前远程调用钱包明细
+ * @param supplier 实际钱包业务调用
+ * @param 调用返回类型
+ * @return 钱包业务调用结果
+ */
+ public T supplyWith(List contexts, Supplier supplier) {
+ List previous = CONTEXT.get();
+ try {
+ CONTEXT.set(contexts == null ? List.of() : List.copyOf(contexts));
+ return supplier.get();
+ } finally {
+ if (previous == null) {
+ CONTEXT.remove();
+ } else {
+ CONTEXT.set(previous);
+ }
+ }
+ }
+
+ /**
+ * 按钱包、业务号和业务类型匹配当前流水的订单商户上下文。
+ *
+ * @param walletId 钱包ID
+ * @param bizId 业务ID
+ * @param bizType WalletBizEnum持久化编码,仅用于诊断;支付补偿会以同一调用上下文生成REFUND流水
+ * @return 匹配的调用明细,不存在时为空
+ */
+ public WalletBalanceTransactionDto find(Long walletId, String bizId, String bizType) {
+ List contexts = CONTEXT.get();
+ if (contexts == null) {
+ return null;
+ }
+ return contexts.stream()
+ .filter(item -> Objects.equals(item.getWalletId(), walletId))
+ .filter(item -> Objects.equals(item.getBizId(), bizId))
+ .findFirst().orElse(null);
+ }
+}