13404089107
2026-04-20 f4b6843a1b3990453d2aef6d87b5ac30723c5870
Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
1个文件已删除
28个文件已添加
25个文件已修改
3555 ■■■■■ 已修改文件
doc/nginx-tcm-training.conf 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/pom.xml 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/update_app_system_notice_schema.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/update_app_user_activity_notice_schema.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/TcmTrainingAdminApplication.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAliyunSmsProperties.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppSmsAuthProperties.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSender.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/integration/haizhentong/entity/HztClinic.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/controller/AppNoticeController.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/entity/AppSystemNotice.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/entity/AppUserActivityNotice.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/mapper/AppSystemNoticeMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/mapper/AppUserActivityNoticeMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppNoticeService.java 473 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeDispatchCommand.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeDispatchService.java 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeType.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppActivityNoticeDetailVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppActivityNoticeListItemVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppNoticeDetailVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/AppDownloadPageController.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/common/Jsons.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/AsyncTaskExecutorConfig.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveRemindDispatchService.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberExpireRemindDispatchService.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/dto/ExchangeShipRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/service/PointsExchangeAppService.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/service/PointsSettlementService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/event/ActivityNoticeCreatedEvent.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/MsgUtils.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application-local.example.yml 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application-prod.yml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSenderTest.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-exchange/shared.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/activityNotice/activityNotice.vue 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/activityNotice/activityNoticeDetail.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/activityNotice/service.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/notice/notice.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/notice/noticeDetail.vue 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/notice/service.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/publish/publish.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nginx-tcm-training.conf
@@ -38,6 +38,22 @@
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    # 邀请二维码落地页入口,补全尾部斜杠时保留邀请码查询参数。
    location = /app-download {
        return 301 /app-download/$is_args$args;
    }
    # 邀请下载页由 Spring Boot 内置静态资源提供,页面内部仍会请求 /api/open/app-invite/landing。
    location /app-download/ {
        proxy_pass http://127.0.0.1:48080;
        proxy_read_timeout 60s;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
# 管理后台部署补充说明:
@@ -46,4 +62,5 @@
# 3. 线上部署目录建议:/usr/local/project/tcm-training/admin/
# 4. 线上访问地址:https://haizhentong.com/admin/
# 5. 后端接口地址:https://haizhentong.com/api/
# 6. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。
# 6. 邀请二维码落地页地址:https://haizhentong.com/app-download/?inviteCode=XXXXXX
# 7. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。
tcm-training-admin-backend/pom.xml
@@ -130,6 +130,17 @@
            <artifactId>jiguang-sdk</artifactId>
            <version>5.3.0</version>
        </dependency>
        <!-- 阿里云短信 -->
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>dysmsapi20170525</artifactId>
            <version>2.0.10</version>
        </dependency>
        <dependency>
            <groupId>cn.jpush.api</groupId>
            <artifactId>jpush-client</artifactId>
            <version>3.3.10</version>
        </dependency>
    </dependencies>
    <build>
tcm-training-admin-backend/sql/update_app_system_notice_schema.sql
New file
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `tcm_app_system_notice` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `app_user_id` bigint NOT NULL COMMENT 'APP用户ID',
  `notice_type_code` varchar(64) NOT NULL COMMENT '通知类型编码',
  `title` varchar(128) NOT NULL COMMENT '通知标题',
  `content` varchar(500) NOT NULL COMMENT '通知摘要内容',
  `reference_type` varchar(64) DEFAULT NULL COMMENT '业务引用类型',
  `reference_id` bigint DEFAULT NULL COMMENT '业务引用主键',
  `payload_json` text COMMENT '透传载荷JSON',
  `push_status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '推送状态:PENDING/SUCCESS/FAILED/SKIPPED',
  `push_error_message` varchar(500) DEFAULT NULL COMMENT '推送失败原因',
  `pushed_at` datetime DEFAULT NULL COMMENT '推送时间',
  `read_at` datetime DEFAULT NULL COMMENT '已读时间',
  `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除:0否 1是',
  `created_by` varchar(64) DEFAULT NULL COMMENT '创建人',
  `updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_app_notice_ref_deleted` (`app_user_id`,`notice_type_code`,`reference_type`,`reference_id`,`deleted`),
  KEY `idx_app_notice_user_created` (`app_user_id`,`deleted`,`created_at`),
  KEY `idx_app_notice_type_created` (`notice_type_code`,`deleted`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='APP统一系统通知表';
tcm-training-admin-backend/sql/update_app_user_activity_notice_schema.sql
New file
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `tcm_app_user_activity_notice` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `app_user_id` bigint NOT NULL COMMENT 'APP用户ID',
  `activity_notice_id` bigint NOT NULL COMMENT '活动通知主键',
  `read_at` datetime DEFAULT NULL COMMENT '已读时间',
  `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除:0否 1是',
  `created_by` varchar(64) DEFAULT NULL COMMENT '创建人',
  `updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_app_user_activity_notice_deleted` (`app_user_id`,`activity_notice_id`,`deleted`),
  KEY `idx_app_user_created_at` (`app_user_id`,`deleted`,`created_at`),
  KEY `idx_activity_notice_deleted` (`activity_notice_id`,`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='APP用户活动通知记录表';
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/TcmTrainingAdminApplication.java
@@ -3,9 +3,11 @@
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan({
        "com.tcm.training.admin.user.mapper",
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAliyunSmsProperties.java
New file
@@ -0,0 +1,140 @@
package com.tcm.training.admin.app.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * APP 阿里云短信发送配置。
 */
@Component
@ConfigurationProperties(prefix = "app.auth.sms.aliyun")
public class AppAliyunSmsProperties {
    private String accessKeyId = "";
    private String accessKeySecret = "";
    private String signName = "";
    private Templates templates = new Templates();
    /**
     * 获取阿里云 AccessKey ID。
     */
    public String getAccessKeyId() {
        return accessKeyId;
    }
    /**
     * 设置阿里云 AccessKey ID。
     */
    public void setAccessKeyId(String accessKeyId) {
        this.accessKeyId = accessKeyId;
    }
    /**
     * 获取阿里云 AccessKey Secret。
     */
    public String getAccessKeySecret() {
        return accessKeySecret;
    }
    /**
     * 设置阿里云 AccessKey Secret。
     */
    public void setAccessKeySecret(String accessKeySecret) {
        this.accessKeySecret = accessKeySecret;
    }
    /**
     * 获取短信签名。
     */
    public String getSignName() {
        return signName;
    }
    /**
     * 设置短信签名。
     */
    public void setSignName(String signName) {
        this.signName = signName;
    }
    /**
     * 获取验证码模板配置。
     */
    public Templates getTemplates() {
        return templates;
    }
    /**
     * 设置验证码模板配置。
     */
    public void setTemplates(Templates templates) {
        this.templates = templates;
    }
    /**
     * 四类验证码模板编码配置。
     */
    public static class Templates {
        private String login = "";
        private String register = "";
        private String passwordReset = "";
        private String phoneChange = "";
        /**
         * 获取登录验证码模板编码。
         */
        public String getLogin() {
            return login;
        }
        /**
         * 设置登录验证码模板编码。
         */
        public void setLogin(String login) {
            this.login = login;
        }
        /**
         * 获取注册验证码模板编码。
         */
        public String getRegister() {
            return register;
        }
        /**
         * 设置注册验证码模板编码。
         */
        public void setRegister(String register) {
            this.register = register;
        }
        /**
         * 获取忘记密码验证码模板编码。
         */
        public String getPasswordReset() {
            return passwordReset;
        }
        /**
         * 设置忘记密码验证码模板编码。
         */
        public void setPasswordReset(String passwordReset) {
            this.passwordReset = passwordReset;
        }
        /**
         * 获取修改手机号验证码模板编码。
         */
        public String getPhoneChange() {
            return phoneChange;
        }
        /**
         * 设置修改手机号验证码模板编码。
         */
        public void setPhoneChange(String phoneChange) {
            this.phoneChange = phoneChange;
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppSmsAuthProperties.java
@@ -10,9 +10,14 @@
@ConfigurationProperties(prefix = "app.auth.sms")
public class AppSmsAuthProperties {
    /**
     * 短信验证码有效期,默认 300 秒,即 5 分钟。
     */
    private int expireSeconds = 300;
    private boolean debugEnabled = false;
    private String debugCode = "";
    private DebugScene login = new DebugScene();
    private DebugScene register = new DebugScene();
    private DebugScene passwordReset = new DebugScene();
    private DebugScene phoneChange = new DebugScene();
    /**
     * 获取验证码有效期秒数。
@@ -29,30 +34,95 @@
    }
    /**
     * 获取是否启用开发调试验证码。
     * 获取短信登录调试验证码配置。
     */
    public boolean isDebugEnabled() {
        return debugEnabled;
    public DebugScene getLogin() {
        return login;
    }
    /**
     * 设置是否启用开发调试验证码。
     * 设置短信登录调试验证码配置。
     */
    public void setDebugEnabled(boolean debugEnabled) {
        this.debugEnabled = debugEnabled;
    public void setLogin(DebugScene login) {
        this.login = login;
    }
    /**
     * 获取开发调试验证码。
     * 获取注册调试验证码配置。
     */
    public String getDebugCode() {
        return debugCode;
    public DebugScene getRegister() {
        return register;
    }
    /**
     * 设置开发调试验证码。
     * 设置注册调试验证码配置。
     */
    public void setDebugCode(String debugCode) {
        this.debugCode = debugCode;
    public void setRegister(DebugScene register) {
        this.register = register;
    }
    /**
     * 获取重置密码调试验证码配置。
     */
    public DebugScene getPasswordReset() {
        return passwordReset;
    }
    /**
     * 设置重置密码调试验证码配置。
     */
    public void setPasswordReset(DebugScene passwordReset) {
        this.passwordReset = passwordReset;
    }
    /**
     * 获取修改手机号调试验证码配置。
     */
    public DebugScene getPhoneChange() {
        return phoneChange;
    }
    /**
     * 设置修改手机号调试验证码配置。
     */
    public void setPhoneChange(DebugScene phoneChange) {
        this.phoneChange = phoneChange;
    }
    /**
     * 单个短信验证码场景的调试配置。
     */
    public static class DebugScene {
        private boolean enabled = false;
        private String code = "";
        /**
         * 获取当前场景是否启用调试验证码。
         */
        public boolean isEnabled() {
            return enabled;
        }
        /**
         * 设置当前场景是否启用调试验证码。
         */
        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }
        /**
         * 获取当前场景调试验证码。
         */
        public String getCode() {
            return code;
        }
        /**
         * 设置当前场景调试验证码。
         */
        public void setCode(String code) {
            this.code = code;
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSender.java
New file
@@ -0,0 +1,100 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.utils.MsgUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Map;
/**
 * APP 短信发送阿里云实现。
 */
@Service
public class AliyunAppSmsSender implements AppSmsSender {
    private static final Logger log = LoggerFactory.getLogger(AliyunAppSmsSender.class);
    private final MsgUtils msgUtils;
    private final AppAliyunSmsProperties properties;
    /**
     * 注入阿里云短信工具与模板配置。
     */
    public AliyunAppSmsSender(MsgUtils msgUtils, AppAliyunSmsProperties properties) {
        this.msgUtils = msgUtils;
        this.properties = properties;
    }
    /**
     * 发送 APP 登录验证码。
     */
    @Override
    public void sendLoginCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getLogin(), "登录");
    }
    /**
     * 发送 APP 注册验证码。
     */
    @Override
    public void sendRegisterCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getRegister(), "注册");
    }
    /**
     * 发送 APP 忘记密码验证码。
     */
    @Override
    public void sendPasswordResetCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getPasswordReset(), "忘记密码");
    }
    /**
     * 发送 APP 修改手机号验证码。
     */
    @Override
    public void sendPhoneChangeCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getPhoneChange(), "修改手机号");
    }
    /**
     * 按场景发送验证码短信,并统一处理配置校验与日志。
     */
    private void sendCode(String phone, String code, String templateCode, String sceneName) {
        validateSmsConfig(templateCode);
        Map<String, String> params = Collections.singletonMap("code", code);
        try {
            msgUtils.sendMsg(phone, params, templateCode, properties.getSignName());
            log.info("APP{}验证码发送成功,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode);
        } catch (Exception exception) {
            log.error("APP{}验证码发送失败,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode, exception);
            if (exception instanceof IllegalStateException) {
                throw (IllegalStateException) exception;
            }
            throw new IllegalStateException("短信发送失败,请稍后重试");
        }
    }
    /**
     * 校验阿里云短信发送配置是否完整。
     */
    private void validateSmsConfig(String templateCode) {
        if (isBlank(properties.getAccessKeyId())
                || isBlank(properties.getAccessKeySecret())
                || isBlank(properties.getSignName())
                || isBlank(templateCode)) {
            throw new IllegalStateException("短信发送配置不完整");
        }
    }
    /**
     * 判断字符串是否为空白。
     */
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
@@ -14,6 +14,10 @@
@Service
public class AppSmsCodeService {
    private static final String SCENE_LOGIN = "login";
    private static final String SCENE_REGISTER = "register";
    private static final String SCENE_PASSWORD_RESET = "password-reset";
    private static final String SCENE_PHONE_CHANGE = "phone-change";
    private static final String LOGIN_CODE_CACHE_PREFIX = "app:sms:login:";
    private static final String REGISTER_CODE_CACHE_PREFIX = "app:sms:register:";
    private static final String PASSWORD_RESET_CODE_CACHE_PREFIX = "app:sms:password-reset:";
@@ -66,28 +70,28 @@
     * 校验登录验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validateLoginCode(String phone, String code) {
        validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX);
        validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX, SCENE_LOGIN);
    }
    /**
     * 校验注册验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validateRegisterCode(String phone, String code) {
        validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX);
        validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX, SCENE_REGISTER);
    }
    /**
     * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validatePasswordResetCode(String phone, String code) {
        validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX);
        validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX, SCENE_PASSWORD_RESET);
    }
    /**
     * 校验修改手机号验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validatePhoneChangeCode(String phone, String code) {
        validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX);
        validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX, SCENE_PHONE_CHANGE);
    }
    /**
@@ -95,30 +99,30 @@
     */
    private AppSmsCodeSendResponse createCode(String phone, String cachePrefix, String scene) {
        String code = generateCode();
        if (SCENE_LOGIN.equals(scene)) {
            appSmsSender.sendLoginCode(phone, code);
        } else if (SCENE_REGISTER.equals(scene)) {
            appSmsSender.sendRegisterCode(phone, code);
        } else if (SCENE_PASSWORD_RESET.equals(scene)) {
            appSmsSender.sendPasswordResetCode(phone, code);
        } else {
            appSmsSender.sendPhoneChangeCode(phone, code);
        }
        stringRedisTemplate.opsForValue().set(
                buildCacheKey(cachePrefix, phone),
                code,
                appSmsAuthProperties.getExpireSeconds(),
                TimeUnit.SECONDS
        );
        if ("login".equals(scene)) {
            appSmsSender.sendLoginCode(phone, code);
        } else if ("register".equals(scene)) {
            appSmsSender.sendRegisterCode(phone, code);
        } else if ("password-reset".equals(scene)) {
            appSmsSender.sendPasswordResetCode(phone, code);
        } else {
            appSmsSender.sendPhoneChangeCode(phone, code);
        }
        return new AppSmsCodeSendResponse(appSmsAuthProperties.getExpireSeconds());
    }
    /**
     * 统一校验指定场景的验证码并执行一次性销毁。
     */
    private void validateCode(String phone, String code, String cachePrefix) {
        if (matchesDebugCode(code)) {
            // 开发环境允许固定验证码直通,便于前后端联调。
    private void validateCode(String phone, String code, String cachePrefix, String scene) {
        if (matchesDebugCode(scene, code)) {
            // 调试验证码按场景控制,生产环境仅放开短信登录场景。
            return;
        }
        String cacheKey = buildCacheKey(cachePrefix, phone);
@@ -142,17 +146,37 @@
    }
    /**
     * 判断是否命中本地调试验证码。
     * 判断当前场景是否命中调试验证码。
     */
    private boolean matchesDebugCode(String code) {
        if (!appSmsAuthProperties.isDebugEnabled()) {
    private boolean matchesDebugCode(String scene, String code) {
        AppSmsAuthProperties.DebugScene debugScene = resolveDebugScene(scene);
        if (debugScene == null || !debugScene.isEnabled()) {
            return false;
        }
        String debugCode = appSmsAuthProperties.getDebugCode();
        String debugCode = debugScene.getCode();
        return debugCode != null && !debugCode.trim().isEmpty() && debugCode.equals(code);
    }
    /**
     * 读取指定场景的调试验证码配置。
     */
    private AppSmsAuthProperties.DebugScene resolveDebugScene(String scene) {
        if (SCENE_LOGIN.equals(scene)) {
            return appSmsAuthProperties.getLogin();
        }
        if (SCENE_REGISTER.equals(scene)) {
            return appSmsAuthProperties.getRegister();
        }
        if (SCENE_PASSWORD_RESET.equals(scene)) {
            return appSmsAuthProperties.getPasswordReset();
        }
        if (SCENE_PHONE_CHANGE.equals(scene)) {
            return appSmsAuthProperties.getPhoneChange();
        }
        return null;
    }
    /**
     * 构造手机号维度的验证码缓存 key。
     */
    private String buildCacheKey(String cachePrefix, String phone) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java
File was deleted
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/integration/haizhentong/entity/HztClinic.java
@@ -45,8 +45,6 @@
    @TableField("district_name")
    private String districtName;
    private String address;
    @TableField("clinic_address")
    private String clinicAddress;
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/controller/AppNoticeController.java
@@ -1,8 +1,12 @@
package com.tcm.training.admin.app.notice.controller;
import com.tcm.training.admin.app.notice.service.AppNoticeService;
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.common.ApiResponse;
import com.tcm.training.admin.common.PageResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -54,4 +58,38 @@
        appNoticeService.markRead(noticeType);
        return ApiResponse.ok(null);
    }
    /**
     * 获取指定类型通知详情。
     */
    @GetMapping("/{noticeType}/detail")
    public ApiResponse<AppNoticeDetailVO> detail(@PathVariable("noticeType") String noticeType) {
        return ApiResponse.ok(appNoticeService.getNoticeDetail(noticeType));
    }
    /**
     * 获取当前登录用户的活动通知分页列表。
     */
    @GetMapping("/activity")
    public ApiResponse<PageResponse<AppActivityNoticeListItemVO>> activityPage(@org.springframework.web.bind.annotation.RequestParam(defaultValue = "1") int page,
                                                                               @org.springframework.web.bind.annotation.RequestParam(defaultValue = "10") int pageSize) {
        return ApiResponse.ok(appNoticeService.pageActivityNotices(page, pageSize));
    }
    /**
     * 获取当前登录用户的活动通知详情。
     */
    @GetMapping("/activity/{id}")
    public ApiResponse<AppActivityNoticeDetailVO> activityDetail(@PathVariable("id") Long id) {
        return ApiResponse.ok(appNoticeService.getActivityNoticeDetail(id));
    }
    /**
     * 将当前登录用户的一条活动通知标记为已读。
     */
    @PostMapping("/activity/{id}/read")
    public ApiResponse<Void> markActivityRead(@PathVariable("id") Long id) {
        appNoticeService.markActivityRead(id);
        return ApiResponse.ok(null);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/entity/AppSystemNotice.java
New file
@@ -0,0 +1,74 @@
package com.tcm.training.admin.app.notice.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 lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/**
 * APP 统一系统通知实体。
 */
@TableName("tcm_app_system_notice")
@Getter
@Setter
@NoArgsConstructor
public class AppSystemNotice {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("app_user_id")
    private Long appUserId;
    @TableField("notice_type_code")
    private String noticeTypeCode;
    @TableField("title")
    private String title;
    @TableField("content")
    private String content;
    @TableField("reference_type")
    private String referenceType;
    @TableField("reference_id")
    private Long referenceId;
    @TableField("payload_json")
    private String payloadJson;
    @TableField("push_status")
    private String pushStatus;
    @TableField("push_error_message")
    private String pushErrorMessage;
    @TableField("pushed_at")
    private LocalDateTime pushedAt;
    @TableField("read_at")
    private LocalDateTime readAt;
    @TableLogic
    private Integer deleted;
    @TableField(value = "created_by", fill = FieldFill.INSERT)
    private String createdBy;
    @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
    private String updatedBy;
    @TableField("created_at")
    private LocalDateTime createdAt;
    @TableField("updated_at")
    private LocalDateTime updatedAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/entity/AppUserActivityNotice.java
New file
@@ -0,0 +1,50 @@
package com.tcm.training.admin.app.notice.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 lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/**
 * APP 用户活动通知记录实体。
 */
@TableName("tcm_app_user_activity_notice")
@Getter
@Setter
@NoArgsConstructor
public class AppUserActivityNotice {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("app_user_id")
    private Long appUserId;
    @TableField("activity_notice_id")
    private Long activityNoticeId;
    @TableField("read_at")
    private LocalDateTime readAt;
    @TableLogic
    private Integer deleted;
    @TableField(value = "created_by", fill = FieldFill.INSERT)
    private String createdBy;
    @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
    private String updatedBy;
    @TableField("created_at")
    private LocalDateTime createdAt;
    @TableField("updated_at")
    private LocalDateTime updatedAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/mapper/AppSystemNoticeMapper.java
New file
@@ -0,0 +1,12 @@
package com.tcm.training.admin.app.notice.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.app.notice.entity.AppSystemNotice;
import org.apache.ibatis.annotations.Mapper;
/**
 * APP 统一系统通知 Mapper。
 */
@Mapper
public interface AppSystemNoticeMapper extends BaseMapper<AppSystemNotice> {
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/mapper/AppUserActivityNoticeMapper.java
New file
@@ -0,0 +1,12 @@
package com.tcm.training.admin.app.notice.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.app.notice.entity.AppUserActivityNotice;
import org.apache.ibatis.annotations.Mapper;
/**
 * APP 用户活动通知记录 Mapper。
 */
@Mapper
public interface AppUserActivityNoticeMapper extends BaseMapper<AppUserActivityNotice> {
}
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) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeDispatchCommand.java
New file
@@ -0,0 +1,24 @@
package com.tcm.training.admin.app.notice.service;
import lombok.Getter;
import lombok.Setter;
import java.util.Map;
/**
 * APP 系统通知发放命令。
 */
@Getter
@Setter
public class AppSystemNoticeDispatchCommand {
    private Long appUserId;
    private String noticeTypeCode;
    private String title;
    private String content;
    private String referenceType;
    private Long referenceId;
    private Map<String, String> payload;
    private Boolean requireUserSetting;
    private String pushAlias;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeDispatchService.java
New file
@@ -0,0 +1,241 @@
package com.tcm.training.admin.app.notice.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.notice.entity.AppSystemNotice;
import com.tcm.training.admin.app.notice.mapper.AppSystemNoticeMapper;
import com.tcm.training.admin.app.settings.entity.AppUserNotificationSetting;
import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
import com.tcm.training.admin.common.Jsons;
import com.tcm.training.admin.system.entity.MessagePushConfig;
import com.tcm.training.admin.system.mapper.MessagePushConfigMapper;
import com.tcm.training.admin.utils.JGPushUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * APP 统一系统通知发放服务。
 */
@Service
public class AppSystemNoticeDispatchService {
    private static final int DELETED_NO = 0;
    private static final int ENABLED_YES = 1;
    private static final String PUSH_STATUS_PENDING = "PENDING";
    private static final String PUSH_STATUS_SUCCESS = "SUCCESS";
    private static final String PUSH_STATUS_FAILED = "FAILED";
    private static final String PUSH_STATUS_SKIPPED = "SKIPPED";
    private final AppSystemNoticeMapper appSystemNoticeMapper;
    private final MessagePushConfigMapper messagePushConfigMapper;
    private final AppUserNotificationSettingMapper appUserNotificationSettingMapper;
    private final JGPushUtil jgPushUtil;
    /**
     * 注入统一通知发放所需依赖。
     */
    public AppSystemNoticeDispatchService(AppSystemNoticeMapper appSystemNoticeMapper,
                                          MessagePushConfigMapper messagePushConfigMapper,
                                          AppUserNotificationSettingMapper appUserNotificationSettingMapper,
                                          JGPushUtil jgPushUtil) {
        this.appSystemNoticeMapper = appSystemNoticeMapper;
        this.messagePushConfigMapper = messagePushConfigMapper;
        this.appUserNotificationSettingMapper = appUserNotificationSettingMapper;
        this.jgPushUtil = jgPushUtil;
    }
    /**
     * 为单个用户创建系统通知,并按双开关规则决定是否触发极光推送。
     */
    @Transactional
    public AppSystemNotice createAndPush(AppSystemNoticeDispatchCommand command) {
        validateCommand(command);
        AppSystemNotice existing = findExistingNotice(command);
        if (existing != null) {
            return existing;
        }
        AppSystemNotice notice = buildNotice(command);
        appSystemNoticeMapper.insert(notice);
        handlePush(notice, command);
        return notice;
    }
    /**
     * 校验发放命令完整性。
     */
    private void validateCommand(AppSystemNoticeDispatchCommand command) {
        if (command == null || command.getAppUserId() == null) {
            throw new IllegalArgumentException("通知接收用户不能为空");
        }
        if (trimToEmpty(command.getNoticeTypeCode()).isEmpty()) {
            throw new IllegalArgumentException("通知类型不能为空");
        }
        if (trimToEmpty(command.getTitle()).isEmpty()) {
            throw new IllegalArgumentException("通知标题不能为空");
        }
        if (trimToEmpty(command.getContent()).isEmpty()) {
            throw new IllegalArgumentException("通知内容不能为空");
        }
    }
    /**
     * 查询同一业务引用是否已生成过通知,保证通知发放幂等。
     */
    private AppSystemNotice findExistingNotice(AppSystemNoticeDispatchCommand command) {
        if (trimToEmpty(command.getReferenceType()).isEmpty() || command.getReferenceId() == null) {
            return null;
        }
        return appSystemNoticeMapper.selectOne(new LambdaQueryWrapper<AppSystemNotice>()
                .eq(AppSystemNotice::getAppUserId, command.getAppUserId())
                .eq(AppSystemNotice::getNoticeTypeCode, command.getNoticeTypeCode())
                .eq(AppSystemNotice::getReferenceType, command.getReferenceType())
                .eq(AppSystemNotice::getReferenceId, command.getReferenceId())
                .eq(AppSystemNotice::getDeleted, DELETED_NO)
                .last("limit 1"));
    }
    /**
     * 构造待落库的系统通知实体。
     */
    private AppSystemNotice buildNotice(AppSystemNoticeDispatchCommand command) {
        AppSystemNotice notice = new AppSystemNotice();
        notice.setAppUserId(command.getAppUserId());
        notice.setNoticeTypeCode(command.getNoticeTypeCode());
        notice.setTitle(trimToEmpty(command.getTitle()));
        notice.setContent(trimToEmpty(command.getContent()));
        notice.setReferenceType(trimToNull(command.getReferenceType()));
        notice.setReferenceId(command.getReferenceId());
        notice.setPayloadJson(Jsons.toJsonString(command.getPayload() == null ? Collections.<String, String>emptyMap() : command.getPayload()));
        notice.setPushStatus(PUSH_STATUS_PENDING);
        notice.setDeleted(DELETED_NO);
        return notice;
    }
    /**
     * 根据后台配置和用户通知设置决定是否触发极光推送,并回写推送结果。
     */
    private void handlePush(AppSystemNotice notice, AppSystemNoticeDispatchCommand command) {
        MessagePushConfig config = resolveMessagePushConfig(command.getNoticeTypeCode());
        if (!isPushEnabled(config)) {
            markSkipped(notice, "后台推送开关关闭");
            return;
        }
        if (Boolean.TRUE.equals(command.getRequireUserSetting()) && !isUserPushEnabled(command)) {
            markSkipped(notice, "用户通知设置关闭");
            return;
        }
        try {
            Map<String, String> payload = new LinkedHashMap<String, String>();
            if (command.getPayload() != null) {
                payload.putAll(command.getPayload());
            }
            payload.put("noticeId", String.valueOf(notice.getId()));
            payload.put("noticeTypeCode", notice.getNoticeTypeCode());
            payload.put("referenceType", trimToEmpty(notice.getReferenceType()));
            payload.put("referenceId", notice.getReferenceId() == null ? "" : String.valueOf(notice.getReferenceId()));
            String alias = trimToEmpty(command.getPushAlias());
            if (alias.isEmpty()) {
                alias = String.valueOf(command.getAppUserId());
            }
            jgPushUtil.push(notice.getContent(), notice.getTitle(), payload, alias);
            notice.setPushStatus(PUSH_STATUS_SUCCESS);
            notice.setPushedAt(LocalDateTime.now());
            notice.setPushErrorMessage(null);
            appSystemNoticeMapper.updateById(notice);
        } catch (Exception ex) {
            notice.setPushStatus(PUSH_STATUS_FAILED);
            notice.setPushErrorMessage(limitText(ex.getMessage(), 500));
            appSystemNoticeMapper.updateById(notice);
        }
    }
    /**
     * 将未推送的通知标记为跳过。
     */
    private void markSkipped(AppSystemNotice notice, String reason) {
        notice.setPushStatus(PUSH_STATUS_SKIPPED);
        notice.setPushErrorMessage(limitText(reason, 500));
        appSystemNoticeMapper.updateById(notice);
    }
    /**
     * 读取后台消息推送配置。
     */
    private MessagePushConfig resolveMessagePushConfig(String noticeTypeCode) {
        return messagePushConfigMapper.selectOne(new LambdaQueryWrapper<MessagePushConfig>()
                .eq(MessagePushConfig::getNoticeTypeCode, noticeTypeCode)
                .eq(MessagePushConfig::getDeleted, DELETED_NO)
                .last("limit 1"));
    }
    /**
     * 判断后台消息推送开关是否开启。
     */
    private boolean isPushEnabled(MessagePushConfig config) {
        return config != null && config.getPushEnabled() != null && config.getPushEnabled().intValue() == ENABLED_YES;
    }
    /**
     * 判断当前用户对应的推送开关是否开启。
     */
    private boolean isUserPushEnabled(AppSystemNoticeDispatchCommand command) {
        AppUserNotificationSetting setting = appUserNotificationSettingMapper.selectOne(new LambdaQueryWrapper<AppUserNotificationSetting>()
                .eq(AppUserNotificationSetting::getAppUserId, command.getAppUserId())
                .eq(AppUserNotificationSetting::getDeleted, DELETED_NO)
                .last("limit 1"));
        if (setting == null) {
            return true;
        }
        String noticeTypeCode = trimToEmpty(command.getNoticeTypeCode());
        if (AppSystemNoticeType.LIVE_REMIND.equals(noticeTypeCode)) {
            return isEnabled(setting.getLiveReservationEnabled());
        }
        if (AppSystemNoticeType.ACTIVITY_NOTICE.equals(noticeTypeCode)) {
            return isEnabled(setting.getActivityNoticeEnabled());
        }
        if (AppSystemNoticeType.ORDER_STATUS_UPDATE.equals(noticeTypeCode)) {
            return isEnabled(setting.getOrderStatusUpdateEnabled());
        }
        if (AppSystemNoticeType.COURSE_UPDATE_NOTICE.equals(noticeTypeCode)) {
            return isEnabled(setting.getCourseUpdateEnabled());
        }
        return true;
    }
    /**
     * 判断用户通知设置开关是否开启。
     */
    private boolean isEnabled(Integer value) {
        return value == null || value.intValue() == ENABLED_YES;
    }
    /**
     * 将可选文本收敛为空字符串。
     */
    private String trimToEmpty(String value) {
        return value == null ? "" : value.trim();
    }
    /**
     * 将可选文本收敛为空值。
     */
    private String trimToNull(String value) {
        String trimmed = trimToEmpty(value);
        return trimmed.isEmpty() ? null : trimmed;
    }
    /**
     * 截断错误文案,避免超出数据库字段长度。
     */
    private String limitText(String value, int maxLength) {
        String safeValue = trimToEmpty(value);
        if (safeValue.length() <= maxLength) {
            return safeValue;
        }
        return safeValue.substring(0, maxLength);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/service/AppSystemNoticeType.java
New file
@@ -0,0 +1,17 @@
package com.tcm.training.admin.app.notice.service;
/**
 * APP 系统通知类型常量。
 */
public final class AppSystemNoticeType {
    public static final String MEMBER_EXPIRE_REMIND = "MEMBER_EXPIRE_REMIND";
    public static final String LIVE_REMIND = "LIVE_REMIND";
    public static final String ACTIVITY_NOTICE = "ACTIVITY_NOTICE";
    public static final String ORDER_STATUS_UPDATE = "ORDER_STATUS_UPDATE";
    public static final String COURSE_UPDATE_NOTICE = "COURSE_UPDATE_NOTICE";
    public static final String STUDY_PROGRESS_REMIND = "STUDY_PROGRESS_REMIND";
    private AppSystemNoticeType() {
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppActivityNoticeDetailVO.java
New file
@@ -0,0 +1,18 @@
package com.tcm.training.admin.app.notice.vo;
import lombok.Getter;
import lombok.Setter;
/**
 * APP 活动通知详情响应。
 */
@Getter
@Setter
public class AppActivityNoticeDetailVO {
    private Long id;
    private String title;
    private String contentHtml;
    private String createdAt;
    private Boolean read;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppActivityNoticeListItemVO.java
New file
@@ -0,0 +1,17 @@
package com.tcm.training.admin.app.notice.vo;
import lombok.Getter;
import lombok.Setter;
/**
 * APP 活动通知列表项。
 */
@Getter
@Setter
public class AppActivityNoticeListItemVO {
    private Long id;
    private String title;
    private String createdAt;
    private Boolean read;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/notice/vo/AppNoticeDetailVO.java
New file
@@ -0,0 +1,18 @@
package com.tcm.training.admin.app.notice.vo;
import lombok.Getter;
import lombok.Setter;
/**
 * APP 通知详情响应。
 */
@Getter
@Setter
public class AppNoticeDetailVO {
    private String type;
    private String title;
    private String contentHtml;
    private String createdAt;
    private Boolean read;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/AppDownloadPageController.java
New file
@@ -0,0 +1,19 @@
package com.tcm.training.admin.app.share.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
 * 邀请下载页静态资源入口。
 */
@Controller
public class AppDownloadPageController {
    /**
     * 将下载页入口统一转发到静态落地页。
     */
    @GetMapping({"/app-download", "/app-download/"})
    public String appDownloadPage() {
        return "forward:/app-download/index.html";
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/common/Jsons.java
@@ -29,4 +29,15 @@
            return Collections.emptyList();
        }
    }
    /**
     * 将对象序列化为 JSON 字符串,序列化失败时返回空对象字符串。
     */
    public static String toJsonString(Object value) {
        try {
            return MAPPER.writeValueAsString(value);
        } catch (Exception e) {
            return "{}";
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/AsyncTaskExecutorConfig.java
New file
@@ -0,0 +1,30 @@
package com.tcm.training.admin.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
 * 异步任务线程池配置。
 */
@Configuration
public class AsyncTaskExecutorConfig {
    /**
     * 创建活动通知分发线程池,避免后台创建通知时阻塞请求线程。
     */
    @Bean("activityNoticeTaskExecutor")
    public Executor activityNoticeTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("activity-notice-");
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(200);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -4,19 +4,26 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.common.Jsons;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.live.dto.HealthCourseSaveRequest;
import com.tcm.training.admin.live.dto.HealthCourseStatusRequest;
import com.tcm.training.admin.live.dto.HealthCourseVideoSaveRequest;
import com.tcm.training.admin.live.entity.Expert;
import com.tcm.training.admin.live.entity.CoursePurchaseOrder;
import com.tcm.training.admin.live.entity.HealthCourse;
import com.tcm.training.admin.live.entity.HealthCourseMemberJoinRecord;
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.excel.HealthCourseExportRow;
import com.tcm.training.admin.live.excel.HealthCourseVideoImportRow;
import com.tcm.training.admin.live.mapper.ExpertMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.live.mapper.CoursePurchaseOrderMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMemberJoinRecordMapper;
import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper;
import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper;
import com.tcm.training.admin.live.vo.ExpertVO;
@@ -53,15 +60,24 @@
    private final HealthCourseVideoMapper healthCourseVideoMapper;
    private final LiveCourseCategoryMapper liveCourseCategoryMapper;
    private final ExpertMapper expertMapper;
    private final CoursePurchaseOrderMapper coursePurchaseOrderMapper;
    private final HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    public HealthCourseAppService(HealthCourseMapper healthCourseMapper,
                                  HealthCourseVideoMapper healthCourseVideoMapper,
                                  LiveCourseCategoryMapper liveCourseCategoryMapper,
                                  ExpertMapper expertMapper) {
                                  ExpertMapper expertMapper,
                                  CoursePurchaseOrderMapper coursePurchaseOrderMapper,
                                  HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper,
                                  AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.healthCourseMapper = healthCourseMapper;
        this.healthCourseVideoMapper = healthCourseVideoMapper;
        this.liveCourseCategoryMapper = liveCourseCategoryMapper;
        this.expertMapper = expertMapper;
        this.coursePurchaseOrderMapper = coursePurchaseOrderMapper;
        this.healthCourseMemberJoinRecordMapper = healthCourseMemberJoinRecordMapper;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    public PageResponse<HealthCourseListItemVO> page(int page,
@@ -297,6 +313,7 @@
        next.setDeleted(0);
        healthCourseVideoMapper.insert(next);
        refreshCourseStats(courseId);
        dispatchCourseUpdateNotice(courseId, next);
        return detailVideoResource(next.getId());
    }
@@ -380,6 +397,7 @@
            video.setDeleted(0);
            healthCourseVideoMapper.insert(video);
            touchedCourseIds.add(course.getId());
            dispatchCourseUpdateNotice(course.getId(), video);
        }
        if (touchedCourseIds.isEmpty()) {
            throw new IllegalArgumentException("导入文件没有可用数据");
@@ -500,6 +518,52 @@
        healthCourseMapper.updateById(course);
    }
    /**
     * 在课程新增章节后通知已购课或已加入学习的用户。
     */
    private void dispatchCourseUpdateNotice(Long courseId, HealthCourseVideo video) {
        if (courseId == null || video == null || video.getId() == null) {
            return;
        }
        HealthCourse course = requireCourse(courseId);
        Set<Long> appUserIds = new LinkedHashSet<Long>();
        List<CoursePurchaseOrder> orders = coursePurchaseOrderMapper.selectList(new LambdaQueryWrapper<CoursePurchaseOrder>()
                .eq(CoursePurchaseOrder::getDeleted, 0)
                .eq(CoursePurchaseOrder::getStatus, "PAID")
                .eq(CoursePurchaseOrder::getCourseId, courseId));
        for (CoursePurchaseOrder order : orders) {
            if (order.getAppUserId() != null) {
                appUserIds.add(order.getAppUserId());
            }
        }
        List<HealthCourseMemberJoinRecord> joinRecords = healthCourseMemberJoinRecordMapper.selectList(new LambdaQueryWrapper<HealthCourseMemberJoinRecord>()
                .eq(HealthCourseMemberJoinRecord::getDeleted, 0)
                .eq(HealthCourseMemberJoinRecord::getCourseId, courseId));
        for (HealthCourseMemberJoinRecord joinRecord : joinRecords) {
            if (joinRecord.getAppUserId() != null) {
                appUserIds.add(joinRecord.getAppUserId());
            }
        }
        for (Long appUserId : appUserIds) {
            AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
            command.setAppUserId(appUserId);
            command.setNoticeTypeCode(AppSystemNoticeType.COURSE_UPDATE_NOTICE);
            command.setTitle("课程更新通知");
            command.setContent(course.getTitle() + " 已更新新章节");
            // 使用章节主键作为引用,保证同一章节对同一用户只发一次。
            command.setReferenceType("HEALTH_COURSE_VIDEO");
            command.setReferenceId(video.getId());
            command.setRequireUserSetting(Boolean.TRUE);
            Map<String, String> payload = new LinkedHashMap<String, String>();
            payload.put("courseId", String.valueOf(courseId));
            payload.put("courseTitle", course.getTitle());
            payload.put("videoId", String.valueOf(video.getId()));
            payload.put("chapterName", defaultText(video.getChapterName()));
            command.setPayload(payload);
            appSystemNoticeDispatchService.createAndPush(command);
        }
    }
    private HealthCourse requireCourse(Long id) {
        HealthCourse course = healthCourseMapper.selectById(id);
        if (course == null) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveRemindDispatchService.java
New file
@@ -0,0 +1,155 @@
package com.tcm.training.admin.live.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.app.settings.entity.AppUserNotificationSetting;
import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
import com.tcm.training.admin.live.entity.LiveCourse;
import com.tcm.training.admin.live.entity.LiveReserve;
import com.tcm.training.admin.live.entity.LiveSession;
import com.tcm.training.admin.live.mapper.LiveCourseMapper;
import com.tcm.training.admin.live.mapper.LiveReserveMapper;
import com.tcm.training.admin.live.mapper.LiveSessionMapper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * 直播提醒通知分发服务。
 */
@Service
public class LiveRemindDispatchService {
    private final LiveSessionMapper liveSessionMapper;
    private final LiveReserveMapper liveReserveMapper;
    private final LiveCourseMapper liveCourseMapper;
    private final AppUserNotificationSettingMapper appUserNotificationSettingMapper;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    /**
     * 注入直播提醒分发所需依赖。
     */
    public LiveRemindDispatchService(LiveSessionMapper liveSessionMapper,
                                     LiveReserveMapper liveReserveMapper,
                                     LiveCourseMapper liveCourseMapper,
                                     AppUserNotificationSettingMapper appUserNotificationSettingMapper,
                                     AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.liveSessionMapper = liveSessionMapper;
        this.liveReserveMapper = liveReserveMapper;
        this.liveCourseMapper = liveCourseMapper;
        this.appUserNotificationSettingMapper = appUserNotificationSettingMapper;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    /**
     * 定时扫描即将开播的预约用户,并按用户设置的提前提醒时间发放通知。
     */
    @Scheduled(fixedDelayString = "${app.live.remind-dispatch-interval-ms:60000}")
    public void dispatchScheduledReminds() {
        LocalDateTime now = LocalDateTime.now();
        List<LiveSession> sessions = liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>()
                .eq(LiveSession::getDeleted, 0)
                .eq(LiveSession::getLiveStatus, 0)
                .isNotNull(LiveSession::getStartTime)
                .gt(LiveSession::getStartTime, now)
                .orderByAsc(LiveSession::getStartTime)
                .orderByAsc(LiveSession::getId));
        for (LiveSession session : sessions) {
            dispatchRemindsForSession(session, now, false);
        }
    }
    /**
     * 手动触发指定场次的直播提醒发放。
     */
    public long dispatchManualRemind(LiveSession session) {
        if (session == null) {
            return 0L;
        }
        return dispatchRemindsForSession(session, LocalDateTime.now(), true);
    }
    /**
     * 根据预约记录与提醒时间配置发放直播提醒。
     */
    private long dispatchRemindsForSession(LiveSession session, LocalDateTime now, boolean ignoreReminderWindow) {
        if (session == null || session.getId() == null || session.getStartTime() == null) {
            return 0L;
        }
        List<LiveReserve> reserves = liveReserveMapper.selectList(new LambdaQueryWrapper<LiveReserve>()
                .eq(LiveReserve::getSessionId, session.getId())
                .eq(LiveReserve::getIsCancel, 0));
        if (reserves.isEmpty()) {
            return 0L;
        }
        LiveCourse liveCourse = liveCourseMapper.selectById(session.getLiveCourseId());
        long dispatchedCount = 0L;
        for (LiveReserve reserve : reserves) {
            if (reserve.getUserId() == null) {
                continue;
            }
            Integer advanceMinutes = resolveAdvanceMinutes(reserve.getUserId());
            if (!ignoreReminderWindow && !isWithinReminderWindow(session.getStartTime(), now, advanceMinutes)) {
                continue;
            }
            AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
            command.setAppUserId(reserve.getUserId());
            command.setNoticeTypeCode(AppSystemNoticeType.LIVE_REMIND);
            command.setTitle("直播提醒");
            command.setContent(defaultCourseTitle(liveCourse) + " 即将开播");
            command.setReferenceType("LIVE_SESSION");
            command.setReferenceId(session.getId());
            command.setRequireUserSetting(Boolean.TRUE);
            Map<String, String> payload = new LinkedHashMap<String, String>();
            payload.put("courseId", session.getLiveCourseId() == null ? "" : String.valueOf(session.getLiveCourseId()));
            payload.put("sessionId", String.valueOf(session.getId()));
            payload.put("startTime", String.valueOf(session.getStartTime()));
            payload.put("courseTitle", defaultCourseTitle(liveCourse));
            command.setPayload(payload);
            appSystemNoticeDispatchService.createAndPush(command);
            dispatchedCount++;
        }
        return dispatchedCount;
    }
    /**
     * 读取用户设置的提前提醒分钟数,未配置时默认按 60 分钟处理。
     */
    private Integer resolveAdvanceMinutes(Long appUserId) {
        AppUserNotificationSetting setting = appUserNotificationSettingMapper.selectOne(new LambdaQueryWrapper<AppUserNotificationSetting>()
                .eq(AppUserNotificationSetting::getAppUserId, appUserId)
                .eq(AppUserNotificationSetting::getDeleted, 0)
                .last("limit 1"));
        if (setting == null || setting.getAdvanceNoticeMinutes() == null || setting.getAdvanceNoticeMinutes().intValue() <= 0) {
            return 60;
        }
        return setting.getAdvanceNoticeMinutes();
    }
    /**
     * 判断当前时间是否已进入当前用户对应的提醒发送窗口。
     */
    private boolean isWithinReminderWindow(LocalDateTime startTime, LocalDateTime now, Integer advanceMinutes) {
        if (startTime == null || now == null) {
            return false;
        }
        LocalDateTime remindTime = startTime.minusMinutes(advanceMinutes == null ? 60L : advanceMinutes.longValue());
        return !now.isBefore(remindTime) && now.isBefore(startTime);
    }
    /**
     * 返回安全的直播课程标题。
     */
    private String defaultCourseTitle(LiveCourse liveCourse) {
        if (liveCourse == null || liveCourse.getTitle() == null) {
            return "您预约的直播";
        }
        return liveCourse.getTitle();
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java
@@ -38,15 +38,18 @@
    private final LiveSessionMapper liveSessionMapper;
    private final LiveSessionQuestionMapper liveSessionQuestionMapper;
    private final LiveReserveMapper liveReserveMapper;
    private final LiveRemindDispatchService liveRemindDispatchService;
    public LiveSessionAppService(LiveCourseMapper liveCourseMapper,
                                 LiveSessionMapper liveSessionMapper,
                                 LiveSessionQuestionMapper liveSessionQuestionMapper,
                                 LiveReserveMapper liveReserveMapper) {
                                 LiveReserveMapper liveReserveMapper,
                                 LiveRemindDispatchService liveRemindDispatchService) {
        this.liveCourseMapper = liveCourseMapper;
        this.liveSessionMapper = liveSessionMapper;
        this.liveSessionQuestionMapper = liveSessionQuestionMapper;
        this.liveReserveMapper = liveReserveMapper;
        this.liveRemindDispatchService = liveRemindDispatchService;
    }
    public PageResponse<LiveSessionVO> pageSessions(Long courseId, int page, int pageSize, String startTimeFrom, String startTimeTo, Integer liveStatus) {
@@ -304,6 +307,8 @@
                .eq(LiveReserve::getLiveCourseId, courseId)
                .eq(LiveReserve::getSessionId, sessionId)
                .eq(LiveReserve::getIsCancel, 0));
        // 后台手动触发时直接对当前场次预约用户执行一次真正的通知发放。
        liveRemindDispatchService.dispatchManualRemind(session);
        return new LivePushNotificationVO(cnt == null ? 0L : cnt, LocalDateTime.now());
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberExpireRemindDispatchService.java
New file
@@ -0,0 +1,99 @@
package com.tcm.training.admin.member.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.system.entity.SystemSettings;
import com.tcm.training.admin.system.mapper.SystemSettingsMapper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * 会员到期提醒分发服务。
 */
@Service
public class MemberExpireRemindDispatchService {
    private final UserMapper userMapper;
    private final SystemSettingsMapper systemSettingsMapper;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    /**
     * 注入会员到期提醒分发所需依赖。
     */
    public MemberExpireRemindDispatchService(UserMapper userMapper,
                                             SystemSettingsMapper systemSettingsMapper,
                                             AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.userMapper = userMapper;
        this.systemSettingsMapper = systemSettingsMapper;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    /**
     * 定时扫描即将到期的会员,并按系统设置发放提醒通知。
     */
    @Scheduled(fixedDelayString = "${app.member.expire-remind-interval-ms:3600000}")
    public void dispatchMemberExpireReminds() {
        Integer remindDays = resolveRemindDays();
        if (remindDays == null || remindDays.intValue() < 0) {
            return;
        }
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime deadline = now.plusDays(remindDays.longValue());
        List<AppUser> users = userMapper.selectList(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getDeleted, 0)
                .eq(AppUser::getStatus, 1)
                .isNotNull(AppUser::getMemberExpireAt)
                .ge(AppUser::getMemberExpireAt, now)
                .le(AppUser::getMemberExpireAt, deadline)
                .orderByAsc(AppUser::getMemberExpireAt)
                .orderByAsc(AppUser::getId));
        for (AppUser user : users) {
            dispatchMemberExpireNotice(user, now);
        }
    }
    /**
     * 为单个即将到期会员发放系统通知。
     */
    private void dispatchMemberExpireNotice(AppUser user, LocalDateTime now) {
        if (user == null || user.getId() == null || user.getMemberExpireAt() == null) {
            return;
        }
        long days = ChronoUnit.DAYS.between(now.toLocalDate(), user.getMemberExpireAt().toLocalDate());
        AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
        command.setAppUserId(user.getId());
        command.setNoticeTypeCode(AppSystemNoticeType.MEMBER_EXPIRE_REMIND);
        command.setTitle("会员到期提醒");
        command.setContent("您的会员有效期将于" + user.getMemberExpireAt().toLocalDate() + "到期");
        // 这里把到期时间编码进引用类型,保证同一到期批次只提醒一次。
        command.setReferenceType("MEMBER_EXPIRE_" + user.getMemberExpireAt().toLocalDate());
        command.setReferenceId(user.getId());
        command.setRequireUserSetting(Boolean.FALSE);
        Map<String, String> payload = new LinkedHashMap<String, String>();
        payload.put("expireAt", String.valueOf(user.getMemberExpireAt()));
        payload.put("remainingDays", String.valueOf(days));
        command.setPayload(payload);
        appSystemNoticeDispatchService.createAndPush(command);
    }
    /**
     * 获取系统配置的会员到期提醒天数。
     */
    private Integer resolveRemindDays() {
        SystemSettings settings = systemSettingsMapper.selectOne(new LambdaQueryWrapper<SystemSettings>()
                .eq(SystemSettings::getDeleted, 0)
                .orderByDesc(SystemSettings::getId)
                .last("limit 1"));
        return settings == null ? null : settings.getMemberExpireRemindDays();
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/dto/ExchangeShipRequest.java
@@ -13,6 +13,6 @@
public class ExchangeShipRequest {
    @NotBlank(message = "请输入物流单号")
    @Size(max = 5, message = "物流单号长度不能超过5个字符")
    @Size(max = 32, message = "物流单号长度不能超过32个字符")
    private String trackingNo;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/service/PointsExchangeAppService.java
@@ -4,6 +4,9 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
@@ -27,18 +30,23 @@
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class PointsExchangeAppService {
    private final PointsExchangeOrderMapper pointsExchangeOrderMapper;
    private final PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    public PointsExchangeAppService(PointsExchangeOrderMapper pointsExchangeOrderMapper,
                                    PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper) {
                                    PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper,
                                    AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.pointsExchangeOrderMapper = pointsExchangeOrderMapper;
        this.pointsExchangeOrderItemMapper = pointsExchangeOrderItemMapper;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    public PageResponse<PointsExchangeOrderListItemVO> pageOrders(int page,
@@ -144,6 +152,7 @@
        order.setTrackingNo(request.getTrackingNo().trim());
        order.setShippedAt(now);
        pointsExchangeOrderMapper.updateById(order);
        dispatchOrderShippedNotice(order);
    }
    @Transactional
@@ -156,6 +165,30 @@
        item.setShippedAt(now);
        pointsExchangeOrderItemMapper.updateById(item);
        refreshOrderStatus(item.getOrderId(), request.getTrackingNo().trim(), now);
        dispatchOrderShippedNotice(requireOrder(item.getOrderId()));
    }
    /**
     * 在订单发货后为诊所用户发送订单状态更新通知。
     */
    private void dispatchOrderShippedNotice(PointsExchangeOrder order) {
        if (order == null || order.getId() == null || order.getClinicAppUserId() == null) {
            return;
        }
        AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
        command.setAppUserId(order.getClinicAppUserId());
        command.setNoticeTypeCode(AppSystemNoticeType.ORDER_STATUS_UPDATE);
        command.setTitle("订单状态更新");
        command.setContent("您的礼品兑换订单已发货");
        command.setReferenceType("POINTS_EXCHANGE_ORDER_SHIPPED");
        command.setReferenceId(order.getId());
        command.setRequireUserSetting(Boolean.TRUE);
        Map<String, String> payload = new LinkedHashMap<String, String>();
        payload.put("orderId", String.valueOf(order.getId()));
        payload.put("orderNo", defaultText(order.getOrderNo()));
        payload.put("trackingNo", defaultText(order.getTrackingNo()));
        command.setPayload(payload);
        appSystemNoticeDispatchService.createAndPush(command);
    }
    @Transactional
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/service/PointsSettlementService.java
@@ -1,6 +1,9 @@
package com.tcm.training.admin.points.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.live.entity.Clinic;
@@ -42,6 +45,7 @@
    private final UserMapper userMapper;
    private final ClinicMapper clinicMapper;
    private final PointsLedgerAppService pointsLedgerAppService;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    public PointsSettlementService(PointsConfigMapper pointsConfigMapper,
                                   UserPointsAccountMapper userPointsAccountMapper,
@@ -49,7 +53,8 @@
                                   PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper,
                                   UserMapper userMapper,
                                   ClinicMapper clinicMapper,
                                   PointsLedgerAppService pointsLedgerAppService) {
                                   PointsLedgerAppService pointsLedgerAppService,
                                   AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.pointsConfigMapper = pointsConfigMapper;
        this.userPointsAccountMapper = userPointsAccountMapper;
        this.pointsExchangeOrderMapper = pointsExchangeOrderMapper;
@@ -57,6 +62,7 @@
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.pointsLedgerAppService = pointsLedgerAppService;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    @Scheduled(fixedDelayString = "${app.points.settlement-interval-ms:60000}")
@@ -199,6 +205,7 @@
            order.setTotalPoints(sumPoints(group));
            order.setStatus(0);
            pointsExchangeOrderMapper.insert(order);
            dispatchGiftOrderCreatedNotice(order, config, group);
            for (PointsExchangeOrderItem item : group) {
                item.setOrderId(order.getId());
@@ -224,6 +231,30 @@
                );
            }
        }
    }
    /**
     * 在积分兑换汇总订单生成后,通知当前诊所用户尽快处理兑换单。
     */
    private void dispatchGiftOrderCreatedNotice(PointsExchangeOrder order, PointsConfig config, List<PointsExchangeOrderItem> group) {
        if (order == null || order.getId() == null || order.getClinicAppUserId() == null) {
            return;
        }
        AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
        command.setAppUserId(order.getClinicAppUserId());
        command.setNoticeTypeCode(AppSystemNoticeType.ORDER_STATUS_UPDATE);
        command.setTitle("订单状态更新");
        command.setContent("您有新的礼品兑换订单待处理");
        command.setReferenceType("POINTS_EXCHANGE_ORDER");
        command.setReferenceId(order.getId());
        command.setRequireUserSetting(Boolean.TRUE);
        Map<String, String> payload = new LinkedHashMap<String, String>();
        payload.put("orderId", String.valueOf(order.getId()));
        payload.put("orderNo", defaultText(order.getOrderNo()));
        payload.put("giftName", config == null ? "" : defaultText(config.getGiftName()));
        payload.put("itemCount", String.valueOf(group == null ? 0 : group.size()));
        command.setPayload(payload);
        appSystemNoticeDispatchService.createAndPush(command);
    }
    private String buildGroupKey(PointsExchangeOrderItem item) {
@@ -327,4 +358,11 @@
    private String buildSettlementReferenceNo(Long configId) {
        return SETTLEMENT_CLEAR_REFERENCE_PREFIX + (configId == null ? 0L : configId.longValue());
    }
    /**
     * 返回安全文本,避免通知载荷出现空值。
     */
    private String defaultText(String value) {
        return value == null ? "" : value;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/event/ActivityNoticeCreatedEvent.java
New file
@@ -0,0 +1,23 @@
package com.tcm.training.admin.system.event;
/**
 * 活动通知创建完成事件。
 */
public class ActivityNoticeCreatedEvent {
    private final Long activityNoticeId;
    /**
     * 记录已创建的活动通知主键。
     */
    public ActivityNoticeCreatedEvent(Long activityNoticeId) {
        this.activityNoticeId = activityNoticeId;
    }
    /**
     * 获取活动通知主键。
     */
    public Long getActivityNoticeId() {
        return activityNoticeId;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java
@@ -6,8 +6,10 @@
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.system.dto.ActivityNoticeCreateRequest;
import com.tcm.training.admin.system.entity.ActivityNotice;
import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent;
import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,9 +20,15 @@
public class ActivityNoticeAppService {
    private final ActivityNoticeMapper activityNoticeMapper;
    private final ActivityNoticeDispatchService activityNoticeDispatchService;
    private final ApplicationEventPublisher applicationEventPublisher;
    public ActivityNoticeAppService(ActivityNoticeMapper activityNoticeMapper) {
    public ActivityNoticeAppService(ActivityNoticeMapper activityNoticeMapper,
                                    ActivityNoticeDispatchService activityNoticeDispatchService,
                                    ApplicationEventPublisher applicationEventPublisher) {
        this.activityNoticeMapper = activityNoticeMapper;
        this.activityNoticeDispatchService = activityNoticeDispatchService;
        this.applicationEventPublisher = applicationEventPublisher;
    }
    public PageResponse<ActivityNoticeListItemVO> page(int page, int pageSize, String title) {
@@ -44,12 +52,14 @@
        item.setContentHtml(normalizeContent(request.getContentHtml()));
        item.setDeleted(0);
        activityNoticeMapper.insert(item);
        applicationEventPublisher.publishEvent(new ActivityNoticeCreatedEvent(item.getId()));
        return toListItem(requireItem(item.getId()));
    }
    @Transactional
    public void delete(Long id) {
        requireItem(id);
        activityNoticeDispatchService.hideDistributedRecords(id);
        activityNoticeMapper.deleteById(id);
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java
New file
@@ -0,0 +1,143 @@
package com.tcm.training.admin.system.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.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.app.notice.entity.AppUserActivityNotice;
import com.tcm.training.admin.app.notice.mapper.AppUserActivityNoticeMapper;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent;
import com.tcm.training.admin.system.entity.ActivityNotice;
import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * 活动通知分发服务。
 */
@Service
public class ActivityNoticeDispatchService {
    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 RECORD_DELETED_YES = 1;
    private static final long PAGE_SIZE = 500L;
    private final UserMapper userMapper;
    private final AppUserActivityNoticeMapper appUserActivityNoticeMapper;
    private final ActivityNoticeMapper activityNoticeMapper;
    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    /**
     * 注入活动通知分发所需依赖。
     */
    public ActivityNoticeDispatchService(UserMapper userMapper,
                                         AppUserActivityNoticeMapper appUserActivityNoticeMapper,
                                         ActivityNoticeMapper activityNoticeMapper,
                                         AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
        this.userMapper = userMapper;
        this.appUserActivityNoticeMapper = appUserActivityNoticeMapper;
        this.activityNoticeMapper = activityNoticeMapper;
        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
    }
    /**
     * 在活动通知创建提交成功后异步为用户分发通知记录。
     */
    @Async("activityNoticeTaskExecutor")
    @TransactionalEventListener
    public void handleActivityNoticeCreated(ActivityNoticeCreatedEvent event) {
        if (event == null || event.getActivityNoticeId() == null) {
            return;
        }
        ActivityNotice activityNotice = activityNoticeMapper.selectOne(new LambdaQueryWrapper<ActivityNotice>()
                .eq(ActivityNotice::getId, event.getActivityNoticeId())
                .eq(ActivityNotice::getDeleted, RECORD_DELETED_NO)
                .last("limit 1"));
        if (activityNotice == null) {
            return;
        }
        long currentPage = 1L;
        while (true) {
            Page<AppUser> page = userMapper.selectPage(
                    new Page<AppUser>(currentPage, PAGE_SIZE),
                    new LambdaQueryWrapper<AppUser>()
                            .eq(AppUser::getDeleted, USER_DELETED_NO)
                            .eq(AppUser::getStatus, USER_STATUS_ENABLED)
                            .orderByAsc(AppUser::getId)
            );
            List<AppUser> users = page.getRecords();
            if (users == null || users.isEmpty()) {
                break;
            }
            for (AppUser user : users) {
                if (user.getId() == null) {
                    continue;
                }
                boolean exists = appUserActivityNoticeMapper.selectCount(new LambdaQueryWrapper<AppUserActivityNotice>()
                        .eq(AppUserActivityNotice::getAppUserId, user.getId())
                        .eq(AppUserActivityNotice::getActivityNoticeId, event.getActivityNoticeId())
                        .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)) > 0;
                if (exists) {
                    continue;
                }
                AppUserActivityNotice record = new AppUserActivityNotice();
                record.setAppUserId(user.getId());
                record.setActivityNoticeId(event.getActivityNoticeId());
                record.setDeleted(RECORD_DELETED_NO);
                appUserActivityNoticeMapper.insert(record);
                // 活动通知发布后同时写入统一系统通知表,并按双开关规则尝试极光推送。
                appSystemNoticeDispatchService.createAndPush(buildActivityNoticeCommand(user.getId(), activityNotice));
            }
            if (page.getCurrent() >= page.getPages()) {
                break;
            }
            currentPage++;
        }
    }
    /**
     * 构建活动通知统一发放命令。
     */
    private AppSystemNoticeDispatchCommand buildActivityNoticeCommand(Long appUserId, ActivityNotice activityNotice) {
        AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
        command.setAppUserId(appUserId);
        command.setNoticeTypeCode(AppSystemNoticeType.ACTIVITY_NOTICE);
        command.setTitle(activityNotice.getTitle());
        command.setContent(activityNotice.getTitle());
        command.setReferenceType("ACTIVITY_NOTICE");
        command.setReferenceId(activityNotice.getId());
        command.setRequireUserSetting(Boolean.TRUE);
        Map<String, String> payload = new LinkedHashMap<String, String>();
        payload.put("activityNoticeId", String.valueOf(activityNotice.getId()));
        payload.put("title", activityNotice.getTitle());
        command.setPayload(payload);
        return command;
    }
    /**
     * 在后台删除活动通知时同步隐藏用户侧对应记录。
     */
    public void hideDistributedRecords(Long activityNoticeId) {
        if (activityNoticeId == null) {
            return;
        }
        appUserActivityNoticeMapper.update(
                null,
                new LambdaUpdateWrapper<AppUserActivityNotice>()
                        .eq(AppUserActivityNotice::getActivityNoticeId, activityNoticeId)
                        .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)
                        .set(AppUserActivityNotice::getDeleted, RECORD_DELETED_YES)
        );
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java
New file
@@ -0,0 +1,93 @@
package com.tcm.training.admin.utils;
import cn.jiguang.common.ClientConfig;
import cn.jiguang.common.resp.APIConnectionException;
import cn.jiguang.common.resp.APIRequestException;
import cn.jpush.api.JPushClient;
import cn.jpush.api.push.PushResult;
import cn.jpush.api.push.model.Platform;
import cn.jpush.api.push.model.PushPayload;
import cn.jpush.api.push.model.audience.Audience;
import cn.jpush.api.push.model.notification.Notification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
 * 极光推送工具类
 */
@Component
public class JGPushUtil {
    private static final Logger log = LoggerFactory.getLogger(JGPushUtil.class);
    @Value("${jpush.appKey}")
    private String appKey;
    @Value("${jpush.masterSecret}")
    private String masterSecret;
    /**
     * 构建简单文本推送载荷。
     */
    private PushPayload buildPushObject_all_all_alert(String content, String...tags) {
        return PushPayload.newBuilder()
                .setPlatform(Platform.all())//设置推送平台为全部平台
                .setAudience(Audience.alias(tags))//通过别名的方式发送(用户owner_id,员工user_id)
                .setNotification(Notification.alert(content))
                .build();
    }
    /**
     * 构建带标题和扩展参数的推送载荷。
     */
//TODO ios 鸿蒙配置好了之后,需要将下面的注释解除
    private PushPayload buildPushObject_all_all_alert(String title, String content, Map<String, String> map, String...tags) {
        return PushPayload.newBuilder()
                .setPlatform(Platform.all())//设置推送平台为全部平台
                .setAudience(Audience.alias(tags))//通过别名的方式发送(user_id)
                .setNotification(Notification.android(content, title, map))
                .setNotification(Notification.ios(content, map))
                // .setNotification(Notification.winphone(content, map))
                .build();
    }
    /**
     * 推送
     * @param tags
     * @param content
     */
    public void push(String content, String...tags){
        try {
        JPushClient jpushClient = new JPushClient(masterSecret, appKey, null, ClientConfig.getInstance());
        PushPayload payload = this.buildPushObject_all_all_alert(content, tags);
            PushResult result = jpushClient.sendPush(payload);
            log.info("jpush simple push finished, statusCode={}, aliases={}", result.statusCode, tags);
        } catch (APIConnectionException e) {
            throw new IllegalStateException("极光推送连接失败", e);
        } catch (APIRequestException e) {
            throw new IllegalStateException("极光推送请求失败", e);
        }
    }
    /**
     * 推送带业务参数的通知消息。
     */
    public void push(String content, String title, Map<String, String> map, String...tags){
        JPushClient jpushClient = new JPushClient(masterSecret, appKey, null, ClientConfig.getInstance());
        PushPayload payload = this.buildPushObject_all_all_alert(title, content, map, tags);
        try {
            PushResult result = jpushClient.sendPush(payload);
            log.info("jpush biz push finished, statusCode={}, title={}, aliases={}", result.statusCode, title, tags);
        } catch (APIConnectionException e) {
            throw new IllegalStateException("极光推送连接失败", e);
        } catch (APIRequestException e) {
            throw new IllegalStateException("极光推送请求失败", e);
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/MsgUtils.java
New file
@@ -0,0 +1,93 @@
package com.tcm.training.admin.utils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import org.springframework.beans.factory.annotation.Autowired;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.tea.TeaException;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Slf4j
public class MsgUtils {
    private final AppAliyunSmsProperties properties;
    private final ObjectMapper objectMapper;
    /**
     * 注入阿里云短信配置与 JSON 序列化工具。
     */
    @Autowired
    public MsgUtils(AppAliyunSmsProperties properties, ObjectMapper objectMapper) {
        this.properties = properties;
        this.objectMapper = objectMapper;
    }
    /**
     * 使用AK&SK初始化账号Client
     * @param accessKeyId
     * @param accessKeySecret
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dysmsapi20170525.Client createClient(String accessKeyId, String accessKeySecret) throws Exception {
        Config config = new Config()
                // 您的 AccessKey ID
                .setAccessKeyId(accessKeyId)
                // 您的 AccessKey Secret
                .setAccessKeySecret(accessKeySecret);
        // 访问的域名
        config.endpoint = "dysmsapi.aliyuncs.com";
        return new com.aliyun.dysmsapi20170525.Client(config);
    }
    /**
     * 发送阿里云短信验证码。
     */
    public void sendMsg(String phone, Map<String,String> params, String templateCode, String signName) throws Exception {
        com.aliyun.dysmsapi20170525.Client client = MsgUtils.createClient(
                properties.getAccessKeyId(),
                properties.getAccessKeySecret()
        );
        SendSmsRequest sendSmsRequest = new SendSmsRequest()
                .setSignName(signName)
                .setTemplateCode(templateCode)
                .setPhoneNumbers(phone);
        if (params != null && !params.isEmpty()) {
            sendSmsRequest.setTemplateParam(toTemplateParam(params));
        }
        RuntimeOptions runtime = new RuntimeOptions();
        try {
            SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
            String responseCode = sendSmsResponse.getBody() == null ? null : sendSmsResponse.getBody().getCode();
            if (!"OK".equalsIgnoreCase(responseCode)) {
                String message = sendSmsResponse.getBody() == null ? "短信发送失败" : sendSmsResponse.getBody().getMessage();
                throw new IllegalStateException(message == null || message.trim().isEmpty() ? "短信发送失败,请稍后重试" : message);
            }
            log.info("阿里云短信发送成功,templateCode={}, statusCode={}", templateCode, sendSmsResponse.getStatusCode());
        } catch (TeaException error) {
            com.aliyun.teautil.Common.assertAsString(error.message);
            log.error("阿里云短信发送失败,templateCode={}, message={}", templateCode, error.message);
            throw new IllegalStateException("短信发送失败,请稍后重试");
        } catch (Exception _error) {
            TeaException error = new TeaException(_error.getMessage(), _error);
            com.aliyun.teautil.Common.assertAsString(error.message);
            log.error("阿里云短信发送异常,templateCode={}, message={}", templateCode, error.message);
            throw new IllegalStateException("短信发送失败,请稍后重试");
        }
    }
    /**
     * 将模板变量转换为阿里云要求的 JSON 字符串。
     */
    private String toTemplateParam(Map<String, String> params) throws JsonProcessingException {
        return objectMapper.writeValueAsString(params);
    }
}
tcm-training-admin-backend/src/main/resources/application-local.example.yml
@@ -35,6 +35,25 @@
    path: ./logs
  auth:
    sms:
      # 本地联调可开启固定验证码
      debug-enabled: true
      debug-code: "666666"
      aliyun:
        access-key-id: your-access-key-id
        access-key-secret: your-access-key-secret
        sign-name: your-sign-name
        templates:
          login: SMS_505135234
          register: SMS_505065330
          password-reset: SMS_505095303
          phone-change: SMS_505135235
      # 本地联调可按场景开启固定验证码
      login:
        enabled: true
        code: "666666"
      register:
        enabled: true
        code: "666666"
      password-reset:
        enabled: true
        code: "666666"
      phone-change:
        enabled: true
        code: "666666"
tcm-training-admin-backend/src/main/resources/application-prod.yml
@@ -33,5 +33,25 @@
app:
  auth:
    sms:
      debug-enabled: false
      debug-code: ""
      aliyun:
        access-key-id: "LTAI5t65dd3j1vT5kxAp8KHy"
        access-key-secret: "XPbEgmD3eSQLfPfFvuGhQchJzqWpF8"
        sign-name: "海诊通海南健康科技"
        templates:
          login: SMS_505135234
          register: SMS_505065330
          password-reset: SMS_505095303
          phone-change: SMS_505135235
      # 生产环境仅允许短信登录使用默认验证码,其他场景继续走真实验证码。
      login:
        enabled: true
        code: "666666"
      register:
        enabled: false
        code: ""
      password-reset:
        enabled: false
        code: ""
      phone-change:
        enabled: false
        code: ""
tcm-training-admin-backend/src/main/resources/application.yml
@@ -76,12 +76,30 @@
    settlement-interval-ms: 60000
  auth:
    sms:
      # 短信验证码有效期(秒)
      # 短信验证码统一有效期 300 秒,即 5 分钟
      expire-seconds: 300
      # 默认关闭开发调试验证码,避免误入生产环境
      debug-enabled: false
      # 开发环境调试验证码,默认留空,需在 application-local.yml 中显式开启
      debug-code: ""
      aliyun:
        access-key-id: ""
        access-key-secret: ""
        sign-name: ""
        templates:
          login: ""
          register: ""
          password-reset: ""
          phone-change: ""
      # 默认关闭所有场景调试验证码,避免误入生产环境
      login:
        enabled: false
        code: ""
      register:
        enabled: false
        code: ""
      password-reset:
        enabled: false
        code: ""
      phone-change:
        enabled: false
        code: ""
    wechat:
      # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式
      app-id: ""
@@ -94,6 +112,9 @@
  share:
    # 邀请二维码默认指向线上下载页,开发环境可在 application-local.yml 覆盖。
    landing-base-url: https://haizhentong.com/app-download/
jpush:
  appKey: 111
  masterSecret: 111
logging:
  level:
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSenderTest.java
New file
@@ -0,0 +1,93 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import com.tcm.training.admin.utils.MsgUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
 * APP 阿里云短信发送实现测试。
 */
@ExtendWith(MockitoExtension.class)
class AliyunAppSmsSenderTest {
    @Mock
    private MsgUtils msgUtils;
    @Captor
    private ArgumentCaptor<Map<String, String>> paramsCaptor;
    private AppAliyunSmsProperties properties;
    private AliyunAppSmsSender aliyunAppSmsSender;
    /**
     * 每个用例前构造阿里云短信发送器。
     */
    @BeforeEach
    void setUp() {
        properties = new AppAliyunSmsProperties();
        properties.setAccessKeyId("test-ak");
        properties.setAccessKeySecret("test-sk");
        properties.setSignName("海诊通");
        properties.getTemplates().setLogin("SMS_505135234");
        properties.getTemplates().setRegister("SMS_505065330");
        properties.getTemplates().setPasswordReset("SMS_505095303");
        properties.getTemplates().setPhoneChange("SMS_505135235");
        aliyunAppSmsSender = new AliyunAppSmsSender(msgUtils, properties);
    }
    /**
     * 登录验证码应使用登录模板并透传 code 参数。
     */
    @Test
    void sendLoginCodeShouldUseLoginTemplate() throws Exception {
        doNothing().when(msgUtils).sendMsg(eq("13800138000"), paramsCaptor.capture(), eq("SMS_505135234"), eq("海诊通"));
        aliyunAppSmsSender.sendLoginCode("13800138000", "666666");
        assertEquals("666666", paramsCaptor.getValue().get("code"));
    }
    /**
     * 短信发送失败时应向上抛出统一业务异常。
     */
    @Test
    void sendRegisterCodeShouldThrowWhenMsgUtilsFails() throws Exception {
        doThrow(new IllegalStateException("短信发送失败,请稍后重试"))
                .when(msgUtils).sendMsg(eq("13800138001"), org.mockito.ArgumentMatchers.anyMap(), eq("SMS_505065330"), eq("海诊通"));
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> aliyunAppSmsSender.sendRegisterCode("13800138001", "123456"));
        assertEquals("短信发送失败,请稍后重试", exception.getMessage());
    }
    /**
     * 模板配置不完整时应直接拒绝发送。
     */
    @Test
    void sendPhoneChangeCodeShouldFailWhenTemplateMissing() {
        properties.getTemplates().setPhoneChange("");
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> aliyunAppSmsSender.sendPhoneChangeCode("13800138002", "123456"));
        assertEquals("短信发送配置不完整", exception.getMessage());
    }
}
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
New file
@@ -0,0 +1,113 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppSmsAuthProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * APP 短信验证码场景化调试配置测试。
 */
@ExtendWith(MockitoExtension.class)
class AppSmsCodeServiceTest {
    @Mock
    private StringRedisTemplate stringRedisTemplate;
    @Mock
    private ValueOperations<String, String> valueOperations;
    @Mock
    private AppSmsSender appSmsSender;
    private AppSmsAuthProperties appSmsAuthProperties;
    private AppSmsCodeService appSmsCodeService;
    /**
     * 每个用例前重新构造短信验证码服务。
     */
    @BeforeEach
    void setUp() {
        appSmsAuthProperties = new AppSmsAuthProperties();
        appSmsAuthProperties.getLogin().setEnabled(true);
        appSmsAuthProperties.getLogin().setCode("666666");
        appSmsAuthProperties.getRegister().setEnabled(false);
        appSmsAuthProperties.getRegister().setCode("");
        appSmsAuthProperties.getPasswordReset().setEnabled(false);
        appSmsAuthProperties.getPasswordReset().setCode("");
        appSmsAuthProperties.getPhoneChange().setEnabled(false);
        appSmsAuthProperties.getPhoneChange().setCode("");
        appSmsCodeService = new AppSmsCodeService(
                stringRedisTemplate,
                appSmsSender,
                appSmsAuthProperties
        );
    }
    /**
     * 仅开启登录调试验证码时,666666 应允许短信登录直接通过。
     */
    @Test
    void validateLoginCodeShouldAllowConfiguredDebugCode() {
        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "666666"));
        verify(stringRedisTemplate, never()).opsForValue();
    }
    /**
     * 未开启注册调试验证码时,666666 不应绕过真实验证码校验。
     */
    @Test
    void validateRegisterCodeShouldRejectDebugCodeWhenSceneDisabled() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get(anyString())).thenReturn(null);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSmsCodeService.validateRegisterCode("13800138000", "666666"));
        assertEquals("验证码已失效,请重新获取", exception.getMessage());
    }
    /**
     * 短信发送成功后才应写入验证码缓存。
     */
    @Test
    void createLoginCodeShouldCacheCodeAfterSendSucceeds() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        appSmsCodeService.createLoginCode("13800138000");
        verify(appSmsSender).sendLoginCode(eq("13800138000"), anyString());
        verify(valueOperations).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS));
    }
    /**
     * 短信发送失败时不应写入验证码缓存。
     */
    @Test
    void createLoginCodeShouldNotCacheCodeWhenSendFails() {
        org.mockito.Mockito.doThrow(new IllegalStateException("短信发送失败,请稍后重试"))
                .when(appSmsSender).sendLoginCode(eq("13800138000"), anyString());
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> appSmsCodeService.createLoginCode("13800138000"));
        assertEquals("短信发送失败,请稍后重试", exception.getMessage());
        verify(valueOperations, never()).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS));
    }
}
tcm-training-admin/src/pages/trade/points-exchange/shared.tsx
@@ -82,10 +82,10 @@
          name="trackingNo"
          rules={[
            { required: true, message: '请输入物流单号' },
            { max: 5, message: '物流单号长度不能超过5个字符' },
            { max: 32, message: '物流单号长度不能超过32个字符' },
          ]}
        >
          <Input placeholder="请输入" maxLength={5} />
          <Input placeholder="请输入" maxLength={32} />
        </Form.Item>
        <div className="points-exchange-ship-form__footer">
          <Button onClick={onCancel}>取消</Button>
traditionalApp/pages.json
@@ -8,7 +8,8 @@
    "pages": [{
            "path": "pages/home/home",
            "style": {
                "navigationStyle": "custom"
                "navigationStyle": "custom",
                "enablePullDownRefresh": true
            }
        }, {
            "path": "pages/login/login",
@@ -49,7 +50,8 @@
        {
            "path": "pages/publish/publish",
            "style": {
                "navigationStyle": "custom"
                "navigationStyle": "custom",
                "enablePullDownRefresh": true
            }
        },
        {
@@ -120,6 +122,24 @@
            }
        },
        {
            "path": "pages/notice/noticeDetail",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/activityNotice/activityNotice",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/activityNotice/activityNoticeDetail",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/qualification/qualification",
            "style": {
                "navigationStyle": "custom"
traditionalApp/pages/activityNotice/activityNotice.vue
New file
@@ -0,0 +1,286 @@
<template>
    <view class="container">
        <view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
            <view class="nav-content">
                <image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
                <text class="nav-title">活动通知</text>
                <view class="nav-placeholder"></view>
            </view>
        </view>
        <scroll-view
            scroll-y
            class="list-scroll"
            @scrolltolower="loadMore"
        >
            <view v-if="noticeList.length === 0 && !loading" class="empty-box">
                <text class="empty-text">暂无活动通知</text>
            </view>
            <view
                v-for="item in noticeList"
                :key="item.id"
                class="notice-card"
                @tap="goDetail(item)"
            >
                <view class="card-top">
                    <view class="title-wrap">
                        <text class="title">{{ item.title }}</text>
                        <view v-if="!item.read" class="red-dot"></view>
                    </view>
                    <text class="time">{{ item.createdAt }}</text>
                </view>
                <text class="desc">点击查看活动详情</text>
            </view>
            <view v-if="loading" class="footer-text">加载中...</view>
            <view v-else-if="noticeList.length > 0 && finished" class="footer-text">没有更多了</view>
        </scroll-view>
    </view>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getActivityNoticePage, markActivityNoticeRead } from './service'
interface ActivityNoticeItem {
    id: number
    title: string
    createdAt: string
    read: boolean
}
const statusBarHeight = ref(20)
const noticeList = ref<ActivityNoticeItem[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const finished = ref(false)
/**
 * 页面初始化时记录状态栏高度。
 */
onMounted(() => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
})
/**
 * 页面展示时刷新活动通知列表。
 */
onShow(() => {
    resetAndLoad()
})
/**
 * 返回上一页。
 */
function goBack() {
    uni.navigateBack()
}
/**
 * 重置分页状态并重新拉取列表。
 */
function resetAndLoad() {
    page.value = 1
    finished.value = false
    noticeList.value = []
    loadList()
}
/**
 * 拉取活动通知分页列表。
 */
async function loadList() {
    if (loading.value || finished.value) {
        return
    }
    loading.value = true
    try {
        const response = await getActivityNoticePage({
            page: page.value,
            pageSize: pageSize.value,
        })
        if (response?.code !== 0 || !response.data) {
            return
        }
        const nextList = Array.isArray(response.data.list) ? response.data.list.map(normalizeNoticeItem) : []
        if (page.value === 1) {
            noticeList.value = nextList
        } else {
            noticeList.value = noticeList.value.concat(nextList)
        }
        const total = Number(response.data.total || 0)
        const currentCount = noticeList.value.length
        finished.value = currentCount >= total || nextList.length < pageSize.value
        if (!finished.value) {
            page.value += 1
        }
    } finally {
        loading.value = false
    }
}
/**
 * 将活动通知数据归一化为页面结构。
 */
function normalizeNoticeItem(source: any): ActivityNoticeItem {
    return {
        id: Number(source?.id || 0),
        title: typeof source?.title === 'string' ? source.title.trim() : '',
        createdAt: typeof source?.createdAt === 'string' ? source.createdAt.trim() : '',
        read: Boolean(source?.read),
    }
}
/**
 * 触底后加载下一页活动通知。
 */
function loadMore() {
    loadList()
}
/**
 * 点击活动通知后先标记已读,再跳转详情页。
 */
async function goDetail(item: ActivityNoticeItem) {
    if (!item.id) {
        return
    }
    if (!item.read) {
        await markActivityNoticeRead(item.id)
        noticeList.value = noticeList.value.map((current) => {
            if (current.id !== item.id) {
                return current
            }
            return {
                ...current,
                read: true,
            }
        })
    }
    uni.navigateTo({
        url: `/pages/activityNotice/activityNoticeDetail?id=${item.id}`,
    })
}
</script>
<style lang="scss" scoped>
.container {
    min-height: 100vh;
    background: #f6f7fb;
    display: flex;
    flex-direction: column;
}
.custom-navbar {
    background: #ffffff;
    position: sticky;
    top: 0;
    z-index: 20;
}
.nav-content {
    height: 88rpx;
    padding: 0 32rpx;
    display: flex;
    align-items: center;
    justify-content: space-between;
}
.back-icon {
    width: 40rpx;
    height: 40rpx;
}
.nav-title {
    font-size: 36rpx;
    line-height: 50rpx;
    font-weight: 600;
    color: #1a1a1a;
}
.nav-placeholder {
    width: 40rpx;
    height: 40rpx;
}
.list-scroll {
    flex: 1;
    padding: 24rpx 24rpx 40rpx;
    box-sizing: border-box;
}
.notice-card {
    background: #ffffff;
    border-radius: 24rpx;
    padding: 28rpx 24rpx;
    margin-bottom: 24rpx;
}
.card-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    column-gap: 24rpx;
}
.title-wrap {
    display: flex;
    align-items: center;
    flex: 1;
    min-width: 0;
}
.title {
    font-size: 32rpx;
    line-height: 44rpx;
    color: #1f2329;
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.red-dot {
    width: 14rpx;
    height: 14rpx;
    border-radius: 50%;
    background: #d93026;
    margin-left: 12rpx;
    flex-shrink: 0;
}
.time {
    font-size: 24rpx;
    line-height: 34rpx;
    color: #86909c;
    flex-shrink: 0;
}
.desc {
    margin-top: 16rpx;
    font-size: 26rpx;
    line-height: 38rpx;
    color: #86909c;
}
.empty-box {
    padding-top: 200rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
.empty-text,
.footer-text {
    font-size: 26rpx;
    line-height: 36rpx;
    color: #86909c;
    text-align: center;
}
.footer-text {
    padding: 12rpx 0 24rpx;
}
</style>
traditionalApp/pages/activityNotice/activityNoticeDetail.vue
New file
@@ -0,0 +1,197 @@
<template>
    <view class="container">
        <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
        <view class="nav-bar">
            <image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
            <text class="nav-title">活动通知详情</text>
        </view>
        <view class="content-card" v-if="detail.id">
            <text class="title">{{ detail.title }}</text>
            <text class="time">{{ detail.createdAt }}</text>
            <rich-text :nodes="contentNodes" class="rich-content"></rich-text>
        </view>
        <view v-else-if="!loading" class="empty-box">
            <text class="empty-text">暂无活动通知详情</text>
        </view>
    </view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getActivityNoticeDetail } from './service'
interface ActivityNoticeDetail {
    id: number | null
    title: string
    contentHtml: string
    createdAt: string
    read: boolean
}
const statusBarHeight = ref(0)
const noticeId = ref(0)
const loading = ref(false)
const detail = ref<ActivityNoticeDetail>(createDefaultDetail())
/**
 * 创建活动通知详情默认数据,避免页面访问空字段。
 */
function createDefaultDetail(): ActivityNoticeDetail {
    return {
        id: null,
        title: '',
        contentHtml: '',
        createdAt: '',
        read: false,
    }
}
/**
 * 将富文本正文转换为 `rich-text` 可直接渲染的节点。
 */
const contentNodes = computed(() => {
    if (detail.value.contentHtml && detail.value.contentHtml.trim()) {
        return detail.value.contentHtml
    }
    return '<p>暂无活动通知内容</p>'
})
/**
 * 初始化状态栏高度。
 */
onMounted(() => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 0
})
/**
 * 页面加载时记录活动通知主键。
 */
onLoad((options) => {
    noticeId.value = Number(options?.id || 0)
})
/**
 * 页面展示时拉取活动通知详情。
 */
onShow(() => {
    loadDetail()
})
/**
 * 拉取活动通知详情并更新页面标题。
 */
async function loadDetail() {
    if (!noticeId.value) {
        detail.value = createDefaultDetail()
        return
    }
    loading.value = true
    try {
        const response = await getActivityNoticeDetail(noticeId.value)
        if (response?.code !== 0 || !response.data) {
            return
        }
        detail.value = {
            id: Number(response.data.id || 0) || null,
            title: typeof response.data.title === 'string' ? response.data.title.trim() : '',
            contentHtml: typeof response.data.contentHtml === 'string' ? response.data.contentHtml : '',
            createdAt: typeof response.data.createdAt === 'string' ? response.data.createdAt.trim() : '',
            read: Boolean(response.data.read),
        }
    } finally {
        loading.value = false
    }
}
/**
 * 返回活动通知列表页。
 */
function goBack() {
    const currentPages = getCurrentPages()
    if (currentPages.length > 1) {
        uni.navigateBack()
        return
    }
    uni.redirectTo({
        url: '/pages/activityNotice/activityNotice',
    })
}
</script>
<style lang="scss" scoped>
.container {
    min-height: 100vh;
    background: #f5f6f8;
}
.nav-bar {
    height: 88rpx;
    padding: 0 24rpx;
    background: #ffffff;
    display: flex;
    align-items: center;
    position: relative;
}
.back-icon {
    position: absolute;
    left: 24rpx;
    width: 40rpx;
    height: 40rpx;
}
.nav-title {
    flex: 1;
    text-align: center;
    font-size: 36rpx;
    line-height: 50rpx;
    font-weight: 500;
    color: #1f2329;
}
.content-card {
    margin: 24rpx;
    padding: 32rpx 28rpx;
    border-radius: 24rpx;
    background: #ffffff;
}
.title {
    display: block;
    font-size: 38rpx;
    line-height: 54rpx;
    font-weight: 600;
    color: #1f2329;
}
.time {
    display: block;
    margin-top: 16rpx;
    font-size: 24rpx;
    line-height: 34rpx;
    color: #86909c;
}
.rich-content {
    display: block;
    margin-top: 28rpx;
    font-size: 30rpx;
    line-height: 46rpx;
    color: #4e5969;
}
.empty-box {
    padding-top: 220rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
.empty-text {
    font-size: 28rpx;
    line-height: 40rpx;
    color: #86909c;
}
</style>
traditionalApp/pages/activityNotice/service.js
New file
@@ -0,0 +1,22 @@
import request from '@/utils/request.js'
/**
 * 获取活动通知分页列表。
 */
export const getActivityNoticePage = (data) => {
    return request.get('/api/app/notices/activity', data, false, false)
}
/**
 * 获取活动通知详情。
 */
export const getActivityNoticeDetail = (id) => {
    return request.get(`/api/app/notices/activity/${id}`, {}, false, false)
}
/**
 * 将单条活动通知标记为已读。
 */
export const markActivityNoticeRead = (id) => {
    return request.post(`/api/app/notices/activity/${id}/read`, {}, false, false)
}
traditionalApp/pages/home/home.vue
@@ -105,7 +105,7 @@
<script setup lang="ts">
    import UpdatePopup from "@/components/updatePopup.vue";
    import { ref, onMounted, computed } from "vue";
    import { onPageScroll, onShow } from "@dcloudio/uni-app";
    import { onPageScroll, onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service";
    interface HomeBannerItem {
@@ -162,6 +162,7 @@
    const defaultLiveCover = '/static/image/home/class.png';
    const defaultCourseCover = '/static/image/home/class.png';
    const defaultPopularizationCover = '/static/image/home/zixun.png';
    const refreshing = ref(false);
    /**
     * 初始化导航栏所需系统信息。
@@ -178,9 +179,18 @@
     * 每次进入首页都刷新首页模块数据,确保展示最新 banner 与直播状态。
     */
    onShow(() => {
        loadBannerList();
        loadHomeLiveCourseList();
        loadFeaturedContent();
        refreshHomeData();
    });
    /**
     * 首页下拉时重新拉取首页聚合数据,并在结束后关闭原生刷新状态。
     */
    onPullDownRefresh(async () => {
        try {
            await refreshHomeData();
        } finally {
            uni.stopPullDownRefresh();
        }
    });
    /**
@@ -270,6 +280,25 @@
    };
    /**
     * 聚合刷新首页三块内容,避免 onShow 和下拉刷新分别维护重复请求逻辑。
     */
    const refreshHomeData = async () => {
        if (refreshing.value) {
            return;
        }
        refreshing.value = true;
        try {
            await Promise.all([
                loadBannerList(),
                loadHomeLiveCourseList(),
                loadFeaturedContent()
            ]);
        } finally {
            refreshing.value = false;
        }
    };
    /**
     * 将课程价格格式化为首页展示文案。
     */
    const formatPrice = (price?: number | string) => {
traditionalApp/pages/notice/notice.vue
@@ -183,16 +183,21 @@
     * 点击通知卡片后标记单类已读,并执行既有页面跳转。
     */
    const goDetail = async (item: NoticeItem) => {
        await syncNoticeRead(item)
        if (item.type === 'SERVICE') {
        if (item.type === 'ACTIVITY') {
            uni.navigateTo({
                url: '/pages/customerService/customerService',
                url: '/pages/activityNotice/activityNotice',
            })
            return
        }
        uni.showToast({
            title: item.title,
            icon: 'none',
        await syncNoticeRead(item)
        if (item.type === 'SERVICE') {
            uni.navigateTo({
                url: `/pages/notice/noticeDetail?type=${item.type}`,
            })
            return
        }
        uni.navigateTo({
            url: `/pages/notice/noticeDetail?type=${item.type}`,
        })
    }
traditionalApp/pages/notice/noticeDetail.vue
New file
@@ -0,0 +1,202 @@
<template>
    <view class="container">
        <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
        <view class="nav-bar">
            <image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
            <text class="nav-title">通知详情</text>
        </view>
        <view v-if="detail.title" class="content-card">
            <text class="title">{{ detail.title }}</text>
            <text class="time">{{ detail.createdAt }}</text>
            <rich-text :nodes="contentNodes" class="rich-content"></rich-text>
        </view>
        <view v-else-if="!loading" class="empty-box">
            <text class="empty-text">暂无通知详情</text>
        </view>
    </view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getNoticeDetail, markNoticeRead } from './service.js'
import { normalizeOptionalText } from '@/utils/utils.js'
interface NoticeDetail {
    type: string
    title: string
    contentHtml: string
    createdAt: string
    read: boolean
}
const statusBarHeight = ref(0)
const noticeType = ref('')
const loading = ref(false)
const detail = ref<NoticeDetail>(createDefaultDetail())
/**
 * 创建默认通知详情,避免模板访问空字段。
 */
function createDefaultDetail(): NoticeDetail {
    return {
        type: '',
        title: '',
        contentHtml: '',
        createdAt: '',
        read: false,
    }
}
/**
 * 将通知正文转换为 `rich-text` 可渲染内容。
 */
const contentNodes = computed(() => {
    if (detail.value.contentHtml) {
        return detail.value.contentHtml
    }
    return '<p>暂无通知内容</p>'
})
/**
 * 初始化状态栏高度。
 */
onMounted(() => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 0
})
/**
 * 页面加载时记录通知类型。
 */
onLoad((options) => {
    noticeType.value = normalizeOptionalText(options?.type)
})
/**
 * 页面展示时拉取通知详情。
 */
onShow(() => {
    loadDetail()
})
/**
 * 拉取通知详情并同步已读状态。
 */
async function loadDetail() {
    if (!noticeType.value) {
        detail.value = createDefaultDetail()
        return
    }
    loading.value = true
    try {
        const response = await getNoticeDetail(noticeType.value)
        if (response?.code !== 0 || !response.data) {
            return
        }
        detail.value = {
            type: normalizeOptionalText(response.data.type),
            title: normalizeOptionalText(response.data.title),
            contentHtml: normalizeOptionalText(response.data.contentHtml),
            createdAt: normalizeOptionalText(response.data.createdAt),
            read: Boolean(response.data.read),
        }
        if (!detail.value.read) {
            await markNoticeRead(noticeType.value)
            detail.value.read = true
        }
    } finally {
        loading.value = false
    }
}
/**
 * 返回上一页。
 */
function goBack() {
    const currentPages = getCurrentPages()
    if (currentPages.length > 1) {
        uni.navigateBack()
        return
    }
    uni.redirectTo({
        url: '/pages/notice/notice',
    })
}
</script>
<style lang="scss" scoped>
.container {
    min-height: 100vh;
    background: #f5f6f8;
}
.nav-bar {
    height: 88rpx;
    padding: 0 24rpx;
    background: #ffffff;
    display: flex;
    align-items: center;
    position: relative;
}
.back-icon {
    position: absolute;
    left: 24rpx;
    width: 40rpx;
    height: 40rpx;
}
.nav-title {
    flex: 1;
    text-align: center;
    font-size: 36rpx;
    line-height: 50rpx;
    font-weight: 500;
    color: #1f2329;
}
.content-card {
    margin: 24rpx;
    padding: 32rpx 28rpx;
    border-radius: 24rpx;
    background: #ffffff;
}
.title {
    display: block;
    font-size: 38rpx;
    line-height: 54rpx;
    font-weight: 600;
    color: #1f2329;
}
.time {
    display: block;
    margin-top: 16rpx;
    font-size: 24rpx;
    line-height: 34rpx;
    color: #86909c;
}
.rich-content {
    display: block;
    margin-top: 28rpx;
    font-size: 30rpx;
    line-height: 46rpx;
    color: #4e5969;
}
.empty-box {
    padding-top: 220rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
.empty-text {
    font-size: 28rpx;
    line-height: 40rpx;
    color: #86909c;
}
</style>
traditionalApp/pages/notice/service.js
@@ -20,3 +20,31 @@
export const markNoticeRead = (noticeType) => {
    return request.post(`/api/app/notices/${noticeType}/read`, {}, false, false)
}
/**
 * 获取指定类型通知详情。
 */
export const getNoticeDetail = (noticeType) => {
    return request.get(`/api/app/notices/${noticeType}/detail`, {}, false, false)
}
/**
 * 获取活动通知分页列表。
 */
export const getActivityNoticePage = (data) => {
    return request.get('/api/app/notices/activity', data, false, false)
}
/**
 * 获取活动通知详情。
 */
export const getActivityNoticeDetail = (id) => {
    return request.get(`/api/app/notices/activity/${id}`, {}, false, false)
}
/**
 * 将单条活动通知标记为已读。
 */
export const markActivityNoticeRead = (id) => {
    return request.post(`/api/app/notices/activity/${id}/read`, {}, false, false)
}
traditionalApp/pages/publish/publish.vue
@@ -26,6 +26,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getHealthPopularizationList } from './service'
interface PopularizationListItem {
@@ -54,6 +55,17 @@
});
/**
 * 下拉刷新健康科普列表,并在结束后关闭原生刷新状态。
 */
onPullDownRefresh(async () => {
    try {
        await loadPopularizationList()
    } finally {
        uni.stopPullDownRefresh()
    }
})
/**
 * 拉取管理后台已启用的健康科普列表数据。
 */
async function loadPopularizationList() {
traditionalApp/pages/watchLive/watchLive.vue
@@ -90,10 +90,11 @@
                    <!-- 底部按钮区域 -->
                    <view class="submit-action-area">
                        <view class="btn cancel-btn" @tap="closeAnswer">稍后可答题</view>
                        <view class="btn confirm-btn" :class="{ disabled: quizSubmitting }" @tap="submitAnswer">
                            {{ quizSubmitting ? '提交中...' : '提交' }}
                        <view class="btn confirm-btn" :class="{ disabled: submitButtonDisabled }" @tap="submitAnswer">
                            {{ submitButtonText }}
                        </view>
                    </view>
                    <view class="submit-hint" v-if="submitHintText">{{ submitHintText }}</view>
                </template>
                <!-- 答题完成结果区域 -->
@@ -240,6 +241,7 @@
const loadFailed = ref(false)
const quizLoading = ref(false)
const quizSubmitting = ref(false)
const quizNowTimestamp = ref(Date.now())
const liveDetail = ref<LiveDetailResponse>(createEmptyLiveDetail())
const quizState = ref<QuizStatusResponse>(createEmptyQuizState())
const questions = ref<QuizQuestionItem[]>([])
@@ -254,6 +256,7 @@
const instance = getCurrentInstance()
let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
let playbackStartupTimer: ReturnType<typeof setTimeout> | null = null
let quizAvailabilityTimer: ReturnType<typeof setInterval> | null = null
/**
 * 生成观看页默认直播详情,避免模板访问空对象。
@@ -404,6 +407,13 @@
})
const watchCountText = computed(() => formatCount(liveDetail.value.watchCount))
const rewardPointsText = computed(() => String(quizState.value.pointsReward || liveDetail.value.quizPoints || 0))
const hasQuizQuestions = computed(() => questions.value.length > 0)
const isQuizTimeReached = computed(() => {
    if (quizState.value.quizEnabled) {
        return true
    }
    return isQuizAvailableByTime(quizState.value.quizAvailableAt)
})
const answerButtonText = computed(() => {
    if (quizState.value.quizPointsAwarded) {
        return '已得分'
@@ -414,6 +424,14 @@
    return '答题'
})
const answerButtonDisabled = computed(() => !activeSessionId.value || quizSubmitting.value)
const isSubmitEnabled = computed(() => {
    if (!activeSessionId.value || quizState.value.quizSubmitted) {
        return false
    }
    return hasQuizQuestions.value && isQuizTimeReached.value
})
const submitButtonDisabled = computed(() => !isSubmitEnabled.value || quizSubmitting.value)
const submitButtonText = computed(() => (quizSubmitting.value ? '提交中...' : '提交'))
const answerViewState = computed<QuizViewState>(() => {
    if (quizState.value.quizSubmitted) {
        if (quizState.value.quizPointsAwarded) {
@@ -425,7 +443,7 @@
        }
        return 'wrong'
    }
    if (quizState.value.quizEnabled) {
    if (hasQuizQuestions.value) {
        return 'answering'
    }
    return 'unopened'
@@ -444,6 +462,18 @@
        return `开放时间:${formatDateLabel(quizState.value.quizAvailableAt)}`
    }
    return ''
})
const submitHintText = computed(() => {
    if (quizState.value.quizSubmitted || !hasQuizQuestions.value) {
        return ''
    }
    if (isQuizTimeReached.value) {
        return ''
    }
    if (quizState.value.quizAvailableAt) {
        return `答题开放时间:${formatDateLabel(quizState.value.quizAvailableAt)}`
    }
    return '答题暂未开放'
})
const videoPlaceholderText = computed(() => {
    if (loading.value) {
@@ -481,6 +511,7 @@
 */
onHide(() => {
    clearPlaybackStartupTimer()
    clearQuizAvailabilityTimer()
    submitCurrentWatchSegment()
})
@@ -489,6 +520,7 @@
 */
onUnload(() => {
    clearPlaybackStartupTimer()
    clearQuizAvailabilityTimer()
    submitCurrentWatchSegment()
})
@@ -571,7 +603,9 @@
                ? response.data.pointsReward
                : Number(liveDetail.value.quizPoints || 0),
        }
        quizNowTimestamp.value = Date.now()
        questions.value = createQuizQuestions(response.data.questions)
        startQuizAvailabilityTimer()
        return true
    } finally {
        quizLoading.value = false
@@ -612,10 +646,38 @@
 * 重置答题状态,避免切换场次时沿用上一场直播数据。
 */
function resetQuizState() {
    clearQuizAvailabilityTimer()
    quizLoading.value = false
    quizSubmitting.value = false
    quizNowTimestamp.value = Date.now()
    quizState.value = createEmptyQuizState()
    questions.value = []
}
/**
 * 清理答题开放时间轮询,避免页面切换后重复计时。
 */
function clearQuizAvailabilityTimer() {
    if (quizAvailabilityTimer) {
        clearInterval(quizAvailabilityTimer)
        quizAvailabilityTimer = null
    }
}
/**
 * 在答题开放前启动本地时间轮询,到点后自动启用提交按钮。
 */
function startQuizAvailabilityTimer() {
    clearQuizAvailabilityTimer()
    if (!quizState.value.quizAvailableAt || quizState.value.quizSubmitted || isQuizTimeReached.value) {
        return
    }
    quizAvailabilityTimer = setInterval(() => {
        quizNowTimestamp.value = Date.now()
        if (isQuizTimeReached.value) {
            clearQuizAvailabilityTimer()
        }
    }, 1000)
}
/**
@@ -628,6 +690,20 @@
    const normalizedValue = value.includes('T') ? value : value.replace(' ', 'T')
    const parsedTime = new Date(normalizedValue).getTime()
    return Number.isNaN(parsedTime) ? 0 : parsedTime
}
/**
 * 根据本地当前时间判断是否已达到答题开放时间。
 */
function isQuizAvailableByTime(value?: string): boolean {
    if (!value) {
        return false
    }
    const availableTimestamp = parsePlaybackStartTime(value)
    if (!availableTimestamp) {
        return false
    }
    return quizNowTimestamp.value >= availableTimestamp
}
/**
@@ -971,7 +1047,7 @@
 * 记录当前题目的选项结果。
 */
function selectOption(qIndex: number, oIndex: number) {
    if (answerViewState.value !== 'answering') {
    if (quizState.value.quizSubmitted || !hasQuizQuestions.value) {
        return
    }
    const question = questions.value[qIndex]
@@ -986,7 +1062,7 @@
 * 提交观看页答题结果,并根据返回结果切换答题状态展示。
 */
async function submitAnswer() {
    if (answerViewState.value !== 'answering' || quizSubmitting.value) {
    if (!isSubmitEnabled.value || quizSubmitting.value) {
        return
    }
    const hasUnanswered = questions.value.some((item) => !item.selectedOption)
@@ -1009,6 +1085,7 @@
        if (response?.code !== 0 || !response.data) {
            return
        }
        clearQuizAvailabilityTimer()
        applyQuizSubmitResult(response.data)
    } finally {
        quizSubmitting.value = false
@@ -1325,7 +1402,7 @@
    .submit-action-area {
        padding: 24rpx 32rpx;
        padding-bottom: 68rpx;
        padding-bottom: 24rpx;
        display: flex;
        justify-content: space-between;
@@ -1355,6 +1432,14 @@
        }
    }
    .submit-hint {
        padding: 0 40rpx 68rpx;
        font-size: 24rpx;
        line-height: 36rpx;
        color: #999999;
        text-align: right;
    }
    .result-container {
        padding: 40rpx;
        display: flex;