Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
1个文件已删除
28个文件已添加
25个文件已修改
| | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | # 管理后台部署补充说明: |
| | |
| | | # 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,避免页面继续请求本地开发地址。 |
| | |
| | | <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> |
| New file |
| | |
| | | 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统一系统通知表'; |
| New file |
| | |
| | | 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用户活动通知记录表'; |
| | |
| | | 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", |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| | |
| | | @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(); |
| | | |
| | | /** |
| | | * 获取验证码有效期秒数。 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取是否启用开发调试验证码。 |
| | | * 获取短信登录调试验证码配置。 |
| | | */ |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | @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:"; |
| | |
| | | * 校验登录验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断是否命中本地调试验证码。 |
| | | * 判断当前场景是否命中调试验证码。 |
| | | */ |
| | | 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) { |
| | |
| | | @TableField("district_name") |
| | | private String districtName; |
| | | |
| | | private String address; |
| | | |
| | | @TableField("clinic_address") |
| | | private String clinicAddress; |
| | | |
| | |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | private final HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper; |
| | | private final AppUserNotificationSettingMapper appUserNotificationSettingMapper; |
| | | private final AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper; |
| | | private final AppUserActivityNoticeMapper appUserActivityNoticeMapper; |
| | | private final AppSystemNoticeMapper appSystemNoticeMapper; |
| | | |
| | | /** |
| | | * 注入 APP 通知列表所需依赖。 |
| | |
| | | HealthCourseVideoMapper healthCourseVideoMapper, |
| | | HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper, |
| | | AppUserNotificationSettingMapper appUserNotificationSettingMapper, |
| | | AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper) { |
| | | AppUserNoticeReadRecordMapper appUserNoticeReadRecordMapper, |
| | | AppUserActivityNoticeMapper appUserActivityNoticeMapper, |
| | | AppSystemNoticeMapper appSystemNoticeMapper) { |
| | | this.appAuthSessionService = appAuthSessionService; |
| | | this.userMapper = userMapper; |
| | | this.systemSettingsMapper = systemSettingsMapper; |
| | |
| | | this.healthCourseMemberJoinRecordMapper = healthCourseMemberJoinRecordMapper; |
| | | this.appUserNotificationSettingMapper = appUserNotificationSettingMapper; |
| | | this.appUserNoticeReadRecordMapper = appUserNoticeReadRecordMapper; |
| | | this.appUserActivityNoticeMapper = appUserActivityNoticeMapper; |
| | | this.appSystemNoticeMapper = appSystemNoticeMapper; |
| | | } |
| | | |
| | | /** |
| | |
| | | for (String noticeType : NOTICE_TYPES) { |
| | | saveReadRecord(userId, noticeType, now); |
| | | } |
| | | markAllActivityRead(userId, now); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | "/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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | "/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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构建直播提醒详情。 |
| | | */ |
| | | 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) { |
| | |
| | | 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, |
| | | "活动通知", |
| | |
| | | 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++; |
| | | } |
| | | } |
| | |
| | | NOTICE_TYPE_ACTIVITY, |
| | | "活动通知", |
| | | trimToEmpty(latest.getTitle()), |
| | | latest.getCreatedAt(), |
| | | chooseTime(latestRecord.getCreatedAt(), latestRecord.getPushedAt(), latest.getCreatedAt()), |
| | | "/static/image/notice/activity.png", |
| | | unreadCount |
| | | ); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构建订单状态更新详情。 |
| | | */ |
| | | 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, |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构建课程更新通知详情。 |
| | | */ |
| | | 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, |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 解析直播提醒最新快照。 |
| | | */ |
| | | 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) { |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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() { |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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"; |
| | | } |
| | | } |
| | |
| | | return Collections.emptyList(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 将对象序列化为 JSON 字符串,序列化失败时返回空对象字符串。 |
| | | */ |
| | | public static String toJsonString(Object value) { |
| | | try { |
| | | return MAPPER.writeValueAsString(value); |
| | | } catch (Exception e) { |
| | | return "{}"; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | next.setDeleted(0); |
| | | healthCourseVideoMapper.insert(next); |
| | | refreshCourseStats(courseId); |
| | | dispatchCourseUpdateNotice(courseId, next); |
| | | return detailVideoResource(next.getId()); |
| | | } |
| | | |
| | |
| | | video.setDeleted(0); |
| | | healthCourseVideoMapper.insert(video); |
| | | touchedCourseIds.add(course.getId()); |
| | | dispatchCourseUpdateNotice(course.getId(), video); |
| | | } |
| | | if (touchedCourseIds.isEmpty()) { |
| | | throw new IllegalArgumentException("导入文件没有可用数据"); |
| | |
| | | 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) { |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | 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) { |
| | |
| | | .eq(LiveReserve::getLiveCourseId, courseId) |
| | | .eq(LiveReserve::getSessionId, sessionId) |
| | | .eq(LiveReserve::getIsCancel, 0)); |
| | | // 后台手动触发时直接对当前场次预约用户执行一次真正的通知发放。 |
| | | liveRemindDispatchService.dispatchManualRemind(session); |
| | | return new LivePushNotificationVO(cnt == null ? 0L : cnt, LocalDateTime.now()); |
| | | } |
| | | |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | public class ExchangeShipRequest { |
| | | |
| | | @NotBlank(message = "请输入物流单号") |
| | | @Size(max = 5, message = "物流单号长度不能超过5个字符") |
| | | @Size(max = 32, message = "物流单号长度不能超过32个字符") |
| | | private String trackingNo; |
| | | } |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | order.setTrackingNo(request.getTrackingNo().trim()); |
| | | order.setShippedAt(now); |
| | | pointsExchangeOrderMapper.updateById(order); |
| | | dispatchOrderShippedNotice(order); |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | 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 |
| | |
| | | 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; |
| | |
| | | private final UserMapper userMapper; |
| | | private final ClinicMapper clinicMapper; |
| | | private final PointsLedgerAppService pointsLedgerAppService; |
| | | private final AppSystemNoticeDispatchService appSystemNoticeDispatchService; |
| | | |
| | | public PointsSettlementService(PointsConfigMapper pointsConfigMapper, |
| | | UserPointsAccountMapper userPointsAccountMapper, |
| | |
| | | PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper, |
| | | UserMapper userMapper, |
| | | ClinicMapper clinicMapper, |
| | | PointsLedgerAppService pointsLedgerAppService) { |
| | | PointsLedgerAppService pointsLedgerAppService, |
| | | AppSystemNoticeDispatchService appSystemNoticeDispatchService) { |
| | | this.pointsConfigMapper = pointsConfigMapper; |
| | | this.userPointsAccountMapper = userPointsAccountMapper; |
| | | this.pointsExchangeOrderMapper = pointsExchangeOrderMapper; |
| | |
| | | this.userMapper = userMapper; |
| | | this.clinicMapper = clinicMapper; |
| | | this.pointsLedgerAppService = pointsLedgerAppService; |
| | | this.appSystemNoticeDispatchService = appSystemNoticeDispatchService; |
| | | } |
| | | |
| | | @Scheduled(fixedDelayString = "${app.points.settlement-interval-ms:60000}") |
| | |
| | | order.setTotalPoints(sumPoints(group)); |
| | | order.setStatus(0); |
| | | pointsExchangeOrderMapper.insert(order); |
| | | dispatchGiftOrderCreatedNotice(order, config, group); |
| | | |
| | | for (PointsExchangeOrderItem item : group) { |
| | | item.setOrderId(order.getId()); |
| | |
| | | ); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 在积分兑换汇总订单生成后,通知当前诊所用户尽快处理兑换单。 |
| | | */ |
| | | 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) { |
| | |
| | | private String buildSettlementReferenceNo(Long configId) { |
| | | return SETTLEMENT_CLEAR_REFERENCE_PREFIX + (configId == null ? 0L : configId.longValue()); |
| | | } |
| | | |
| | | /** |
| | | * 返回安全文本,避免通知载荷出现空值。 |
| | | */ |
| | | private String defaultText(String value) { |
| | | return value == null ? "" : value; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | | |
| | |
| | | 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) { |
| | |
| | | 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); |
| | | } |
| | | |
| New file |
| | |
| | | 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) |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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" |
| | |
| | | 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: "" |
| | |
| | | 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: "" |
| | |
| | | share: |
| | | # 邀请二维码默认指向线上下载页,开发环境可在 application-local.yml 覆盖。 |
| | | landing-base-url: https://haizhentong.com/app-download/ |
| | | jpush: |
| | | appKey: 111 |
| | | masterSecret: 111 |
| | | |
| | | logging: |
| | | level: |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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> |
| | |
| | | "pages": [{ |
| | | "path": "pages/home/home", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | "navigationStyle": "custom", |
| | | "enablePullDownRefresh": true |
| | | } |
| | | }, { |
| | | "path": "pages/login/login", |
| | |
| | | { |
| | | "path": "pages/publish/publish", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | "navigationStyle": "custom", |
| | | "enablePullDownRefresh": true |
| | | } |
| | | }, |
| | | { |
| | |
| | | } |
| | | }, |
| | | { |
| | | "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" |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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) |
| | | } |
| | |
| | | <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 { |
| | |
| | | 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); |
| | | |
| | | /** |
| | | * 初始化导航栏所需系统信息。 |
| | |
| | | * 每次进入首页都刷新首页模块数据,确保展示最新 banner 与直播状态。 |
| | | */ |
| | | onShow(() => { |
| | | loadBannerList(); |
| | | loadHomeLiveCourseList(); |
| | | loadFeaturedContent(); |
| | | refreshHomeData(); |
| | | }); |
| | | |
| | | /** |
| | | * 首页下拉时重新拉取首页聚合数据,并在结束后关闭原生刷新状态。 |
| | | */ |
| | | onPullDownRefresh(async () => { |
| | | try { |
| | | await refreshHomeData(); |
| | | } finally { |
| | | uni.stopPullDownRefresh(); |
| | | } |
| | | }); |
| | | |
| | | /** |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 聚合刷新首页三块内容,避免 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) => { |
| | |
| | | * 点击通知卡片后标记单类已读,并执行既有页面跳转。 |
| | | */ |
| | | 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}`, |
| | | }) |
| | | } |
| | | |
| New file |
| | |
| | | <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> |
| | |
| | | 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) |
| | | } |
| | |
| | | |
| | | <script setup lang="ts"> |
| | | import { ref, onMounted } from 'vue'; |
| | | import { onPullDownRefresh } from '@dcloudio/uni-app' |
| | | import { getHealthPopularizationList } from './service' |
| | | |
| | | interface PopularizationListItem { |
| | |
| | | }); |
| | | |
| | | /** |
| | | * 下拉刷新健康科普列表,并在结束后关闭原生刷新状态。 |
| | | */ |
| | | onPullDownRefresh(async () => { |
| | | try { |
| | | await loadPopularizationList() |
| | | } finally { |
| | | uni.stopPullDownRefresh() |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * 拉取管理后台已启用的健康科普列表数据。 |
| | | */ |
| | | async function loadPopularizationList() { |
| | |
| | | <!-- 底部按钮区域 --> |
| | | <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> |
| | | |
| | | <!-- 答题完成结果区域 --> |
| | |
| | | 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[]>([]) |
| | |
| | | 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 |
| | | |
| | | /** |
| | | * 生成观看页默认直播详情,避免模板访问空对象。 |
| | |
| | | }) |
| | | 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 '已得分' |
| | |
| | | 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) { |
| | |
| | | } |
| | | return 'wrong' |
| | | } |
| | | if (quizState.value.quizEnabled) { |
| | | if (hasQuizQuestions.value) { |
| | | return 'answering' |
| | | } |
| | | return 'unopened' |
| | |
| | | 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) { |
| | |
| | | */ |
| | | onHide(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | | |
| | |
| | | */ |
| | | onUnload(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | | |
| | |
| | | ? 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 |
| | |
| | | * 重置答题状态,避免切换场次时沿用上一场直播数据。 |
| | | */ |
| | | 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) |
| | | } |
| | | |
| | | /** |
| | |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | * 记录当前题目的选项结果。 |
| | | */ |
| | | function selectOption(qIndex: number, oIndex: number) { |
| | | if (answerViewState.value !== 'answering') { |
| | | if (quizState.value.quizSubmitted || !hasQuizQuestions.value) { |
| | | return |
| | | } |
| | | const question = questions.value[qIndex] |
| | |
| | | * 提交观看页答题结果,并根据返回结果切换答题状态展示。 |
| | | */ |
| | | async function submitAnswer() { |
| | | if (answerViewState.value !== 'answering' || quizSubmitting.value) { |
| | | if (!isSubmitEnabled.value || quizSubmitting.value) { |
| | | return |
| | | } |
| | | const hasUnanswered = questions.value.some((item) => !item.selectedOption) |
| | |
| | | if (response?.code !== 0 || !response.data) { |
| | | return |
| | | } |
| | | clearQuizAvailabilityTimer() |
| | | applyQuizSubmitResult(response.data) |
| | | } finally { |
| | | quizSubmitting.value = false |
| | |
| | | |
| | | .submit-action-area { |
| | | padding: 24rpx 32rpx; |
| | | padding-bottom: 68rpx; |
| | | padding-bottom: 24rpx; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | .submit-hint { |
| | | padding: 0 40rpx 68rpx; |
| | | font-size: 24rpx; |
| | | line-height: 36rpx; |
| | | color: #999999; |
| | | text-align: right; |
| | | } |
| | | |
| | | .result-container { |
| | | padding: 40rpx; |
| | | display: flex; |