“mitao”
2026-04-20 ed527bae283588de0a9848390348e93c3820fef9
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) {