feat: 新增定时账单导入提醒与通知直达导入页
- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller - 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送 - 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒 - 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发 - 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志 - 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗 - 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package cn.rosercode.finance.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 通知调度配置类。
|
||||
*
|
||||
* <p>开启 Spring 的定时任务能力,供通知调度器按用户配置的周期检查并推送账单导入提醒。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:36:47
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class NotificationSchedulingConfig {
|
||||
}
|
||||
@@ -17,4 +17,7 @@ public class AppConfig {
|
||||
|
||||
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
|
||||
private boolean registrationEnabled = false;
|
||||
|
||||
/** 通知链接地址(完整链接,如 https://finance.rosercode.cn/bill-import),为空时通知不附带链接 */
|
||||
private String notificationUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.config.app.AppConfig;
|
||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
||||
import cn.rosercode.finance.dto.NotificationUrlRequest;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 应用配置控制器
|
||||
*
|
||||
* <p>提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接)。</p>
|
||||
*
|
||||
* @date 2026-09-18 15:55:15
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/app")
|
||||
@Tag(name = "应用配置", description = "全局应用级配置读写接口")
|
||||
public class AppConfigController {
|
||||
|
||||
private final AppConfigStore appConfigStore;
|
||||
|
||||
public AppConfigController(AppConfigStore appConfigStore) {
|
||||
this.appConfigStore = appConfigStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询通知链接地址。
|
||||
*
|
||||
* @return 通知链接地址,未配置时为空
|
||||
*/
|
||||
@GetMapping("/notification-url")
|
||||
@Operation(summary = "查询通知链接地址", description = "读取账单导入提醒中使用的跳转链接")
|
||||
public Result<String> getNotificationUrl() {
|
||||
return Result.success(appConfigStore.load().getNotificationUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知链接地址。
|
||||
*
|
||||
* @param request 通知链接地址更新请求
|
||||
* @return 更新后的通知链接地址
|
||||
*/
|
||||
@PutMapping("/notification-url")
|
||||
@Operation(summary = "更新通知链接地址", description = "设置账单导入提醒中使用的跳转链接")
|
||||
public Result<String> updateNotificationUrl(@RequestBody NotificationUrlRequest request) {
|
||||
AppConfig config = appConfigStore.load();
|
||||
config.setNotificationUrl(request.getUrl());
|
||||
appConfigStore.save(config);
|
||||
return Result.success(config.getNotificationUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.NotificationChannelRequest;
|
||||
import cn.rosercode.finance.dto.NotificationConfigRequest;
|
||||
import cn.rosercode.finance.service.NotificationChannelService;
|
||||
import cn.rosercode.finance.service.NotificationConfigService;
|
||||
import cn.rosercode.finance.service.NotificationService;
|
||||
import cn.rosercode.finance.vo.NotificationChannelVO;
|
||||
import cn.rosercode.finance.vo.NotificationConfigVO;
|
||||
import cn.rosercode.finance.vo.NotificationLogVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通知控制器
|
||||
*
|
||||
* <p>提供账单导入提醒的周期配置、推送渠道管理、测试发送与发送日志查询接口。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:38:35
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/notification")
|
||||
@Tag(name = "定时通知", description = "账单导入提醒的周期配置与多渠道推送相关接口")
|
||||
public class NotificationController {
|
||||
|
||||
private final NotificationConfigService notificationConfigService;
|
||||
private final NotificationChannelService notificationChannelService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public NotificationController(NotificationConfigService notificationConfigService,
|
||||
NotificationChannelService notificationChannelService,
|
||||
NotificationService notificationService) {
|
||||
this.notificationConfigService = notificationConfigService;
|
||||
this.notificationChannelService = notificationChannelService;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知配置。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 通知配置
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "获取通知配置", description = "获取当前用户的账单导入提醒周期配置")
|
||||
public Result<NotificationConfigVO> getConfig(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(notificationConfigService.getConfig(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前用户的通知配置。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param configRequest 配置保存请求
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/config")
|
||||
@Operation(summary = "保存通知配置", description = "更新当前用户的提醒周期与发送时间")
|
||||
public Result<Void> saveConfig(HttpServletRequest request,
|
||||
@RequestBody NotificationConfigRequest configRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
notificationConfigService.saveConfig(userId, configRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的通知渠道。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 渠道列表
|
||||
*/
|
||||
@GetMapping("/channels")
|
||||
@Operation(summary = "查询通知渠道", description = "查询当前用户配置的所有推送渠道")
|
||||
public Result<List<NotificationChannelVO>> listChannels(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(notificationChannelService.listChannels(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增通知渠道。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param channelRequest 渠道保存请求
|
||||
* @return 新渠道ID
|
||||
*/
|
||||
@PostMapping("/channels")
|
||||
@Operation(summary = "新增通知渠道", description = "新增邮箱或 Server 酱推送渠道")
|
||||
public Result<Long> createChannel(HttpServletRequest request,
|
||||
@RequestBody NotificationChannelRequest channelRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(notificationChannelService.createChannel(userId, channelRequest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改通知渠道。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 渠道ID
|
||||
* @param channelRequest 渠道保存请求
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/channels/{id}")
|
||||
@Operation(summary = "修改通知渠道", description = "更新指定渠道的名称、开关与配置")
|
||||
public Result<Void> updateChannel(HttpServletRequest request, @PathVariable Long id,
|
||||
@RequestBody NotificationChannelRequest channelRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
notificationChannelService.updateChannel(userId, id, channelRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除通知渠道。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 渠道ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/channels/{id}")
|
||||
@Operation(summary = "删除通知渠道", description = "删除指定的推送渠道")
|
||||
public Result<Void> deleteChannel(HttpServletRequest request, @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
notificationChannelService.deleteChannel(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试指定渠道。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 渠道ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/channels/{id}/test")
|
||||
@Operation(summary = "测试通知渠道", description = "向指定渠道发送一条测试消息")
|
||||
public Result<Void> testChannel(HttpServletRequest request, @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
notificationService.sendTest(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即发送账单导入提醒。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 发送成功的渠道数
|
||||
*/
|
||||
@PostMapping("/send-now")
|
||||
@Operation(summary = "立即发送提醒", description = "跳过周期设置,立即向所有已启用渠道发送账单导入提醒")
|
||||
public Result<Integer> sendNow(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(notificationService.sendReminderNow(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近的发送日志。
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param limit 条数上限,默认 20
|
||||
* @return 日志列表
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
@Operation(summary = "查询发送日志", description = "查询当前用户最近的提醒发送结果")
|
||||
public Result<List<NotificationLogVO>> listLogs(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(notificationService.listLogs(userId, limit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知渠道保存请求DTO,用于新增或修改消息推送渠道。
|
||||
*
|
||||
* @date 2026-09-18 12:37:12
|
||||
*/
|
||||
@Data
|
||||
public class NotificationChannelRequest {
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称,便于用户识别 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 渠道配置,字段随类型不同而变化;EMAIL 的 password 留空表示保留原值 */
|
||||
private Map<String, Object> config;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 通知配置保存请求DTO,用于更新用户的账单导入提醒周期。
|
||||
*
|
||||
* @date 2026-09-18 12:37:11
|
||||
*/
|
||||
@Data
|
||||
public class NotificationConfigRequest {
|
||||
|
||||
/** 是否启用定时提醒 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
|
||||
private String frequency;
|
||||
|
||||
/** 每月提醒日(1-28),frequency=MONTHLY 时生效 */
|
||||
private Integer dayOfMonth;
|
||||
|
||||
/** 每周提醒日(1-7,1 为周一),frequency=WEEKLY 时生效 */
|
||||
private Integer dayOfWeek;
|
||||
|
||||
/** 提醒时间(HH:mm) */
|
||||
private String sendTime;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 通知链接地址更新请求对象。
|
||||
*
|
||||
* @date 2026-09-18 15:55:15
|
||||
*/
|
||||
@Data
|
||||
public class NotificationUrlRequest {
|
||||
|
||||
/** 通知链接地址(完整链接,如 https://finance.rosercode.cn/bill-import) */
|
||||
private String url;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通知渠道实体类,对应数据库表 t_notification_channel,用于记录用户配置的消息推送渠道。
|
||||
*
|
||||
* @date 2026-09-18 12:37:06
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_notification_channel")
|
||||
public class NotificationChannel {
|
||||
|
||||
/** 渠道ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称,便于用户识别 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 渠道配置(JSON),如 webhook 地址、SMTP 账号等 */
|
||||
private String config;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通知配置实体类,对应数据库表 t_notification_config,用于记录用户的账单导入提醒周期。
|
||||
*
|
||||
* @date 2026-09-18 12:37:06
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_notification_config")
|
||||
public class NotificationConfig {
|
||||
|
||||
/** 配置ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 是否启用定时提醒 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
|
||||
private String frequency;
|
||||
|
||||
/** 每月提醒日(1-28),frequency=MONTHLY 时生效 */
|
||||
private Integer dayOfMonth;
|
||||
|
||||
/** 每周提醒日(1-7,1 为周一),frequency=WEEKLY 时生效 */
|
||||
private Integer dayOfWeek;
|
||||
|
||||
/** 提醒时间(HH:mm) */
|
||||
private String sendTime;
|
||||
|
||||
/** 最近一次发送时间,用于避免同一周期重复提醒 */
|
||||
private LocalDateTime lastSentAt;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通知日志实体类,对应数据库表 t_notification_log,用于记录每次提醒的发送结果。
|
||||
*
|
||||
* @date 2026-09-18 12:37:07
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_notification_log")
|
||||
public class NotificationLog {
|
||||
|
||||
/** 日志ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 渠道ID,测试或渠道已删除时可能为空 */
|
||||
private Long channelId;
|
||||
|
||||
/** 渠道类型 */
|
||||
private String channelType;
|
||||
|
||||
/** 是否成功 */
|
||||
private Boolean success;
|
||||
|
||||
/** 结果描述或失败原因 */
|
||||
private String message;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.rosercode.finance.enums;
|
||||
|
||||
/**
|
||||
* 通知渠道类型枚举,标识消息推送的途径。
|
||||
*
|
||||
* @date 2026-09-18 12:37:05
|
||||
*/
|
||||
public enum NotificationChannelType {
|
||||
|
||||
/** 邮箱(SMTP,如 QQ 邮箱) */
|
||||
EMAIL,
|
||||
|
||||
/** Server 酱(推送到个人微信) */
|
||||
SERVERCHAN
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.rosercode.finance.enums;
|
||||
|
||||
/**
|
||||
* 通知周期枚举,标识账单导入提醒的触发频率。
|
||||
*
|
||||
* @date 2026-09-18 12:37:04
|
||||
*/
|
||||
public enum NotificationFrequency {
|
||||
|
||||
/** 每天提醒 */
|
||||
DAILY,
|
||||
|
||||
/** 每周提醒 */
|
||||
WEEKLY,
|
||||
|
||||
/** 每月提醒 */
|
||||
MONTHLY
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 通知渠道Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知渠道表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-18 12:37:08
|
||||
*/
|
||||
@Mapper
|
||||
public interface NotificationChannelMapper extends BaseMapper<NotificationChannel> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.NotificationConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 通知配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知配置表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-18 12:37:08
|
||||
*/
|
||||
@Mapper
|
||||
public interface NotificationConfigMapper extends BaseMapper<NotificationConfig> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.NotificationLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 通知日志Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知日志表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-18 12:37:08
|
||||
*/
|
||||
@Mapper
|
||||
public interface NotificationLogMapper extends BaseMapper<NotificationLog> {
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package cn.rosercode.finance.notification;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HTTP 通知发送器抽象基类。
|
||||
*
|
||||
* <p>封装基于 Webhook 的渠道共用的 JSON / 表单 POST 调用与常用取值方法。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:37:34
|
||||
*/
|
||||
public abstract class AbstractHttpNotificationSender implements NotificationSender {
|
||||
|
||||
/** 连接超时时间 */
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
/** 请求超时时间 */
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);
|
||||
|
||||
/** 错误信息中保留的响应体最大长度 */
|
||||
private static final int ERROR_BODY_LIMIT = 300;
|
||||
|
||||
/** JSON 映射器,供子类解析响应使用 */
|
||||
protected final ObjectMapper objectMapper;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
protected AbstractHttpNotificationSender(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 JSON 形式发送 POST 请求。
|
||||
*
|
||||
* @param url 请求地址
|
||||
* @param body 请求体对象
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
protected String postJson(String url, Object body) {
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("构建通知请求失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以表单形式发送 POST 请求(application/x-www-form-urlencoded)。
|
||||
*
|
||||
* @param url 请求地址
|
||||
* @param form 表单参数
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
protected String postForm(String url, Map<String, String> form) {
|
||||
StringBuilder body = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : form.entrySet()) {
|
||||
if (body.length() > 0) {
|
||||
body.append('&');
|
||||
}
|
||||
body.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
|
||||
.append('=')
|
||||
.append(URLEncoder.encode(entry.getValue() == null ? "" : entry.getValue(),
|
||||
StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 HTTP 请求并校验响应状态码。
|
||||
*
|
||||
* @param request 请求对象
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
private String execute(HttpRequest request) {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new BusinessException("通知请求返回异常(" + response.statusCode() + "):" + truncate(response.body()));
|
||||
}
|
||||
return response.body();
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException("发送通知失败:" + describe(e));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("发送通知被中断");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取异常描述,消息为空时回退到异常类名。
|
||||
*
|
||||
* @param e 异常
|
||||
* @return 异常描述
|
||||
*/
|
||||
private String describe(Exception e) {
|
||||
return e.getMessage() == null || e.getMessage().isBlank()
|
||||
? e.getClass().getSimpleName() : e.getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断过长的响应体,避免错误信息过长。
|
||||
*
|
||||
* @param body 响应体
|
||||
* @return 截断后的字符串
|
||||
*/
|
||||
protected String truncate(String body) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
return body.length() <= ERROR_BODY_LIMIT ? body : body.substring(0, ERROR_BODY_LIMIT) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将配置值转为字符串。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @return 字符串,null 时返回 null
|
||||
*/
|
||||
protected String asString(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
protected boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cn.rosercode.finance.notification;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.enums.NotificationChannelType;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* 邮箱通知发送器,通过 SMTP 发送纯文本邮件(兼容 QQ 邮箱)。
|
||||
*
|
||||
* <p>SMTP 参数来自用户配置,因此每次发送动态构建 {@link JavaMailSenderImpl},
|
||||
* 不依赖全局邮件配置。QQ 邮箱默认使用 465 端口并开启 SSL。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:37:41
|
||||
*/
|
||||
@Component
|
||||
public class EmailNotificationSender implements NotificationSender {
|
||||
|
||||
/** 默认 SMTP 主机(QQ 邮箱) */
|
||||
private static final String DEFAULT_HOST = "smtp.qq.com";
|
||||
|
||||
/** 默认 SMTP 端口 */
|
||||
private static final int DEFAULT_PORT = 465;
|
||||
|
||||
@Override
|
||||
public NotificationChannelType type() {
|
||||
return NotificationChannelType.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Map<String, Object> config, String title, String content) {
|
||||
String host = stringOrDefault(config.get("smtpHost"), DEFAULT_HOST);
|
||||
int port = intOrDefault(config.get("smtpPort"), DEFAULT_PORT);
|
||||
String username = asString(config.get("username"));
|
||||
String password = asString(config.get("password"));
|
||||
String to = stringOrDefault(config.get("to"), username);
|
||||
boolean ssl = booleanOrDefault(config.get("ssl"), true);
|
||||
|
||||
if (isBlank(username) || isBlank(password)) {
|
||||
throw new BusinessException("请配置邮箱账号与授权码");
|
||||
}
|
||||
if (isBlank(to)) {
|
||||
throw new BusinessException("请配置收件人邮箱");
|
||||
}
|
||||
|
||||
JavaMailSenderImpl sender = new JavaMailSenderImpl();
|
||||
sender.setHost(host);
|
||||
sender.setPort(port);
|
||||
sender.setUsername(username);
|
||||
sender.setPassword(password);
|
||||
sender.setDefaultEncoding("UTF-8");
|
||||
|
||||
Properties props = sender.getJavaMailProperties();
|
||||
props.put("mail.transport.protocol", "smtp");
|
||||
props.put("mail.smtp.auth", "true");
|
||||
props.put("mail.smtp.ssl.enable", String.valueOf(ssl));
|
||||
props.put("mail.smtp.connectiontimeout", "10000");
|
||||
props.put("mail.smtp.timeout", "10000");
|
||||
props.put("mail.smtp.writetimeout", "10000");
|
||||
|
||||
try {
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
|
||||
helper.setFrom(username);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(title);
|
||||
helper.setText(content, false);
|
||||
sender.send(message);
|
||||
} catch (MessagingException | MailException e) {
|
||||
throw new BusinessException("邮件发送失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将配置值转为字符串。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @return 字符串,null 时返回 null
|
||||
*/
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字符串配置,为空时使用默认值。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @param defaultValue 默认值
|
||||
* @return 结果字符串
|
||||
*/
|
||||
private String stringOrDefault(Object value, String defaultValue) {
|
||||
String text = asString(value);
|
||||
return isBlank(text) ? defaultValue : text.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取整数配置,为空或非法时使用默认值。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @param defaultValue 默认值
|
||||
* @return 结果整数
|
||||
*/
|
||||
private int intOrDefault(Object value, int defaultValue) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取布尔配置,为空时使用默认值。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @param defaultValue 默认值
|
||||
* @return 结果布尔值
|
||||
*/
|
||||
private boolean booleanOrDefault(Object value, boolean defaultValue) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (value instanceof Boolean bool) {
|
||||
return bool;
|
||||
}
|
||||
return Boolean.parseBoolean(String.valueOf(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.rosercode.finance.notification;
|
||||
|
||||
import cn.rosercode.finance.enums.NotificationChannelType;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知发送器接口,定义不同渠道的消息推送能力。
|
||||
*
|
||||
* <p>各实现类负责将标题与正文转换为对应渠道的消息格式并发送。渠道的配置
|
||||
* (如 webhook 地址、SMTP 账号)由调用方解析为 Map 后传入。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:37:22
|
||||
*/
|
||||
public interface NotificationSender {
|
||||
|
||||
/**
|
||||
* 返回该发送器支持的渠道类型。
|
||||
*
|
||||
* @return 渠道类型
|
||||
*/
|
||||
NotificationChannelType type();
|
||||
|
||||
/**
|
||||
* 发送一条通知消息。
|
||||
*
|
||||
* @param config 渠道配置
|
||||
* @param title 消息标题
|
||||
* @param content 消息正文
|
||||
*/
|
||||
void send(Map<String, Object> config, String title, String content);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.rosercode.finance.notification;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.enums.NotificationChannelType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知发送器工厂,按渠道类型分发到对应的发送器实现。
|
||||
*
|
||||
* @date 2026-09-18 12:37:23
|
||||
*/
|
||||
@Component
|
||||
public class NotificationSenderFactory {
|
||||
|
||||
private final Map<NotificationChannelType, NotificationSender> senders = new EnumMap<>(NotificationChannelType.class);
|
||||
|
||||
/**
|
||||
* 构造方法,收集容器中所有发送器实现。
|
||||
*
|
||||
* @param senderList 发送器列表
|
||||
*/
|
||||
public NotificationSenderFactory(List<NotificationSender> senderList) {
|
||||
for (NotificationSender sender : senderList) {
|
||||
senders.put(sender.type(), sender);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据渠道类型获取发送器。
|
||||
*
|
||||
* @param type 渠道类型
|
||||
* @return 对应的发送器
|
||||
* @throws BusinessException 类型不支持时抛出
|
||||
*/
|
||||
public NotificationSender get(String type) {
|
||||
NotificationChannelType channelType;
|
||||
try {
|
||||
channelType = NotificationChannelType.valueOf(type);
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
throw new BusinessException("不支持的通知渠道类型:" + type);
|
||||
}
|
||||
NotificationSender sender = senders.get(channelType);
|
||||
if (sender == null) {
|
||||
throw new BusinessException("不支持的通知渠道类型:" + type);
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.rosercode.finance.notification;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.enums.NotificationChannelType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Server 酱通知发送器,通过 Server 酱 Turbo 接口将消息推送到个人微信。
|
||||
*
|
||||
* <p>用户只需在 sct.ftqq.com 获取 SendKey,消息以表单形式提交到
|
||||
* {@code https://sctapi.ftqq.com/{sendKey}.send},由 Server 酱转发到微信。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:58:53
|
||||
*/
|
||||
@Component
|
||||
public class ServerChanNotificationSender extends AbstractHttpNotificationSender {
|
||||
|
||||
/** Server 酱 Turbo 接口地址 */
|
||||
private static final String DEFAULT_BASE_URL = "https://sctapi.ftqq.com";
|
||||
|
||||
public ServerChanNotificationSender(ObjectMapper objectMapper) {
|
||||
super(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationChannelType type() {
|
||||
return NotificationChannelType.SERVERCHAN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Map<String, Object> config, String title, String content) {
|
||||
String sendKey = asString(config.get("sendKey"));
|
||||
if (isBlank(sendKey)) {
|
||||
throw new BusinessException("请配置 Server 酱 SendKey");
|
||||
}
|
||||
String baseUrl = asString(config.get("baseUrl"));
|
||||
if (isBlank(baseUrl)) {
|
||||
baseUrl = DEFAULT_BASE_URL;
|
||||
}
|
||||
// 兼容以斜杠结尾的自定义地址
|
||||
String url = (baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl)
|
||||
+ "/" + sendKey.trim() + ".send";
|
||||
|
||||
Map<String, String> form = new LinkedHashMap<>();
|
||||
form.put("title", title);
|
||||
form.put("desp", content);
|
||||
|
||||
checkResponse(postForm(url, form));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Server 酱响应,code 非 0 视为失败。
|
||||
*
|
||||
* @param response 响应体
|
||||
*/
|
||||
private void checkResponse(String response) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
int code = root.path("code").asInt(0);
|
||||
if (code != 0) {
|
||||
throw new BusinessException("Server 酱返回失败:" + root.path("message").asText(""));
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析 Server 酱响应失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package cn.rosercode.finance.scheduler;
|
||||
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.entity.NotificationConfig;
|
||||
import cn.rosercode.finance.enums.NotificationFrequency;
|
||||
import cn.rosercode.finance.mapper.NotificationConfigMapper;
|
||||
import cn.rosercode.finance.service.NotificationChannelService;
|
||||
import cn.rosercode.finance.service.NotificationService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通知调度器。
|
||||
*
|
||||
* <p>每分钟检查一次已启用的通知配置,当到达用户设定的提醒周期且本周期尚未发送时,
|
||||
* 向该用户所有已启用的渠道推送账单导入提醒,并更新最近发送时间。</p>
|
||||
*
|
||||
* @date 2026-09-18 12:38:30
|
||||
*/
|
||||
@Component
|
||||
public class NotificationScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationScheduler.class);
|
||||
|
||||
/** 时间格式 */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
/** 默认提醒时间 */
|
||||
private static final LocalTime DEFAULT_TIME = LocalTime.of(9, 0);
|
||||
|
||||
private final NotificationConfigMapper notificationConfigMapper;
|
||||
private final NotificationChannelService notificationChannelService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public NotificationScheduler(NotificationConfigMapper notificationConfigMapper,
|
||||
NotificationChannelService notificationChannelService,
|
||||
NotificationService notificationService) {
|
||||
this.notificationConfigMapper = notificationConfigMapper;
|
||||
this.notificationChannelService = notificationChannelService;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时检查并发送提醒。
|
||||
*
|
||||
* <p>固定延迟 60 秒,启动后延迟 30 秒执行首次检查。整体异常仅记录调试日志,
|
||||
* 避免数据库尚未配置时刷屏。</p>
|
||||
*/
|
||||
@Scheduled(fixedDelay = 60_000L, initialDelay = 30_000L)
|
||||
public void checkAndSend() {
|
||||
List<NotificationConfig> configs;
|
||||
try {
|
||||
configs = notificationConfigMapper.selectList(new LambdaQueryWrapper<NotificationConfig>()
|
||||
.eq(NotificationConfig::getEnabled, true));
|
||||
} catch (Exception e) {
|
||||
log.debug("跳过通知调度,读取配置失败:{}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (NotificationConfig config : configs) {
|
||||
try {
|
||||
dispatchIfDue(config, now);
|
||||
} catch (Exception e) {
|
||||
log.warn("处理用户 {} 的账单提醒失败:{}", config.getUserId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断并执行单个用户的通知发送。
|
||||
*
|
||||
* @param config 通知配置
|
||||
* @param now 当前时间
|
||||
*/
|
||||
private void dispatchIfDue(NotificationConfig config, LocalDateTime now) {
|
||||
if (!isDue(config, now)) {
|
||||
return;
|
||||
}
|
||||
List<NotificationChannel> channels = notificationChannelService.listEnabledChannels(config.getUserId());
|
||||
if (channels.isEmpty()) {
|
||||
// 无可用渠道时不更新最近发送时间,待用户配置后仍可在本周期触发
|
||||
return;
|
||||
}
|
||||
notificationService.sendToChannels(config.getUserId(), channels,
|
||||
NotificationService.REMINDER_TITLE, notificationService.buildReminderContent());
|
||||
config.setLastSentAt(now);
|
||||
notificationConfigMapper.updateById(config);
|
||||
log.info("已向用户 {} 的 {} 个渠道发送账单导入提醒", config.getUserId(), channels.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前时间是否已到达本周期提醒时间且尚未发送。
|
||||
*
|
||||
* @param config 通知配置
|
||||
* @param now 当前时间
|
||||
* @return 需要发送时返回 true
|
||||
*/
|
||||
boolean isDue(NotificationConfig config, LocalDateTime now) {
|
||||
LocalDateTime due = computeLatestDueTime(config, now);
|
||||
LocalDateTime lastSent = config.getLastSentAt();
|
||||
return lastSent == null || lastSent.isBefore(due);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前时间之前最近的一个触发时间点。
|
||||
*
|
||||
* @param config 通知配置
|
||||
* @param now 当前时间
|
||||
* @return 最近的触发时间点
|
||||
*/
|
||||
LocalDateTime computeLatestDueTime(NotificationConfig config, LocalDateTime now) {
|
||||
LocalTime time = parseTime(config.getSendTime());
|
||||
String frequency = config.getFrequency() == null
|
||||
? NotificationFrequency.MONTHLY.name() : config.getFrequency();
|
||||
|
||||
switch (frequency) {
|
||||
case "DAILY": {
|
||||
LocalDateTime today = now.toLocalDate().atTime(time);
|
||||
return now.isBefore(today) ? today.minusDays(1) : today;
|
||||
}
|
||||
case "WEEKLY": {
|
||||
int target = config.getDayOfWeek() == null ? 1 : config.getDayOfWeek();
|
||||
LocalDate date = now.toLocalDate();
|
||||
int diff = (date.getDayOfWeek().getValue() - target + 7) % 7;
|
||||
LocalDateTime candidate = date.minusDays(diff).atTime(time);
|
||||
return candidate.isAfter(now) ? candidate.minusWeeks(1) : candidate;
|
||||
}
|
||||
case "MONTHLY":
|
||||
default: {
|
||||
int target = config.getDayOfMonth() == null ? 1 : config.getDayOfMonth();
|
||||
LocalDate date = now.toLocalDate();
|
||||
LocalDateTime candidate = date.withDayOfMonth(Math.min(target, date.lengthOfMonth())).atTime(time);
|
||||
if (candidate.isAfter(now)) {
|
||||
LocalDate previous = date.minusMonths(1);
|
||||
candidate = previous.withDayOfMonth(Math.min(target, previous.lengthOfMonth())).atTime(time);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析提醒时间,非法时回退默认值。
|
||||
*
|
||||
* @param sendTime 时间字符串
|
||||
* @return 提醒时间
|
||||
*/
|
||||
private LocalTime parseTime(String sendTime) {
|
||||
if (sendTime == null || sendTime.isBlank()) {
|
||||
return DEFAULT_TIME;
|
||||
}
|
||||
try {
|
||||
return LocalTime.parse(sendTime, TIME_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
return DEFAULT_TIME;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.NotificationChannelRequest;
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.vo.NotificationChannelVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通知渠道服务接口,负责用户消息推送渠道的增删改查。
|
||||
*
|
||||
* @date 2026-09-18 12:37:49
|
||||
*/
|
||||
public interface NotificationChannelService {
|
||||
|
||||
/**
|
||||
* 查询当前用户的所有通知渠道。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 渠道视图对象列表
|
||||
*/
|
||||
List<NotificationChannelVO> listChannels(Long userId);
|
||||
|
||||
/**
|
||||
* 新增通知渠道。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 渠道保存请求
|
||||
* @return 新渠道ID
|
||||
*/
|
||||
Long createChannel(Long userId, NotificationChannelRequest request);
|
||||
|
||||
/**
|
||||
* 修改通知渠道。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channelId 渠道ID
|
||||
* @param request 渠道保存请求
|
||||
*/
|
||||
void updateChannel(Long userId, Long channelId, NotificationChannelRequest request);
|
||||
|
||||
/**
|
||||
* 删除通知渠道。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channelId 渠道ID
|
||||
*/
|
||||
void deleteChannel(Long userId, Long channelId);
|
||||
|
||||
/**
|
||||
* 获取指定渠道原始实体,并校验归属。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channelId 渠道ID
|
||||
* @return 渠道实体
|
||||
*/
|
||||
NotificationChannel getRawChannel(Long userId, Long channelId);
|
||||
|
||||
/**
|
||||
* 查询当前用户所有已启用的通知渠道,供调度发送使用。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 已启用的渠道实体列表
|
||||
*/
|
||||
List<NotificationChannel> listEnabledChannels(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.NotificationConfigRequest;
|
||||
import cn.rosercode.finance.entity.NotificationConfig;
|
||||
import cn.rosercode.finance.vo.NotificationConfigVO;
|
||||
|
||||
/**
|
||||
* 通知配置服务接口,负责账单导入提醒周期的读写。
|
||||
*
|
||||
* @date 2026-09-18 12:37:48
|
||||
*/
|
||||
public interface NotificationConfigService {
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知配置,未配置时返回默认值。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 通知配置视图对象
|
||||
*/
|
||||
NotificationConfigVO getConfig(Long userId);
|
||||
|
||||
/**
|
||||
* 保存当前用户的通知配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 配置保存请求
|
||||
*/
|
||||
void saveConfig(Long userId, NotificationConfigRequest request);
|
||||
|
||||
/**
|
||||
* 获取通知配置原始实体,供调度器使用。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 通知配置实体,未配置时返回 null
|
||||
*/
|
||||
NotificationConfig getRawConfig(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.vo.NotificationLogVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通知发送服务接口,负责消息的实际推送与发送日志记录。
|
||||
*
|
||||
* @date 2026-09-18 12:37:50
|
||||
*/
|
||||
public interface NotificationService {
|
||||
|
||||
/** 账单导入提醒的消息标题 */
|
||||
String REMINDER_TITLE = "账单导入提醒";
|
||||
|
||||
/** 账单导入提醒的消息正文 */
|
||||
String REMINDER_CONTENT = "到了整理账单的时间,请及时导出并导入最新的账单,保持账目完整。";
|
||||
|
||||
/**
|
||||
* 构建账单导入提醒正文,配置了站点地址时附加账单上传页链接。
|
||||
*
|
||||
* @return 提醒正文
|
||||
*/
|
||||
String buildReminderContent();
|
||||
|
||||
/**
|
||||
* 向指定渠道发送一条测试消息。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channelId 渠道ID
|
||||
*/
|
||||
void sendTest(Long userId, Long channelId);
|
||||
|
||||
/**
|
||||
* 立即向当前用户所有已启用渠道发送账单导入提醒。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 发送成功的渠道数
|
||||
*/
|
||||
int sendReminderNow(Long userId);
|
||||
|
||||
/**
|
||||
* 向给定渠道列表发送提醒消息并记录日志。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channels 渠道列表
|
||||
* @param title 消息标题
|
||||
* @param content 消息正文
|
||||
*/
|
||||
void sendToChannels(Long userId, List<NotificationChannel> channels, String title, String content);
|
||||
|
||||
/**
|
||||
* 查询当前用户最近的发送日志。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param limit 条数上限
|
||||
* @return 日志视图对象列表
|
||||
*/
|
||||
List<NotificationLogVO> listLogs(Long userId, int limit);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.NotificationChannelRequest;
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.enums.NotificationChannelType;
|
||||
import cn.rosercode.finance.mapper.NotificationChannelMapper;
|
||||
import cn.rosercode.finance.service.NotificationChannelService;
|
||||
import cn.rosercode.finance.vo.NotificationChannelVO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知渠道服务实现类,处理用户消息推送渠道的增删改查与配置脱敏。
|
||||
*
|
||||
* @date 2026-09-18 12:38:09
|
||||
*/
|
||||
@Service
|
||||
public class NotificationChannelServiceImpl implements NotificationChannelService {
|
||||
|
||||
private final NotificationChannelMapper notificationChannelMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public NotificationChannelServiceImpl(NotificationChannelMapper notificationChannelMapper,
|
||||
ObjectMapper objectMapper) {
|
||||
this.notificationChannelMapper = notificationChannelMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<NotificationChannelVO> listChannels(Long userId) {
|
||||
List<NotificationChannel> channels = notificationChannelMapper.selectList(
|
||||
new LambdaQueryWrapper<NotificationChannel>()
|
||||
.eq(NotificationChannel::getUserId, userId)
|
||||
.orderByAsc(NotificationChannel::getId));
|
||||
return channels.stream().map(this::toVO).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public Long createChannel(Long userId, NotificationChannelRequest request) {
|
||||
String type = validateType(request.getType());
|
||||
NotificationChannel channel = new NotificationChannel();
|
||||
channel.setUserId(userId);
|
||||
channel.setType(type);
|
||||
channel.setName(resolveName(request.getName(), type));
|
||||
channel.setEnabled(request.getEnabled() == null || request.getEnabled());
|
||||
channel.setConfig(mergeConfig(request.getConfig(), null));
|
||||
notificationChannelMapper.insert(channel);
|
||||
return channel.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* EMAIL 渠道的密码留空时保留原值;渠道类型变更时不再沿用旧配置。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateChannel(Long userId, Long channelId, NotificationChannelRequest request) {
|
||||
NotificationChannel channel = getRawChannel(userId, channelId);
|
||||
String type = validateType(request.getType());
|
||||
String oldConfig = type.equals(channel.getType()) ? channel.getConfig() : null;
|
||||
channel.setType(type);
|
||||
channel.setName(resolveName(request.getName(), type));
|
||||
if (request.getEnabled() != null) {
|
||||
channel.setEnabled(request.getEnabled());
|
||||
}
|
||||
channel.setConfig(mergeConfig(request.getConfig(), oldConfig));
|
||||
notificationChannelMapper.updateById(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteChannel(Long userId, Long channelId) {
|
||||
getRawChannel(userId, channelId);
|
||||
notificationChannelMapper.deleteById(channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public NotificationChannel getRawChannel(Long userId, Long channelId) {
|
||||
NotificationChannel channel = notificationChannelMapper.selectById(channelId);
|
||||
if (channel == null || !channel.getUserId().equals(userId)) {
|
||||
throw new BusinessException("通知渠道不存在");
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<NotificationChannel> listEnabledChannels(Long userId) {
|
||||
return notificationChannelMapper.selectList(new LambdaQueryWrapper<NotificationChannel>()
|
||||
.eq(NotificationChannel::getUserId, userId)
|
||||
.eq(NotificationChannel::getEnabled, true)
|
||||
.orderByAsc(NotificationChannel::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为视图对象,移除邮箱密码并标记是否已配置。
|
||||
*
|
||||
* @param channel 渠道实体
|
||||
* @return 渠道视图对象
|
||||
*/
|
||||
private NotificationChannelVO toVO(NotificationChannel channel) {
|
||||
NotificationChannelVO vo = new NotificationChannelVO();
|
||||
vo.setId(channel.getId());
|
||||
vo.setType(channel.getType());
|
||||
vo.setName(channel.getName());
|
||||
vo.setEnabled(Boolean.TRUE.equals(channel.getEnabled()));
|
||||
vo.setCreatedAt(channel.getCreatedAt());
|
||||
vo.setUpdatedAt(channel.getUpdatedAt());
|
||||
|
||||
Map<String, Object> config = parseConfig(channel.getConfig());
|
||||
Object password = config.remove("password");
|
||||
vo.setHasPassword(password != null && !String.valueOf(password).isBlank());
|
||||
vo.setConfig(config);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验渠道类型。
|
||||
*
|
||||
* @param type 渠道类型
|
||||
* @return 规范化后的类型名称
|
||||
*/
|
||||
private String validateType(String type) {
|
||||
if (type == null || type.isBlank()) {
|
||||
throw new BusinessException("请选择通知渠道类型");
|
||||
}
|
||||
try {
|
||||
return NotificationChannelType.valueOf(type.trim().toUpperCase()).name();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("不支持的通知渠道类型:" + type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析渠道名称,为空时按类型给出默认名称。
|
||||
*
|
||||
* @param name 用户填写名称
|
||||
* @param type 渠道类型
|
||||
* @return 渠道名称
|
||||
*/
|
||||
private String resolveName(String name, String type) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name.trim();
|
||||
}
|
||||
return switch (NotificationChannelType.valueOf(type)) {
|
||||
case EMAIL -> "邮箱";
|
||||
case SERVERCHAN -> "Server 酱";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并新旧渠道配置:新值覆盖旧值,邮箱密码为空时不覆盖。
|
||||
*
|
||||
* @param newConfig 新配置
|
||||
* @param oldJson 旧配置 JSON
|
||||
* @return 合并后的配置 JSON
|
||||
*/
|
||||
private String mergeConfig(Map<String, Object> newConfig, String oldJson) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
if (oldJson != null && !oldJson.isBlank()) {
|
||||
merged.putAll(parseConfig(oldJson));
|
||||
}
|
||||
if (newConfig != null) {
|
||||
for (Map.Entry<String, Object> entry : newConfig.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 密码留空表示保留原值
|
||||
if ("password".equals(key) && (value == null || String.valueOf(value).isBlank())) {
|
||||
continue;
|
||||
}
|
||||
merged.put(key, value);
|
||||
}
|
||||
}
|
||||
return writeConfig(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置 JSON。
|
||||
*
|
||||
* @param json 配置 JSON
|
||||
* @return 配置映射,解析失败或为空时返回空映射
|
||||
*/
|
||||
private Map<String, Object> parseConfig(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析渠道配置失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化配置映射为 JSON。
|
||||
*
|
||||
* @param config 配置映射
|
||||
* @return 配置 JSON
|
||||
*/
|
||||
private String writeConfig(Map<String, Object> config) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(config);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("保存渠道配置失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.NotificationConfigRequest;
|
||||
import cn.rosercode.finance.entity.NotificationConfig;
|
||||
import cn.rosercode.finance.enums.NotificationFrequency;
|
||||
import cn.rosercode.finance.mapper.NotificationConfigMapper;
|
||||
import cn.rosercode.finance.service.NotificationConfigService;
|
||||
import cn.rosercode.finance.vo.NotificationConfigVO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 通知配置服务实现类,处理用户级账单导入提醒周期的读写。
|
||||
*
|
||||
* @date 2026-09-18 12:38:01
|
||||
*/
|
||||
@Service
|
||||
public class NotificationConfigServiceImpl implements NotificationConfigService {
|
||||
|
||||
/** 默认提醒周期:每月 */
|
||||
private static final String DEFAULT_FREQUENCY = NotificationFrequency.MONTHLY.name();
|
||||
|
||||
/** 默认每月提醒日 */
|
||||
private static final int DEFAULT_DAY_OF_MONTH = 1;
|
||||
|
||||
/** 默认每周提醒日(周一) */
|
||||
private static final int DEFAULT_DAY_OF_WEEK = 1;
|
||||
|
||||
/** 默认提醒时间 */
|
||||
private static final String DEFAULT_SEND_TIME = "09:00";
|
||||
|
||||
/** 时间格式 */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
private final NotificationConfigMapper notificationConfigMapper;
|
||||
|
||||
public NotificationConfigServiceImpl(NotificationConfigMapper notificationConfigMapper) {
|
||||
this.notificationConfigMapper = notificationConfigMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 未配置时返回默认值:每月 1 日 09:00 提醒,关闭状态。
|
||||
*/
|
||||
@Override
|
||||
public NotificationConfigVO getConfig(Long userId) {
|
||||
NotificationConfig config = getRawConfig(userId);
|
||||
NotificationConfigVO vo = new NotificationConfigVO();
|
||||
if (config == null) {
|
||||
vo.setEnabled(false);
|
||||
vo.setFrequency(DEFAULT_FREQUENCY);
|
||||
vo.setDayOfMonth(DEFAULT_DAY_OF_MONTH);
|
||||
vo.setDayOfWeek(DEFAULT_DAY_OF_WEEK);
|
||||
vo.setSendTime(DEFAULT_SEND_TIME);
|
||||
return vo;
|
||||
}
|
||||
vo.setEnabled(Boolean.TRUE.equals(config.getEnabled()));
|
||||
vo.setFrequency(config.getFrequency());
|
||||
vo.setDayOfMonth(config.getDayOfMonth());
|
||||
vo.setDayOfWeek(config.getDayOfWeek());
|
||||
vo.setSendTime(config.getSendTime());
|
||||
vo.setLastSentAt(config.getLastSentAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验周期、日期与时间;从关闭切到启用时重置最近发送时间,避免保存后立即补发。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveConfig(Long userId, NotificationConfigRequest request) {
|
||||
NotificationConfig config = getRawConfig(userId);
|
||||
boolean wasEnabled = config != null && Boolean.TRUE.equals(config.getEnabled());
|
||||
if (config == null) {
|
||||
config = new NotificationConfig();
|
||||
config.setUserId(userId);
|
||||
}
|
||||
|
||||
boolean enabled = Boolean.TRUE.equals(request.getEnabled());
|
||||
config.setEnabled(enabled);
|
||||
config.setFrequency(parseFrequency(request.getFrequency()));
|
||||
config.setDayOfMonth(clamp(request.getDayOfMonth(), DEFAULT_DAY_OF_MONTH, 1, 28));
|
||||
config.setDayOfWeek(clamp(request.getDayOfWeek(), DEFAULT_DAY_OF_WEEK, 1, 7));
|
||||
config.setSendTime(parseTime(request.getSendTime()));
|
||||
|
||||
// 首次启用或重新开启时,将最近发送时间设为当前,避免立即触发历史周期
|
||||
if (enabled && (!wasEnabled || config.getLastSentAt() == null)) {
|
||||
config.setLastSentAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
if (config.getId() == null) {
|
||||
notificationConfigMapper.insert(config);
|
||||
} else {
|
||||
notificationConfigMapper.updateById(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public NotificationConfig getRawConfig(Long userId) {
|
||||
return notificationConfigMapper.selectOne(new LambdaQueryWrapper<NotificationConfig>()
|
||||
.eq(NotificationConfig::getUserId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验提醒周期。
|
||||
*
|
||||
* @param value 周期字符串
|
||||
* @return 合法的周期名称
|
||||
*/
|
||||
private String parseFrequency(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return DEFAULT_FREQUENCY;
|
||||
}
|
||||
try {
|
||||
return NotificationFrequency.valueOf(value.trim().toUpperCase()).name();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("不支持的提醒周期:" + value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验提醒时间。
|
||||
*
|
||||
* @param value 时间字符串(HH:mm)
|
||||
* @return 规范化后的时间字符串
|
||||
*/
|
||||
private String parseTime(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return DEFAULT_SEND_TIME;
|
||||
}
|
||||
try {
|
||||
return LocalTime.parse(value.trim(), TIME_FORMATTER).format(TIME_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new BusinessException("提醒时间格式不正确,应为 HH:mm");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内,为空时返回默认值。
|
||||
*
|
||||
* @param value 原值
|
||||
* @param defaultValue 默认值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @return 限制后的值
|
||||
*/
|
||||
private int clamp(Integer value, int defaultValue, int min, int max) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.entity.NotificationLog;
|
||||
import cn.rosercode.finance.mapper.NotificationLogMapper;
|
||||
import cn.rosercode.finance.notification.NotificationSenderFactory;
|
||||
import cn.rosercode.finance.service.NotificationChannelService;
|
||||
import cn.rosercode.finance.service.NotificationService;
|
||||
import cn.rosercode.finance.vo.NotificationLogVO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知发送服务实现类,负责消息推送编排与发送日志记录。
|
||||
*
|
||||
* @date 2026-09-18 12:38:20
|
||||
*/
|
||||
@Service
|
||||
public class NotificationServiceImpl implements NotificationService {
|
||||
|
||||
/** 日志正文最大长度 */
|
||||
private static final int MESSAGE_LIMIT = 500;
|
||||
|
||||
/** 查询日志的最大条数 */
|
||||
private static final int MAX_LOG_LIMIT = 100;
|
||||
|
||||
/** 测试消息标题 */
|
||||
private static final String TEST_TITLE = "通知渠道测试";
|
||||
|
||||
/** 测试消息正文 */
|
||||
private static final String TEST_CONTENT = "这是一条来自收支管理系统的测试消息,收到即表示该渠道配置成功。";
|
||||
|
||||
private final NotificationChannelService notificationChannelService;
|
||||
private final NotificationLogMapper notificationLogMapper;
|
||||
private final NotificationSenderFactory notificationSenderFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppConfigStore appConfigStore;
|
||||
|
||||
public NotificationServiceImpl(NotificationChannelService notificationChannelService,
|
||||
NotificationLogMapper notificationLogMapper,
|
||||
NotificationSenderFactory notificationSenderFactory,
|
||||
ObjectMapper objectMapper,
|
||||
AppConfigStore appConfigStore) {
|
||||
this.notificationChannelService = notificationChannelService;
|
||||
this.notificationLogMapper = notificationLogMapper;
|
||||
this.notificationSenderFactory = notificationSenderFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.appConfigStore = appConfigStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 发送失败时记录日志并抛出异常,便于前端直接提示原因。
|
||||
*/
|
||||
@Override
|
||||
public void sendTest(Long userId, Long channelId) {
|
||||
NotificationChannel channel = notificationChannelService.getRawChannel(userId, channelId);
|
||||
try {
|
||||
send(channel, TEST_TITLE, TEST_CONTENT);
|
||||
recordLog(userId, channel, true, "发送成功");
|
||||
} catch (RuntimeException e) {
|
||||
recordLog(userId, channel, false, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public int sendReminderNow(Long userId) {
|
||||
List<NotificationChannel> channels = notificationChannelService.listEnabledChannels(userId);
|
||||
if (channels.isEmpty()) {
|
||||
throw new BusinessException("请先添加并启用至少一个通知渠道");
|
||||
}
|
||||
String content = buildReminderContent();
|
||||
int success = 0;
|
||||
for (NotificationChannel channel : channels) {
|
||||
if (trySend(userId, channel, REMINDER_TITLE, content)) {
|
||||
success++;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 配置了通知链接地址时,在提醒正文后附加该完整链接,便于用户点击后直接上传账单;
|
||||
* 未配置时退化为原始文案。
|
||||
*/
|
||||
@Override
|
||||
public String buildReminderContent() {
|
||||
String linkUrl = appConfigStore.load().getNotificationUrl();
|
||||
if (linkUrl == null || linkUrl.isBlank()) {
|
||||
return REMINDER_CONTENT;
|
||||
}
|
||||
return REMINDER_CONTENT + "\n\n" + linkUrl.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 单个渠道失败不影响其余渠道,失败原因写入日志。
|
||||
*/
|
||||
@Override
|
||||
public void sendToChannels(Long userId, List<NotificationChannel> channels, String title, String content) {
|
||||
for (NotificationChannel channel : channels) {
|
||||
trySend(userId, channel, title, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<NotificationLogVO> listLogs(Long userId, int limit) {
|
||||
int safeLimit = Math.max(1, Math.min(MAX_LOG_LIMIT, limit));
|
||||
List<NotificationLog> logs = notificationLogMapper.selectList(new LambdaQueryWrapper<NotificationLog>()
|
||||
.eq(NotificationLog::getUserId, userId)
|
||||
.orderByDesc(NotificationLog::getCreatedAt)
|
||||
.orderByDesc(NotificationLog::getId)
|
||||
.last("LIMIT " + safeLimit));
|
||||
return logs.stream().map(this::toVO).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向单个渠道发送消息并记录日志,失败时返回 false。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channel 渠道
|
||||
* @param title 标题
|
||||
* @param content 正文
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
private boolean trySend(Long userId, NotificationChannel channel, String title, String content) {
|
||||
try {
|
||||
send(channel, title, content);
|
||||
recordLog(userId, channel, true, "发送成功");
|
||||
return true;
|
||||
} catch (RuntimeException e) {
|
||||
recordLog(userId, channel, false, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析渠道配置并调用对应的发送器。
|
||||
*
|
||||
* @param channel 渠道
|
||||
* @param title 标题
|
||||
* @param content 正文
|
||||
*/
|
||||
private void send(NotificationChannel channel, String title, String content) {
|
||||
Map<String, Object> config = parseConfig(channel.getConfig());
|
||||
notificationSenderFactory.get(channel.getType()).send(config, title, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一条发送日志。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param channel 渠道
|
||||
* @param success 是否成功
|
||||
* @param message 结果描述
|
||||
*/
|
||||
private void recordLog(Long userId, NotificationChannel channel, boolean success, String message) {
|
||||
NotificationLog log = new NotificationLog();
|
||||
log.setUserId(userId);
|
||||
log.setChannelId(channel.getId());
|
||||
log.setChannelType(channel.getType());
|
||||
log.setSuccess(success);
|
||||
log.setMessage(truncate(message));
|
||||
notificationLogMapper.insert(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析渠道配置 JSON。
|
||||
*
|
||||
* @param json 配置 JSON
|
||||
* @return 配置映射
|
||||
*/
|
||||
private Map<String, Object> parseConfig(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析渠道配置失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断过长的日志正文。
|
||||
*
|
||||
* @param message 原文
|
||||
* @return 截断后的字符串
|
||||
*/
|
||||
private String truncate(String message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
return message.length() <= MESSAGE_LIMIT ? message : message.substring(0, MESSAGE_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为日志视图对象。
|
||||
*
|
||||
* @param log 日志实体
|
||||
* @return 日志视图对象
|
||||
*/
|
||||
private NotificationLogVO toVO(NotificationLog log) {
|
||||
NotificationLogVO vo = new NotificationLogVO();
|
||||
vo.setId(log.getId());
|
||||
vo.setChannelId(log.getChannelId());
|
||||
vo.setChannelType(log.getChannelType());
|
||||
vo.setSuccess(Boolean.TRUE.equals(log.getSuccess()));
|
||||
vo.setMessage(log.getMessage());
|
||||
vo.setCreatedAt(log.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知渠道视图对象,用于向前端返回渠道信息(敏感字段已脱敏)。
|
||||
*
|
||||
* @date 2026-09-18 12:37:13
|
||||
*/
|
||||
@Data
|
||||
public class NotificationChannelVO {
|
||||
|
||||
/** 渠道ID */
|
||||
private Long id;
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 渠道配置(EMAIL 的 password 已移除) */
|
||||
private Map<String, Object> config;
|
||||
|
||||
/** 是否已配置密码/授权码(EMAIL 渠道使用) */
|
||||
private Boolean hasPassword;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通知配置视图对象,用于向前端返回账单导入提醒的周期设置。
|
||||
*
|
||||
* @date 2026-09-18 12:37:12
|
||||
*/
|
||||
@Data
|
||||
public class NotificationConfigVO {
|
||||
|
||||
/** 是否启用定时提醒 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
|
||||
private String frequency;
|
||||
|
||||
/** 每月提醒日(1-28) */
|
||||
private Integer dayOfMonth;
|
||||
|
||||
/** 每周提醒日(1-7,1 为周一) */
|
||||
private Integer dayOfWeek;
|
||||
|
||||
/** 提醒时间(HH:mm) */
|
||||
private String sendTime;
|
||||
|
||||
/** 最近一次发送时间 */
|
||||
private LocalDateTime lastSentAt;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通知日志视图对象,用于向前端展示历史发送结果。
|
||||
*
|
||||
* @date 2026-09-18 12:37:14
|
||||
*/
|
||||
@Data
|
||||
public class NotificationLogVO {
|
||||
|
||||
/** 日志ID */
|
||||
private Long id;
|
||||
|
||||
/** 渠道ID */
|
||||
private Long channelId;
|
||||
|
||||
/** 渠道类型 */
|
||||
private String channelType;
|
||||
|
||||
/** 是否成功 */
|
||||
private Boolean success;
|
||||
|
||||
/** 结果描述或失败原因 */
|
||||
private String message;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -96,3 +96,43 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||
);
|
||||
|
||||
-- 通知配置表:存储每个用户的账单导入提醒设置
|
||||
CREATE TABLE IF NOT EXISTS t_notification_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用定时提醒:1-启用,0-关闭',
|
||||
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY' COMMENT '提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月',
|
||||
day_of_month INT NOT NULL DEFAULT 1 COMMENT '每月提醒日(1-28),frequency=MONTHLY 时生效',
|
||||
day_of_week INT NOT NULL DEFAULT 1 COMMENT '每周提醒日(1-7,1为周一),frequency=WEEKLY 时生效',
|
||||
send_time VARCHAR(5) NOT NULL DEFAULT '09:00' COMMENT '提醒时间(HH:mm)',
|
||||
last_sent_at DATETIME COMMENT '最近一次发送时间,用于避免重复提醒',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE INDEX uk_notification_config_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
|
||||
);
|
||||
|
||||
-- 通知渠道表:存储用户配置的邮箱、Server 酱等推送渠道
|
||||
CREATE TABLE IF NOT EXISTS t_notification_channel (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '渠道ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱',
|
||||
name VARCHAR(50) COMMENT '渠道名称,便于用户识别',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-关闭',
|
||||
config TEXT COMMENT '渠道配置(JSON),如 webhook 地址、SMTP 账号等',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_notification_channel_user_id (user_id) COMMENT '用户ID索引'
|
||||
);
|
||||
|
||||
-- 通知发送日志表:记录每次提醒的发送结果,便于排查问题
|
||||
CREATE TABLE IF NOT EXISTS t_notification_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
channel_id BIGINT COMMENT '渠道ID,测试或渠道已删除时可能为空',
|
||||
channel_type VARCHAR(20) COMMENT '渠道类型',
|
||||
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
|
||||
message VARCHAR(500) COMMENT '结果描述或失败原因',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_notification_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||
);
|
||||
|
||||
@@ -123,3 +123,56 @@ WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE t_ai_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_notification_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY',
|
||||
day_of_month INTEGER NOT NULL DEFAULT 1,
|
||||
day_of_week INTEGER NOT NULL DEFAULT 1,
|
||||
send_time VARCHAR(5) NOT NULL DEFAULT '09:00',
|
||||
last_sent_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
|
||||
);;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_notification_config_user_id ON t_notification_config (user_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_notification_channel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(50),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
config TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
|
||||
);;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_user_id ON t_notification_channel (user_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_notification_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
channel_id INTEGER,
|
||||
channel_type VARCHAR(20),
|
||||
success INTEGER NOT NULL DEFAULT 1,
|
||||
message VARCHAR(500),
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
|
||||
);;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_log_user_created ON t_notification_log (user_id, created_at);;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_notification_config_updated_at
|
||||
AFTER UPDATE ON t_notification_config
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE t_notification_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_notification_channel_updated_at
|
||||
AFTER UPDATE ON t_notification_channel
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE t_notification_channel SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
Reference in New Issue
Block a user