diff --git a/src/main/java/com/yunhai/userbase/config/PushMqConfig.java b/src/main/java/com/yunhai/userbase/config/PushMqConfig.java
new file mode 100644
index 0000000..2be5b9a
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/config/PushMqConfig.java
@@ -0,0 +1,102 @@
+package com.yunhai.userbase.config;
+
+import org.springframework.amqp.core.*;
+import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
+import org.springframework.amqp.support.converter.MessageConverter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.yunhai.userbase.model.push.PushTaskMessage;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 推送订阅 MQ 配置。
+ * 拓扑:
+ *
+ * push.topic (topic, durable)
+ * └─ push.main (durable, concurrency=1, x-dead-letter-exchange=push.dlx) ← 串行消费,可观测
+ * push.dlx (fanout, durable)
+ * └─ push.dlq (durable) ← 死信归档(重试耗尽)
+ *
+ * 连接信息(host/port/user/pwd/vhost)由 Nacos {@code application-infra.yaml} 的 {@code spring.rabbitmq.*}
+ * 公共配置注入,本类不写死(也不改 bootstrap.yml / application.yml)。
+ * 可靠性:消费端手动 ack,失败由 {@code PushConsumer} 应用层自管重试(共 3 次),耗尽进死信队列。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Configuration
+public class PushMqConfig {
+
+ public static final String EXCHANGE_TOPIC = "push.topic";
+ public static final String EXCHANGE_DLX = "push.dlx";
+ public static final String QUEUE_MAIN = "push.main";
+ public static final String QUEUE_DLQ = "push.dlq";
+
+ /** 统一 JSON 消息转换器(固定反序列化为 PushTaskMessage,绕过 trusted-packages 版本差异)。 */
+ @Bean
+ public MessageConverter pushMessageConverter() {
+ Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
+ converter.setClassMapper(new PushTaskClassMapper(PushTaskMessage.class));
+ return converter;
+ }
+
+ @Bean
+ public TopicExchange pushTopicExchange() {
+ return new TopicExchange(EXCHANGE_TOPIC, true, false);
+ }
+
+ @Bean
+ public FanoutExchange pushDlxExchange() {
+ return new FanoutExchange(EXCHANGE_DLX, true, false);
+ }
+
+ /** 主队列:单消费者串行 + 死信路由到 push.dlx。 */
+ @Bean
+ public Queue pushMainQueue() {
+ Map args = new HashMap<>();
+ args.put("x-dead-letter-exchange", EXCHANGE_DLX);
+ return new Queue(QUEUE_MAIN, true, false, false, args);
+ }
+
+ /** 死信队列:归档重试耗尽的任务。 */
+ @Bean
+ public Queue pushDlqQueue() {
+ return new Queue(QUEUE_DLQ, true, false, false);
+ }
+
+ @Bean
+ public Binding pushMainBinding() {
+ return BindingBuilder.bind(pushMainQueue()).to(pushTopicExchange()).with("#");
+ }
+
+ @Bean
+ public Binding pushDlqBinding() {
+ return BindingBuilder.bind(pushDlqQueue()).to(pushDlxExchange());
+ }
+
+ @Bean
+ public RabbitTemplate pushRabbitTemplate(ConnectionFactory connectionFactory) {
+ RabbitTemplate template = new RabbitTemplate(connectionFactory);
+ template.setMessageConverter(pushMessageConverter());
+ return template;
+ }
+
+ /** 消费者容器:手动 ack + 单并发 + prefetch=1,保证串行可观测。 */
+ @Bean(name = "pushListenerContainerFactory")
+ public SimpleRabbitListenerContainerFactory pushListenerContainerFactory(ConnectionFactory connectionFactory) {
+ SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
+ factory.setConnectionFactory(connectionFactory);
+ factory.setMessageConverter(pushMessageConverter());
+ factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.MANUAL);
+ factory.setConcurrentConsumers(1);
+ factory.setMaxConcurrentConsumers(1);
+ factory.setPrefetchCount(1);
+ return factory;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java b/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java
new file mode 100644
index 0000000..fce04f8
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java
@@ -0,0 +1,32 @@
+package com.yunhai.userbase.config;
+
+import org.springframework.amqp.core.MessageProperties;
+import org.springframework.amqp.support.converter.ClassMapper;
+
+/**
+ * 推送消息固定反序列化为 {@link PushTaskMessage},绕过 Jackson2JsonMessageConverter 的
+ * trusted-packages 安全限制(不同 spring-amqp 版本该方法签名/默认值不一致,编译期不可靠)。
+ * 生产者端序列化仍由 Jackson2JsonMessageConverter 完成(写入 __TypeId__),消费者端忽略 __TypeId__
+ * 直接按本映射器返回 PushTaskMessage,类型单一且可控。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+public class PushTaskClassMapper implements ClassMapper {
+
+ private final Class> payloadClass;
+
+ public PushTaskClassMapper(Class> payloadClass) {
+ this.payloadClass = payloadClass;
+ }
+
+ @Override
+ public Class> toClass(MessageProperties properties) {
+ return payloadClass;
+ }
+
+ @Override
+ public void fromClass(Class> clazz, MessageProperties properties) {
+ properties.setHeader("__TypeId__", clazz.getName());
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java
new file mode 100644
index 0000000..8e415cb
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java
@@ -0,0 +1,25 @@
+package com.yunhai.userbase.event;
+
+import com.yunhai.userbase.model.push.PushInterfaceType;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 数据同步事件:组 / 用户变更后由 {@code SysGroupService}/{@code SysUserService} 在事务内 publish,
+ * {@code PushDispatcher} 用 {@code @TransactionalEventListener(AFTER_COMMIT)} 消费(事务提交后才推送,
+ * 推送失败不影响主业务)。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class DataSyncEvent {
+
+ /** 接口类型,如 group/AddGroup */
+ private PushInterfaceType type;
+ /** 组事件携带的受影响组 id 列表 */
+ private List groupIds;
+ /** 用户事件携带的受影响用户 id 列表 */
+ private List userIds;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java b/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java
new file mode 100644
index 0000000..a6d58a4
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java
@@ -0,0 +1,30 @@
+package com.yunhai.userbase.model.application;
+
+import lombok.Data;
+
+/**
+ * 保存应用推送设置请求(应用列表「推送设置」按钮提交)。
+ * 所有字段均为可选更新:传 null 表示不修改该项。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushSettingRequest {
+
+ /** 推送外部接口地址(不含固定后缀) */
+ private String pushBaseUrl;
+ /** 推送总开关:0关 1开 */
+ private Integer pushEnabled;
+ /** 启用的接口路径,逗号分隔 */
+ private String pushEventTypes;
+ /** 关联学校组 id,逗号分隔;空=全局 */
+ private String pushSchoolIds;
+ /** 推送角色 key,逗号分隔;空=全部角色 */
+ private String pushRoleIds;
+ /**
+ * 推送签名密钥;传 null 表示不修改(保留现有值),
+ * 传空串 "" 表示清空(后续复用 appSecret)。
+ */
+ private String pushSecret;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java b/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java
new file mode 100644
index 0000000..38ce68f
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java
@@ -0,0 +1,24 @@
+package com.yunhai.userbase.model.application;
+
+import lombok.Data;
+
+/**
+ * 应用推送设置回显 VO(GetPushSetting 返回,仅含推送相关字段,不含 appSecret 等无关大字段)。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushSettingVo {
+
+ private Integer id;
+ private Integer appKey;
+ private String name;
+ private String pushBaseUrl;
+ private Integer pushEnabled;
+ private String pushEventTypes;
+ private String pushSchoolIds;
+ private String pushRoleIds;
+ /** 推送签名密钥(可展示供复制;空=复用 appSecret) */
+ private String pushSecret;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java b/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java
new file mode 100644
index 0000000..61b45f7
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java
@@ -0,0 +1,49 @@
+package com.yunhai.userbase.model.push;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文档约定的 8 个订阅接口:path(固定后缀,拼在订阅方 base URL 后)↔ 友好名 label。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Getter
+@AllArgsConstructor
+public enum PushInterfaceType {
+
+ SYNC_GROUP("group/SyncGroup", "组全量同步"),
+ SYNC_USER("user/SyncUser", "用户全量同步"),
+ ADD_GROUP("group/AddGroup", "添加组"),
+ UPDATE_GROUP("group/UpdateGroup", "更新组"),
+ DELETE_GROUP("group/DeleteGroup", "删除组"),
+ ADD_USER("user/AddUser", "添加用户"),
+ UPDATE_USER("user/UpdateUser", "更新用户"),
+ DELETE_USER("user/DeleteUser", "删除用户");
+
+ private final String path;
+ private final String label;
+
+ public static PushInterfaceType getByPath(String path) {
+ if (path == null) {
+ return null;
+ }
+ for (PushInterfaceType type : values()) {
+ if (type.path.equals(path)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /** 是否为组相关接口。 */
+ public boolean isGroup() {
+ return path.startsWith("group/");
+ }
+
+ /** 是否为用户相关接口。 */
+ public boolean isUser() {
+ return path.startsWith("user/");
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java b/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java
new file mode 100644
index 0000000..9017d6a
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java
@@ -0,0 +1,74 @@
+package com.yunhai.userbase.model.push;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.CreationTimestamp;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * 推送订阅记录实体(订阅方维度,落库用于日志 / 重试 / 排查)。
+ * 与 DDL(push_subscription_ddl.sql §2)的 push_record 表一一对应。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+@NoArgsConstructor
+@Entity
+@Table(name = "push_record")
+public class PushRecordEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "subscriber_app_key", nullable = false)
+ private Integer subscriberAppKey;
+
+ @Column(name = "app_name")
+ private String appName;
+
+ @Column(name = "interface_path", nullable = false)
+ private String interfacePath;
+
+ @Column(name = "interface_label")
+ private String interfaceLabel;
+
+ @Column(name = "request_url", nullable = false)
+ private String requestUrl;
+
+ @Column(name = "payload", columnDefinition = "MEDIUMTEXT")
+ private String payload;
+
+ @Column(name = "sign_t")
+ private String signT;
+
+ @Column(name = "http_status")
+ private Integer httpStatus;
+
+ @Column(name = "resp_code")
+ private Integer respCode;
+
+ @Column(name = "resp_msg")
+ private String respMsg;
+
+ /** 是否成功:0失败 1成功 */
+ @Column(name = "success", nullable = false)
+ private Integer success = 0;
+
+ @Column(name = "attempt_no", nullable = false)
+ private Integer attemptNo = 1;
+
+ @Column(name = "cost_ms")
+ private Integer costMs;
+
+ /** 触发类型:manual / auto / retry */
+ @Column(name = "trigger_type")
+ private String triggerType;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java b/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java
new file mode 100644
index 0000000..ffa1240
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java
@@ -0,0 +1,24 @@
+package com.yunhai.userbase.model.push;
+
+import lombok.Data;
+
+/**
+ * 推送记录查询条件(前端日志页搜索用)。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushRecordQuery {
+
+ /** 订阅方 appKey */
+ private Integer appKey;
+ /** 接口路径,如 group/AddGroup */
+ private String interfacePath;
+ /** 是否成功:0失败 1成功 */
+ private Integer success;
+ /** 起始时间 yyyy-MM-dd(含当天 00:00:00) */
+ private String startTime;
+ /** 结束时间 yyyy-MM-dd(含当天 23:59:59) */
+ private String endTime;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java b/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java
new file mode 100644
index 0000000..744d641
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java
@@ -0,0 +1,38 @@
+package com.yunhai.userbase.model.push;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * MQ 推送任务消息。
+ * 由 {@code PushDispatcher}(生产方)投递,{@code PushConsumer}(消费方)取出后 POST 给订阅方。
+ * 签名(t + sign)在投递时由基础平台计算一次;自动重试保持原 t/sign,手动重试重新生成。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class PushTaskMessage implements Serializable {
+
+ /** 订阅方 appKey */
+ private Integer appKey;
+ /** 接口路径,如 group/AddGroup */
+ private String interfacePath;
+ /** 接口友好名 */
+ private String interfaceLabel;
+ /** 业务数据原始 JSON 字符串(签名与发送均基于该原始字符串字节) */
+ private String body;
+ /** 签名时间戳(字符串) */
+ private String t;
+ /** HMAC-SHA256(业务JSON + t, secret) hex 小写 */
+ private String sign;
+ /** 第几次尝试:1=首次 */
+ private int attemptNo;
+ /** 触发类型:manual / auto / retry */
+ private String triggerType;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java b/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java
new file mode 100644
index 0000000..4859229
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java
@@ -0,0 +1,22 @@
+package com.yunhai.userbase.model.push;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 测试推送请求(测试推送页调用)。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushTestRequest {
+
+ /** 订阅方应用 id */
+ private Integer appId;
+ /** 接口路径,如 group/AddGroup */
+ private String interfacePath;
+ /** 目标 id 列表(可选):不传则按接口类型取全量/全部 */
+ private List targetIds;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java b/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java
new file mode 100644
index 0000000..06cd21a
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java
@@ -0,0 +1,32 @@
+package com.yunhai.userbase.model.push.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 文档「组」契约 DTO(group/SyncGroup、AddGroup、UpdateGroup 共用)。
+ * 字段严格对齐《弘育基础平台数据同步接口 2》:
+ *
+ * - SyncGroup:children 递归树(可空)
+ * - AddGroup:pid 必填(上级组 id,0 为顶级)
+ * - UpdateGroup:仅 id 必填,其余可选
+ *
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushGroupDTO {
+
+ private Integer id;
+ private String name;
+ private String code;
+ /** 组类别 1-8,与 GroupTypeEnum 1:1 零映射 */
+ private Integer type;
+ /** 上级组 id(AddGroup 必填) */
+ private Integer pid;
+ private Integer no;
+ /** 子组列表(SyncGroup 用) */
+ private List children;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java
new file mode 100644
index 0000000..697ca4b
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java
@@ -0,0 +1,53 @@
+package com.yunhai.userbase.model.push.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 文档「用户」契约 DTO(user/SyncUser、AddUser、UpdateUser 共用)。
+ * 字段严格对齐《弘育基础平台数据同步接口 2》:
+ *
+ * - status:0正常 / 2禁用(由 userbase StatusEnum 映射)
+ * - groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)
+ * - roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)
+ *
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Data
+public class PushUserDTO {
+
+ private Integer id;
+ private String account;
+ private String name;
+ private String idcard;
+ private String phone;
+ private String email;
+ /** 性别 0女 1男 */
+ private Integer gender;
+ private String birthday;
+ /** 用户状态 0正常 2禁用 */
+ private Integer status;
+ private String profilePicture;
+ private List groups;
+ private List roleList;
+
+ /** 用户所属组引用。 */
+ @Data
+ public static class PushUserGroupRef {
+ private Integer groupId;
+ private Integer no;
+ /** 组所属学校范围 id(沿 parentId 向上最近的 SCHOOL_GROUP) */
+ private Integer schoolId;
+ }
+
+ /** 用户身份引用。 */
+ @Data
+ public static class PushUserRoleRef {
+ private Integer roleId;
+ /** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */
+ private Integer schId;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java b/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java
new file mode 100644
index 0000000..65a4eaa
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java
@@ -0,0 +1,16 @@
+package com.yunhai.userbase.repository;
+
+import com.yunhai.userbase.model.push.PushRecordEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 推送记录仓储。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Repository
+public interface PushRecordRepository extends JpaRepository, JpaSpecificationExecutor {
+}
diff --git a/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java b/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java
new file mode 100644
index 0000000..d523fdd
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java
@@ -0,0 +1,32 @@
+package com.yunhai.userbase.task;
+
+import com.yunhai.userbase.service.push.PushRecordService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 推送日志定时清理任务:每日凌晨 3 点清理 1 年前的推送记录。
+ * 与 {@code OperationLogCleanupTask} 同范式(@EnableScheduling 已在 UserBaseApplication 开启)。
+ *
+ * @author chenx
+ * @date 2026/8/14
+ */
+@Slf4j
+@Component
+public class PushRecordCleanupTask {
+
+ @Autowired
+ private PushRecordService pushRecordService;
+
+ @Scheduled(cron = "0 0 3 * * ?")
+ public void run() {
+ try {
+ long deleted = pushRecordService.cleanupExpired();
+ log.info("推送日志定时清理完成, 删除{}条", deleted);
+ } catch (Exception e) {
+ log.error("推送日志定时清理失败", e);
+ }
+ }
+}