From ed527bae283588de0a9848390348e93c3820fef9 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期一, 20 四月 2026 17:07:49 +0800
Subject: [PATCH] feat:通知功能完善

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java |  473 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 463 insertions(+), 10 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java
index 4327b54..51c90fc 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java
@@ -1,12 +1,22 @@
 package com.tcm.training.admin.app.notice.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.notice.entity.AppSystemNotice;
+import com.tcm.training.admin.app.notice.entity.AppUserActivityNotice;
 import com.tcm.training.admin.app.notice.entity.AppUserNoticeReadRecord;
+import com.tcm.training.admin.app.notice.mapper.AppSystemNoticeMapper;
+import com.tcm.training.admin.app.notice.mapper.AppUserActivityNoticeMapper;
 import com.tcm.training.admin.app.notice.mapper.AppUserNoticeReadRecordMapper;
+import com.tcm.training.admin.app.notice.vo.AppActivityNoticeDetailVO;
+import com.tcm.training.admin.app.notice.vo.AppActivityNoticeListItemVO;
+import com.tcm.training.admin.app.notice.vo.AppNoticeDetailVO;
 import com.tcm.training.admin.app.notice.vo.AppNoticeItemVO;
 import com.tcm.training.admin.app.settings.entity.AppUserNotificationSetting;
 import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
+import com.tcm.training.admin.common.PageResponse;
 import com.tcm.training.admin.live.entity.CoursePurchaseOrder;
 import com.tcm.training.admin.live.entity.HealthCourse;
 import com.tcm.training.admin.live.entity.HealthCourseMemberJoinRecord;
@@ -67,6 +77,7 @@
     private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
     private static final int USER_STATUS_ENABLED = 1;
     private static final int USER_DELETED_NO = 0;
+    private static final int RECORD_DELETED_NO = 0;
     private static final int COURSE_STATUS_ENABLED = 1;
     private static final int LIVE_COURSE_STATUS_ENABLED = 1;
     private static final int LIVE_STATUS_PENDING = 0;
@@ -87,6 +98,8 @@
     private final HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper;
     private final AppUserNotificationSettingMapper appUserNotificationSettingMapper;
     private final AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper;
+    private final AppUserActivityNoticeMapper appUserActivityNoticeMapper;
+    private final AppSystemNoticeMapper appSystemNoticeMapper;
 
     /**
      * 注入 APP 通知列表所需依赖。
@@ -105,7 +118,9 @@
                             HealthCourseVideoMapper healthCourseVideoMapper,
                             HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper,
                             AppUserNotificationSettingMapper appUserNotificationSettingMapper,
-                            AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper) {
+                            AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper,
+                            AppUserActivityNoticeMapper appUserActivityNoticeMapper,
+                            AppSystemNoticeMapper appSystemNoticeMapper) {
         this.appAuthSessionService = appAuthSessionService;
         this.userMapper = userMapper;
         this.systemSettingsMapper = systemSettingsMapper;
@@ -121,6 +136,8 @@
         this.healthCourseMemberJoinRecordMapper = healthCourseMemberJoinRecordMapper;
         this.appUserNotificationSettingMapper = appUserNotificationSettingMapper;
         this.appUserNoticeReadRecordMapper = appUserNoticeReadRecordMapper;
+        this.appUserActivityNoticeMapper = appUserActivityNoticeMapper;
+        this.appSystemNoticeMapper = appSystemNoticeMapper;
     }
 
     /**
@@ -150,6 +167,7 @@
         for (String noticeType : NOTICE_TYPES) {
             saveReadRecord(userId, noticeType, now);
         }
+        markAllActivityRead(userId, now);
     }
 
     /**
@@ -160,6 +178,88 @@
         Long userId = requireCurrentEnabledUser().getId();
         String normalizedNoticeType = normalizeNoticeType(noticeType);
         saveReadRecord(userId, normalizedNoticeType, LocalDateTime.now());
+        if (NOTICE_TYPE_ACTIVITY.equals(normalizedNoticeType)) {
+            markAllActivityRead(userId, LocalDateTime.now());
+        }
+    }
+
+    /**
+     * 获取当前登录用户指定类型通知详情。
+     */
+    public AppNoticeDetailVO getNoticeDetail(String noticeType) {
+        AppUser user = requireCurrentEnabledUser();
+        String normalizedNoticeType = normalizeNoticeType(noticeType);
+        if (NOTICE_TYPE_ACTIVITY.equals(normalizedNoticeType)) {
+            throw new IllegalArgumentException("活动通知请使用活动通知详情接口");
+        }
+        AppUserNotificationSetting setting = getNotificationSetting(user.getId());
+        Map<String, LocalDateTime> readTimeMap = getReadTimeMap(user.getId());
+        if (NOTICE_TYPE_SERVICE.equals(normalizedNoticeType)) {
+            return buildServiceNoticeDetail(user, readTimeMap.get(normalizedNoticeType));
+        }
+        if (NOTICE_TYPE_MEMBER_EXPIRE.equals(normalizedNoticeType)) {
+            return buildMemberExpireNoticeDetail(user, readTimeMap.get(normalizedNoticeType));
+        }
+        if (NOTICE_TYPE_LIVE_RESERVE.equals(normalizedNoticeType)) {
+            return buildLiveReserveNoticeDetail(user.getId(), setting, readTimeMap.get(normalizedNoticeType));
+        }
+        if (NOTICE_TYPE_ORDER.equals(normalizedNoticeType)) {
+            return buildOrderNoticeDetail(user.getId(), setting, readTimeMap.get(normalizedNoticeType));
+        }
+        if (NOTICE_TYPE_COURSE_UPDATE.equals(normalizedNoticeType)) {
+            return buildCourseUpdateNoticeDetail(user.getId(), setting, readTimeMap.get(normalizedNoticeType));
+        }
+        throw new IllegalArgumentException("通知类型不合法");
+    }
+
+    /**
+     * 获取当前登录用户的活动通知分页列表。
+     */
+    public PageResponse<AppActivityNoticeListItemVO> pageActivityNotices(int page, int pageSize) {
+        Long userId = requireCurrentEnabledUser().getId();
+        Page<AppSystemNotice> result = appSystemNoticeMapper.selectPage(
+                new Page<AppSystemNotice>(Math.max(page, 1), Math.max(pageSize, 1)),
+                new LambdaQueryWrapper<AppSystemNotice>()
+                        .eq(AppSystemNotice::getAppUserId, userId)
+                        .eq(AppSystemNotice::getNoticeTypeCode, AppSystemNoticeType.ACTIVITY_NOTICE)
+                        .eq(AppSystemNotice::getDeleted, RECORD_DELETED_NO)
+                        .orderByDesc(AppSystemNotice::getCreatedAt)
+                        .orderByDesc(AppSystemNotice::getId)
+        );
+        List<AppActivityNoticeListItemVO> list = new ArrayList<AppActivityNoticeListItemVO>();
+        for (AppSystemNotice record : result.getRecords()) {
+            ActivityNotice activityNotice = requireActivityNotice(record.getReferenceId());
+            if (activityNotice == null) {
+                continue;
+            }
+            list.add(toActivityListItem(record, activityNotice));
+        }
+        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    /**
+     * 获取当前登录用户的活动通知详情。
+     */
+    public AppActivityNoticeDetailVO getActivityNoticeDetail(Long id) {
+        AppSystemNotice record = requireActivitySystemNoticeRecord(requireCurrentEnabledUser().getId(), id);
+        ActivityNotice notice = requireActivityNotice(record.getReferenceId());
+        AppActivityNoticeDetailVO vo = new AppActivityNoticeDetailVO();
+        vo.setId(record.getId());
+        vo.setTitle(trimToEmpty(notice.getTitle()));
+        vo.setContentHtml(trimToEmpty(notice.getContentHtml()));
+        vo.setCreatedAt(formatTime(chooseTime(record.getCreatedAt(), record.getPushedAt(), notice.getCreatedAt())));
+        vo.setRead(Boolean.valueOf(record.getReadAt() != null));
+        return vo;
+    }
+
+    /**
+     * 将当前登录用户的一条活动通知标记为已读。
+     */
+    @Transactional
+    public void markActivityRead(Long id) {
+        AppSystemNotice record = requireActivitySystemNoticeRecord(requireCurrentEnabledUser().getId(), id);
+        record.setReadAt(LocalDateTime.now());
+        appSystemNoticeMapper.updateById(record);
     }
 
     /**
@@ -181,6 +281,28 @@
                 "/static/image/notice/service.png",
                 calculateUnreadBadge(noticeTime, lastReadAt)
         );
+    }
+
+    /**
+     * 构建系统客服通知详情。
+     */
+    private AppNoticeDetailVO buildServiceNoticeDetail(AppUser user, LocalDateTime lastReadAt) {
+        SystemSettings settings = systemSettingsMapper.selectOne(new LambdaQueryWrapper<SystemSettings>()
+                .eq(SystemSettings::getDeleted, 0)
+                .orderByDesc(SystemSettings::getId)
+                .last("limit 1"));
+        String phone = trimToEmpty(settings == null ? null : settings.getServicePhone());
+        String onlineTime = trimToEmpty(settings == null ? null : settings.getServiceOnlineTime());
+        StringBuilder content = new StringBuilder();
+        content.append("<p>如需帮助,请联系系统客服。</p>");
+        if (!phone.isEmpty()) {
+            content.append("<p>客服电话:").append(phone).append("</p>");
+        }
+        if (!onlineTime.isEmpty()) {
+            content.append("<p>服务时间:").append(onlineTime).append("</p>");
+        }
+        LocalDateTime noticeTime = settings == null ? null : settings.getUpdatedAt();
+        return buildNoticeDetailVO(NOTICE_TYPE_SERVICE, "系统客服", content.toString(), noticeTime, !isUnread(noticeTime, lastReadAt));
     }
 
     /**
@@ -208,6 +330,25 @@
                 "/static/image/notice/lingdang.png",
                 badge
         );
+    }
+
+    /**
+     * 构建会员到期提醒详情。
+     */
+    private AppNoticeDetailVO buildMemberExpireNoticeDetail(AppUser user, LocalDateTime lastReadAt) {
+        LocalDateTime expireAt = user.getMemberExpireAt();
+        LocalDateTime noticeTime = null;
+        String content = "<p>暂无会员到期提醒</p>";
+        if (expireAt != null) {
+            int remindDays = resolveMemberExpireRemindDays();
+            LocalDateTime remindTime = expireAt.minusDays(remindDays);
+            if (!LocalDateTime.now().isBefore(remindTime)) {
+                noticeTime = expireAt;
+                content = "<p>您的会员有效期不足" + remindDays + "天,为避免影响你的学习体验,请及时续费。</p>"
+                        + "<p>会员到期时间:" + formatTime(expireAt) + "</p>";
+            }
+        }
+        return buildNoticeDetailVO(NOTICE_TYPE_MEMBER_EXPIRE, "会员到期提醒", content, noticeTime, !isUnread(noticeTime, lastReadAt));
     }
 
     /**
@@ -320,6 +461,27 @@
     }
 
     /**
+     * 构建直播提醒详情。
+     */
+    private AppNoticeDetailVO buildLiveReserveNoticeDetail(Long userId,
+                                                           AppUserNotificationSetting setting,
+                                                           LocalDateTime lastReadAt) {
+        if (!isEnabled(setting == null ? null : setting.getLiveReservationEnabled())) {
+            return buildNoticeDetailVO(
+                    NOTICE_TYPE_LIVE_RESERVE,
+                    "直播提醒",
+                    "<p>直播预约通知已关闭</p>",
+                    null,
+                    Boolean.TRUE
+            );
+        }
+        NotificationSnapshot snapshot = resolveLiveReserveSnapshot(userId, setting);
+        String content = snapshot == null ? "<p>暂无直播预约提醒</p>" : wrapParagraph(snapshot.getContent());
+        LocalDateTime noticeTime = snapshot == null ? null : snapshot.getNoticeTime();
+        return buildNoticeDetailVO(NOTICE_TYPE_LIVE_RESERVE, "直播提醒", content, noticeTime, !isUnread(noticeTime, lastReadAt));
+    }
+
+    /**
      * 构建活动通知。
      */
     private AppNoticeItemVO buildActivityNotice(AppUserNotificationSetting setting, LocalDateTime lastReadAt) {
@@ -333,11 +495,14 @@
                     0
             );
         }
-        List<ActivityNotice> notices = activityNoticeMapper.selectList(new LambdaQueryWrapper<ActivityNotice>()
-                .eq(ActivityNotice::getDeleted, 0)
-                .orderByDesc(ActivityNotice::getCreatedAt)
-                .orderByDesc(ActivityNotice::getId));
-        if (notices == null || notices.isEmpty()) {
+        Long userId = requireCurrentEnabledUser().getId();
+        List<AppSystemNotice> noticeRecords = appSystemNoticeMapper.selectList(new LambdaQueryWrapper<AppSystemNotice>()
+                .eq(AppSystemNotice::getAppUserId, userId)
+                .eq(AppSystemNotice::getNoticeTypeCode, AppSystemNoticeType.ACTIVITY_NOTICE)
+                .eq(AppSystemNotice::getDeleted, RECORD_DELETED_NO)
+                .orderByDesc(AppSystemNotice::getCreatedAt)
+                .orderByDesc(AppSystemNotice::getId));
+        if (noticeRecords == null || noticeRecords.isEmpty()) {
             return buildNoticeItem(
                     NOTICE_TYPE_ACTIVITY,
                     "活动通知",
@@ -347,10 +512,11 @@
                     0
             );
         }
-        ActivityNotice latest = notices.get(0);
+        AppSystemNotice latestRecord = noticeRecords.get(0);
+        ActivityNotice latest = requireActivityNotice(latestRecord.getReferenceId());
         int unreadCount = 0;
-        for (ActivityNotice notice : notices) {
-            if (isUnread(notice.getCreatedAt(), lastReadAt)) {
+        for (AppSystemNotice noticeRecord : noticeRecords) {
+            if (noticeRecord.getReadAt() == null) {
                 unreadCount++;
             }
         }
@@ -358,7 +524,7 @@
                 NOTICE_TYPE_ACTIVITY,
                 "活动通知",
                 trimToEmpty(latest.getTitle()),
-                latest.getCreatedAt(),
+                chooseTime(latestRecord.getCreatedAt(), latestRecord.getPushedAt(), latest.getCreatedAt()),
                 "/static/image/notice/activity.png",
                 unreadCount
         );
@@ -446,6 +612,21 @@
     }
 
     /**
+     * 构建订单状态更新详情。
+     */
+    private AppNoticeDetailVO buildOrderNoticeDetail(Long userId,
+                                                     AppUserNotificationSetting setting,
+                                                     LocalDateTime lastReadAt) {
+        if (!isEnabled(setting == null ? null : setting.getOrderStatusUpdateEnabled())) {
+            return buildNoticeDetailVO(NOTICE_TYPE_ORDER, "订单状态更新", "<p>订单状态更新通知已关闭</p>", null, Boolean.TRUE);
+        }
+        NotificationSnapshot latestSnapshot = resolveOrderSnapshot(userId);
+        String content = latestSnapshot == null ? "<p>暂无订单状态更新</p>" : wrapParagraph(latestSnapshot.getContent());
+        LocalDateTime noticeTime = latestSnapshot == null ? null : latestSnapshot.getNoticeTime();
+        return buildNoticeDetailVO(NOTICE_TYPE_ORDER, "订单状态更新", content, noticeTime, !isUnread(noticeTime, lastReadAt));
+    }
+
+    /**
      * 构建课程更新通知。
      */
     private AppNoticeItemVO buildCourseUpdateNotice(Long userId,
@@ -524,6 +705,21 @@
     }
 
     /**
+     * 构建课程更新通知详情。
+     */
+    private AppNoticeDetailVO buildCourseUpdateNoticeDetail(Long userId,
+                                                            AppUserNotificationSetting setting,
+                                                            LocalDateTime lastReadAt) {
+        if (!isEnabled(setting == null ? null : setting.getCourseUpdateEnabled())) {
+            return buildNoticeDetailVO(NOTICE_TYPE_COURSE_UPDATE, "课程更新通知", "<p>课程更新通知已关闭</p>", null, Boolean.TRUE);
+        }
+        NotificationSnapshot latestSnapshot = resolveCourseUpdateSnapshot(userId);
+        String content = latestSnapshot == null ? "<p>暂无课程更新通知</p>" : wrapParagraph(latestSnapshot.getContent());
+        LocalDateTime noticeTime = latestSnapshot == null ? null : latestSnapshot.getNoticeTime();
+        return buildNoticeDetailVO(NOTICE_TYPE_COURSE_UPDATE, "课程更新通知", content, noticeTime, !isUnread(noticeTime, lastReadAt));
+    }
+
+    /**
      * 组装统一通知列表项。
      */
     private AppNoticeItemVO buildNoticeItem(String type,
@@ -541,6 +737,23 @@
         itemVO.setBadge(Integer.valueOf(Math.max(unreadCount, 0)));
         itemVO.setHasRedDot(Boolean.valueOf(unreadCount > 0));
         return itemVO;
+    }
+
+    /**
+     * 组装统一通知详情。
+     */
+    private AppNoticeDetailVO buildNoticeDetailVO(String type,
+                                                  String title,
+                                                  String contentHtml,
+                                                  LocalDateTime noticeTime,
+                                                  Boolean read) {
+        AppNoticeDetailVO detailVO = new AppNoticeDetailVO();
+        detailVO.setType(type);
+        detailVO.setTitle(title);
+        detailVO.setContentHtml(trimToEmpty(contentHtml));
+        detailVO.setCreatedAt(formatTime(noticeTime));
+        detailVO.setRead(read);
+        return detailVO;
     }
 
     /**
@@ -616,6 +829,83 @@
         }
         record.setLastReadAt(readAt);
         appUserNoticeReadRecordMapper.updateById(record);
+    }
+
+    /**
+     * 将当前用户全部活动通知标记为已读。
+     */
+    private void markAllActivityRead(Long userId, LocalDateTime readAt) {
+        appSystemNoticeMapper.update(
+                null,
+                new LambdaUpdateWrapper<AppSystemNotice>()
+                        .eq(AppSystemNotice::getAppUserId, userId)
+                        .eq(AppSystemNotice::getNoticeTypeCode, AppSystemNoticeType.ACTIVITY_NOTICE)
+                        .eq(AppSystemNotice::getDeleted, RECORD_DELETED_NO)
+                        .isNull(AppSystemNotice::getReadAt)
+                        .set(AppSystemNotice::getReadAt, readAt)
+        );
+    }
+
+    /**
+     * 校验当前用户可访问的活动通知记录。
+     */
+    private AppUserActivityNotice requireActivityNoticeRecord(Long userId, Long id) {
+        AppUserActivityNotice record = appUserActivityNoticeMapper.selectOne(new LambdaQueryWrapper<AppUserActivityNotice>()
+                .eq(AppUserActivityNotice::getId, id)
+                .eq(AppUserActivityNotice::getAppUserId, userId)
+                .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)
+                .last("limit 1"));
+        if (record == null) {
+            throw new IllegalArgumentException("通知不存在");
+        }
+        requireActivityNotice(record.getActivityNoticeId());
+        return record;
+    }
+
+    /**
+     * 校验当前用户可访问的统一活动通知记录。
+     */
+    private AppSystemNotice requireActivitySystemNoticeRecord(Long userId, Long id) {
+        AppSystemNotice record = appSystemNoticeMapper.selectOne(new LambdaQueryWrapper<AppSystemNotice>()
+                .eq(AppSystemNotice::getId, id)
+                .eq(AppSystemNotice::getAppUserId, userId)
+                .eq(AppSystemNotice::getNoticeTypeCode, AppSystemNoticeType.ACTIVITY_NOTICE)
+                .eq(AppSystemNotice::getDeleted, RECORD_DELETED_NO)
+                .last("limit 1"));
+        if (record == null) {
+            throw new IllegalArgumentException("通知不存在");
+        }
+        requireActivityNotice(record.getReferenceId());
+        return record;
+    }
+
+    /**
+     * 校验活动通知主表记录仍然有效。
+     */
+    private ActivityNotice requireActivityNotice(Long activityNoticeId) {
+        if (activityNoticeId == null) {
+            return null;
+        }
+        ActivityNotice notice = activityNoticeMapper.selectOne(new LambdaQueryWrapper<ActivityNotice>()
+                .eq(ActivityNotice::getId, activityNoticeId)
+                .eq(ActivityNotice::getDeleted, RECORD_DELETED_NO)
+                .last("limit 1"));
+        if (notice == null) {
+            throw new IllegalArgumentException("通知不存在");
+        }
+        return notice;
+    }
+
+    /**
+     * 组装活动通知列表项。
+     */
+    private AppActivityNoticeListItemVO toActivityListItem(AppSystemNotice record, ActivityNotice notice) {
+        AppActivityNoticeListItemVO itemVO = new AppActivityNoticeListItemVO();
+        itemVO.setId(record.getId());
+        itemVO.setTitle(trimToEmpty(notice.getTitle()));
+        itemVO.setCreatedAt(formatTime(chooseTime(record.getCreatedAt(), record.getPushedAt(), notice.getCreatedAt())));
+        itemVO.setRead(Boolean.valueOf(record.getReadAt() != null));
+        return itemVO;
     }
 
     /**
@@ -700,6 +990,169 @@
     }
 
     /**
+     * 解析直播提醒最新快照。
+     */
+    private NotificationSnapshot resolveLiveReserveSnapshot(Long userId, AppUserNotificationSetting setting) {
+        List<LiveReserve> reserves = liveReserveMapper.selectList(new LambdaQueryWrapper<LiveReserve>()
+                .eq(LiveReserve::getUserId, userId)
+                .eq(LiveReserve::getIsCancel, 0)
+                .orderByDesc(LiveReserve::getReserveTime)
+                .orderByDesc(LiveReserve::getId));
+        if (reserves == null || reserves.isEmpty()) {
+            return null;
+        }
+        List<Long> sessionIds = new ArrayList<Long>();
+        for (LiveReserve reserve : reserves) {
+            if (reserve.getSessionId() != null) {
+                sessionIds.add(reserve.getSessionId());
+            }
+        }
+        if (sessionIds.isEmpty()) {
+            return null;
+        }
+        List<LiveSession> sessions = liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>()
+                .in(LiveSession::getId, sessionIds)
+                .eq(LiveSession::getDeleted, 0));
+        if (sessions == null || sessions.isEmpty()) {
+            return null;
+        }
+        Map<Long, LiveSession> sessionMap = new HashMap<Long, LiveSession>();
+        for (LiveSession session : sessions) {
+            sessionMap.put(session.getId(), session);
+        }
+        List<Long> courseIds = new ArrayList<Long>();
+        for (LiveSession session : sessions) {
+            if (session.getLiveCourseId() != null) {
+                courseIds.add(session.getLiveCourseId());
+            }
+        }
+        Map<Long, LiveCourse> courseMap = getLiveCourseMap(courseIds);
+        NotificationSnapshot snapshot = null;
+        LocalDateTime now = LocalDateTime.now();
+        Integer advanceMinutes = setting == null ? null : setting.getAdvanceNoticeMinutes();
+        int normalizedAdvanceMinutes = advanceMinutes == null || advanceMinutes.intValue() <= 0 ? 60 : advanceMinutes.intValue();
+        for (LiveReserve reserve : reserves) {
+            LiveSession session = sessionMap.get(reserve.getSessionId());
+            if (session == null || session.getStartTime() == null || session.getLiveStatus() == null) {
+                continue;
+            }
+            if (session.getLiveStatus().intValue() != LIVE_STATUS_PENDING) {
+                continue;
+            }
+            LocalDateTime remindTime = session.getStartTime().minusMinutes(normalizedAdvanceMinutes);
+            if (now.isBefore(remindTime)) {
+                continue;
+            }
+            LiveCourse liveCourse = courseMap.get(session.getLiveCourseId());
+            String title = trimToEmpty(liveCourse == null ? null : liveCourse.getTitle());
+            String content = title.isEmpty() ? "您预约的直播即将开播。" : "您预约的【" + title + "】即将开播。";
+            NotificationSnapshot current = new NotificationSnapshot(content, session.getStartTime());
+            if (snapshot == null || compareTime(current.getNoticeTime(), snapshot.getNoticeTime()) > 0) {
+                snapshot = current;
+            }
+        }
+        return snapshot;
+    }
+
+    /**
+     * 解析订单状态更新最新快照。
+     */
+    private NotificationSnapshot resolveOrderSnapshot(Long userId) {
+        NotificationSnapshot latestSnapshot = null;
+        List<PointsExchangeOrder> exchangeOrders = pointsExchangeOrderMapper.selectList(new LambdaQueryWrapper<PointsExchangeOrder>()
+                .eq(PointsExchangeOrder::getClinicAppUserId, userId)
+                .eq(PointsExchangeOrder::getDeleted, 0)
+                .orderByDesc(PointsExchangeOrder::getUpdatedAt)
+                .orderByDesc(PointsExchangeOrder::getId));
+        for (PointsExchangeOrder order : exchangeOrders) {
+            LocalDateTime noticeTime = chooseTime(order.getShippedAt(), order.getUpdatedAt(), order.getCreatedAt());
+            NotificationSnapshot snapshot = new NotificationSnapshot(buildExchangeOrderContent(order), noticeTime);
+            if (latestSnapshot == null || compareTime(snapshot.getNoticeTime(), latestSnapshot.getNoticeTime()) > 0) {
+                latestSnapshot = snapshot;
+            }
+        }
+        List<MemberPurchaseOrder> memberOrders = memberPurchaseOrderMapper.selectList(new LambdaQueryWrapper<MemberPurchaseOrder>()
+                .eq(MemberPurchaseOrder::getAppUserId, userId)
+                .eq(MemberPurchaseOrder::getDeleted, 0)
+                .orderByDesc(MemberPurchaseOrder::getUpdatedAt)
+                .orderByDesc(MemberPurchaseOrder::getId));
+        for (MemberPurchaseOrder order : memberOrders) {
+            LocalDateTime noticeTime = chooseTime(order.getPaidAt(), order.getUpdatedAt(), order.getCreatedAt());
+            NotificationSnapshot snapshot = new NotificationSnapshot("您的会员订单状态已更新", noticeTime);
+            if (latestSnapshot == null || compareTime(snapshot.getNoticeTime(), latestSnapshot.getNoticeTime()) > 0) {
+                latestSnapshot = snapshot;
+            }
+        }
+        List<CoursePurchaseOrder> courseOrders = coursePurchaseOrderMapper.selectList(new LambdaQueryWrapper<CoursePurchaseOrder>()
+                .eq(CoursePurchaseOrder::getAppUserId, userId)
+                .eq(CoursePurchaseOrder::getDeleted, 0)
+                .orderByDesc(CoursePurchaseOrder::getUpdatedAt)
+                .orderByDesc(CoursePurchaseOrder::getId));
+        for (CoursePurchaseOrder order : courseOrders) {
+            LocalDateTime noticeTime = chooseTime(order.getPaidAt(), order.getUpdatedAt(), order.getCreatedAt());
+            String title = trimToEmpty(order.getProductNameSnapshot());
+            String content = title.isEmpty() ? "您的课程订单状态已更新" : "您的【" + title + "】订单状态已更新";
+            NotificationSnapshot snapshot = new NotificationSnapshot(content, noticeTime);
+            if (latestSnapshot == null || compareTime(snapshot.getNoticeTime(), latestSnapshot.getNoticeTime()) > 0) {
+                latestSnapshot = snapshot;
+            }
+        }
+        return latestSnapshot;
+    }
+
+    /**
+     * 解析课程更新通知最新快照。
+     */
+    private NotificationSnapshot resolveCourseUpdateSnapshot(Long userId) {
+        List<Long> ownedCourseIds = resolveOwnedCourseIds(userId);
+        if (ownedCourseIds.isEmpty()) {
+            return null;
+        }
+        List<HealthCourse> courses = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>()
+                .in(HealthCourse::getId, ownedCourseIds)
+                .eq(HealthCourse::getDeleted, 0)
+                .eq(HealthCourse::getStatus, COURSE_STATUS_ENABLED));
+        if (courses == null || courses.isEmpty()) {
+            return null;
+        }
+        List<HealthCourseVideo> videos = healthCourseVideoMapper.selectList(new LambdaQueryWrapper<HealthCourseVideo>()
+                .in(HealthCourseVideo::getCourseId, ownedCourseIds)
+                .eq(HealthCourseVideo::getDeleted, 0)
+                .orderByDesc(HealthCourseVideo::getUpdatedAt)
+                .orderByDesc(HealthCourseVideo::getId));
+        Map<Long, HealthCourse> courseMap = new HashMap<Long, HealthCourse>();
+        for (HealthCourse course : courses) {
+            courseMap.put(course.getId(), course);
+        }
+        NotificationSnapshot latestSnapshot = null;
+        for (HealthCourseVideo video : videos) {
+            HealthCourse course = courseMap.get(video.getCourseId());
+            if (course == null) {
+                continue;
+            }
+            LocalDateTime noticeTime = chooseTime(video.getUpdatedAt(), course.getUpdatedAt(), course.getCreatedAt());
+            String title = trimToEmpty(course.getTitle());
+            String content = title.isEmpty() ? "您的课程有更新" : "你的【" + title + "】有更新";
+            NotificationSnapshot snapshot = new NotificationSnapshot(content, noticeTime);
+            if (latestSnapshot == null || compareTime(snapshot.getNoticeTime(), latestSnapshot.getNoticeTime()) > 0) {
+                latestSnapshot = snapshot;
+            }
+        }
+        return latestSnapshot;
+    }
+
+    /**
+     * 将纯文本内容包装为段落,便于 APP 统一使用 rich-text 渲染。
+     */
+    private String wrapParagraph(String content) {
+        String safeContent = trimToEmpty(content);
+        if (safeContent.isEmpty()) {
+            return "<p>暂无通知内容</p>";
+        }
+        return "<p>" + safeContent + "</p>";
+    }
+
+    /**
      * 判断通知开关是否开启。
      */
     private boolean isEnabled(Integer value) {

--
Gitblit v1.7.1