diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java index fc343442..c8606f9a 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java @@ -1,8 +1,11 @@ package com.qqchen.deploy.backend.framework.security.util; +import com.qqchen.deploy.backend.framework.security.CustomUserDetails; +import com.qqchen.deploy.backend.framework.utils.RedisUtil; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; @@ -10,6 +13,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -25,6 +29,12 @@ public class JwtTokenUtil { @Value("${jwt.expiration}") private Long expiration; + @Resource + private RedisUtil redisUtil; + + // Redis Key前缀 + private static final String TOKEN_PREFIX = "auth:token:"; + private SecretKey key; private SecretKey getKey() { @@ -63,9 +73,23 @@ public class JwtTokenUtil { return expiration.before(new Date()); } + /** + * 生成Token并存储到Redis + * + * @param userDetails 用户信息 + * @return Token字符串 + */ public String generateToken(UserDetails userDetails) { Map claims = new HashMap<>(); - return doGenerateToken(claims, userDetails.getUsername()); + String token = doGenerateToken(claims, userDetails.getUsername()); + + // 存储到Redis(如果userDetails是CustomUserDetails,可以获取userId) + if (userDetails instanceof CustomUserDetails) { + Long userId = ((CustomUserDetails) userDetails).getUserId(); + storeToken(userId, token); + } + + return token; } private String doGenerateToken(Map claims, String subject) { @@ -78,13 +102,118 @@ public class JwtTokenUtil { .compact(); } + /** + * 验证Token(集成Redis验证) + * + * @param token Token字符串 + * @param userDetails 用户信息 + * @return true=有效, false=无效 + */ public Boolean validateToken(String token, UserDetails userDetails) { try { final String username = getUsernameFromToken(token); - return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + + // 1. 基础验证:用户名匹配 + Token未过期 + if (!username.equals(userDetails.getUsername()) || isTokenExpired(token)) { + return false; + } + + // 2. Redis验证:检查Token是否匹配(用于强制下线场景) + if (userDetails instanceof CustomUserDetails) { + Long userId = ((CustomUserDetails) userDetails).getUserId(); + + // 检查Redis中的Token是否匹配(如果Redis中没有Token,说明用户已被强制下线) + String storedToken = getStoredToken(userId); + if (storedToken == null) { + log.warn("Token不存在(可能已被强制下线): userId={}", userId); + return false; + } + + if (!storedToken.equals(token)) { + log.warn("Token不匹配: userId={}", userId); + return false; + } + } + + return true; } catch (Exception e) { log.error("Error validating token", e); return false; } } -} \ No newline at end of file + + // ============================== Redis Token 管理 ============================== + + /** + * 存储Token到Redis(包含登录时间) + */ + private void storeToken(Long userId, String token) { + String key = TOKEN_PREFIX + userId; + + // 存储Token + 登录时间 + Map tokenInfo = new HashMap<>(); + tokenInfo.put("token", token); + tokenInfo.put("loginTime", LocalDateTime.now().toString()); + + redisUtil.set(key, tokenInfo, expiration); + log.debug("Token已存储到Redis: userId={}, ttl={}秒", userId, expiration); + } + + /** + * 从Redis获取Token字符串 + */ + private String getStoredToken(Long userId) { + String key = TOKEN_PREFIX + userId; + Object value = redisUtil.get(key); + + if (value == null) return null; + + // 兼容旧格式(字符串)和新格式(Map) + if (value instanceof String) { + return (String) value; + } else if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map tokenInfo = (Map) value; + return (String) tokenInfo.get("token"); + } + + return null; + } + + /** + * 获取完整的Token信息(包含在线状态) + */ + @SuppressWarnings("unchecked") + public Map getTokenInfo(Long userId) { + String key = TOKEN_PREFIX + userId; + Object value = redisUtil.get(key); + + if (value instanceof Map) { + return (Map) value; + } + + return null; + } + + /** + * 删除Token(用于登出或强制下线) + * + * @param userId 用户ID + */ + public void removeToken(Long userId) { + String key = TOKEN_PREFIX + userId; + redisUtil.del(key); + log.info("Token已删除: userId={}", userId); + } + + /** + * 刷新Token过期时间 + * + * @param userId 用户ID + */ + public void refreshToken(Long userId) { + String key = TOKEN_PREFIX + userId; + redisUtil.expire(key, expiration); + log.debug("Token过期时间已刷新: userId={}, ttl={}秒", userId, expiration); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/api/OnlineUserController.java b/backend/src/main/java/com/qqchen/deploy/backend/system/api/OnlineUserController.java new file mode 100644 index 00000000..cdf3f659 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/api/OnlineUserController.java @@ -0,0 +1,63 @@ +package com.qqchen.deploy.backend.system.api; + +import com.qqchen.deploy.backend.framework.api.Response; +import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery; +import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO; +import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO; +import com.qqchen.deploy.backend.system.service.IOnlineUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.data.domain.Page; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 在线用户管理接口 + * + * @author qqchen + * @date 2025-11-20 + */ +@Tag(name = "在线用户管理") +@RestController +@RequestMapping("/api/v1/online") +public class OnlineUserController { + + @Resource + private IOnlineUserService onlineUserService; + + /** + * 获取在线用户列表(分页) + */ + @Operation(summary = "获取在线用户列表") + @GetMapping("/users") + @PreAuthorize("hasAuthority('system:online:view')") + public Response> getOnlineUsers(OnlineUserQuery query) { + Page result = onlineUserService.getOnlineUsers(query); + return Response.success(result); + } + + /** + * 获取在线用户统计 + */ + @Operation(summary = "获取在线统计信息") + @GetMapping("/statistics") + public Response getStatistics() { + OnlineStatisticsVO statistics = onlineUserService.getStatistics(); + return Response.success(statistics); + } + + /** + * 强制用户下线 + */ + @Operation(summary = "强制用户下线") + @DeleteMapping("/kick/{userId}") + @PreAuthorize("hasAuthority('system:online:kick')") + public Response kickUser( + @Parameter(description = "用户ID", required = true) @PathVariable Long userId + ) { + onlineUserService.kickUser(userId); + return Response.success(); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/model/query/OnlineUserQuery.java b/backend/src/main/java/com/qqchen/deploy/backend/system/model/query/OnlineUserQuery.java new file mode 100644 index 00000000..ac2ab4aa --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/model/query/OnlineUserQuery.java @@ -0,0 +1,21 @@ +package com.qqchen.deploy.backend.system.model.query; + +import com.qqchen.deploy.backend.framework.query.BaseQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 在线用户查询对象 + * + * @author qqchen + * @date 2025-11-20 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OnlineUserQuery extends BaseQuery { + + /** + * 搜索关键词(用户名/昵称) + */ + private String keyword; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/model/request/ResetPasswordRequest.java b/backend/src/main/java/com/qqchen/deploy/backend/system/model/request/ResetPasswordRequest.java new file mode 100644 index 00000000..7ae3a8a6 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/model/request/ResetPasswordRequest.java @@ -0,0 +1,11 @@ +package com.qqchen.deploy.backend.system.model.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "新密码不能为空") + private String newPassword; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineStatisticsVO.java b/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineStatisticsVO.java new file mode 100644 index 00000000..ea814b75 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineStatisticsVO.java @@ -0,0 +1,34 @@ +package com.qqchen.deploy.backend.system.model.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 在线用户统计视图对象 + * + * @author qqchen + * @date 2025-11-20 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OnlineStatisticsVO { + + /** + * 当前在线人数 + */ + private Integer totalOnline; + + /** + * 今日峰值 + */ + private Integer todayPeak; + + /** + * 平均在线时长(格式化字符串,如 "2小时30分钟") + */ + private String averageOnlineTime; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineUserVO.java b/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineUserVO.java new file mode 100644 index 00000000..0c768e0a --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/model/vo/OnlineUserVO.java @@ -0,0 +1,71 @@ +package com.qqchen.deploy.backend.system.model.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 在线用户视图对象 + * + * @author qqchen + * @date 2025-11-20 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OnlineUserVO { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 昵称 + */ + private String nickname; + + /** + * 部门名称 + */ + private String departmentName; + + /** + * 登录时间 + */ + private LocalDateTime loginTime; + + /** + * 最后活跃时间 + */ + private LocalDateTime lastActiveTime; + + /** + * 在线时长(秒) + */ + private Long onlineDuration; + + /** + * IP地址 + */ + private String ipAddress; + + /** + * 浏览器信息 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/service/IOnlineUserService.java b/backend/src/main/java/com/qqchen/deploy/backend/system/service/IOnlineUserService.java new file mode 100644 index 00000000..47f946c3 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/service/IOnlineUserService.java @@ -0,0 +1,46 @@ +package com.qqchen.deploy.backend.system.service; + +import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery; +import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO; +import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 在线用户服务接口 + * + * @author qqchen + * @date 2025-11-20 + */ +public interface IOnlineUserService { + + /** + * 获取在线用户列表(分页) + * + * @param query 查询参数 + * @return 在线用户分页列表 + */ + Page getOnlineUsers(OnlineUserQuery query); + + /** + * 获取在线用户列表(不分页) + * + * @return 所有在线用户 + */ + List getAllOnlineUsers(); + + /** + * 获取在线用户统计 + * + * @return 统计信息 + */ + OnlineStatisticsVO getStatistics(); + + /** + * 强制用户下线 + * + * @param userId 用户ID + */ + void kickUser(Long userId); +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java new file mode 100644 index 00000000..bf4a8c2e --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java @@ -0,0 +1,222 @@ +package com.qqchen.deploy.backend.system.service.impl; + +import com.qqchen.deploy.backend.framework.utils.RedisUtil; +import com.qqchen.deploy.backend.framework.security.util.JwtTokenUtil; +import com.qqchen.deploy.backend.system.entity.User; +import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery; +import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO; +import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO; +import com.qqchen.deploy.backend.system.repository.IUserRepository; +import com.qqchen.deploy.backend.system.service.IOnlineUserService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 在线用户服务实现 + * + * @author qqchen + * @date 2025-11-20 + */ +@Slf4j +@Service +public class OnlineUserServiceImpl implements IOnlineUserService { + + @Resource + private RedisUtil redisUtil; + + @Resource + private JwtTokenUtil jwtTokenUtil; + + @Resource + private IUserRepository userRepository; + + // Redis Key前缀(使用JwtTokenUtil的统一Key) + private static final String TOKEN_PREFIX = "auth:token:"; + private static final String PEAK_KEY = "online:peak:today"; + + @Override + public Page getOnlineUsers(OnlineUserQuery query) { + List allOnlineUsers = getAllOnlineUsers(); + + // 关键词过滤 + String keyword = query.getKeyword(); + if (keyword != null && !keyword.trim().isEmpty()) { + String lowerKeyword = keyword.toLowerCase(); + allOnlineUsers = allOnlineUsers.stream() + .filter(user -> + user.getUsername().toLowerCase().contains(lowerKeyword) || + (user.getNickname() != null && user.getNickname().toLowerCase().contains(lowerKeyword)) + ) + .collect(Collectors.toList()); + } + + // 排序:按登录时间倒序 + allOnlineUsers.sort(Comparator.comparing(OnlineUserVO::getLoginTime).reversed()); + + // 分页(使用BaseQuery的pageNum从1开始) + int pageNum = query.getPageNum() != null ? query.getPageNum() : 1; + int pageSize = query.getPageSize() != null ? query.getPageSize() : 10; + int start = (pageNum - 1) * pageSize; + int end = Math.min(start + pageSize, allOnlineUsers.size()); + List pageContent = start < allOnlineUsers.size() ? allOnlineUsers.subList(start, end) : Collections.emptyList(); + + // 创建PageRequest(注意:Spring Data的page从0开始) + PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize); + return new PageImpl<>(pageContent, pageRequest, allOnlineUsers.size()); + } + + @Override + public List getAllOnlineUsers() { + // 获取所有Token Key + Set keys = redisUtil.keys(TOKEN_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return Collections.emptyList(); + } + + List onlineUsers = new ArrayList<>(); + + for (String key : keys) { + try { + // 从Tok Key中提取userId + String userIdStr = key.replace(TOKEN_PREFIX, ""); + Long userId = Long.parseLong(userIdStr); + + // 获取Token信息(包含在线状态) + Map tokenInfo = jwtTokenUtil.getTokenInfo(userId); + if (tokenInfo == null) continue; + + String loginTimeStr = (String) tokenInfo.get("loginTime"); + + // 查询用户详细信息 + User user = userRepository.findById(userId).orElse(null); + if (user == null) continue; + + // 解析时间 + LocalDateTime loginTime = loginTimeStr != null ? LocalDateTime.parse(loginTimeStr) : LocalDateTime.now(); + + // 计算在线时长 + long onlineDuration = Duration.between(loginTime, LocalDateTime.now()).getSeconds(); + + OnlineUserVO vo = OnlineUserVO.builder() + .userId(userId) + .username(user.getUsername()) + .nickname(user.getNickname()) + .departmentName(user.getDepartment() != null ? user.getDepartment().getName() : null) + .loginTime(loginTime) + .lastActiveTime(loginTime) // 由于没有心跳,最后活跃时间=登录时间 + .onlineDuration(onlineDuration) + .build(); + + onlineUsers.add(vo); + } catch (Exception e) { + log.error("解析在线用户信息失败: key={}", key, e); + } + } + + return onlineUsers; + } + + @Override + public OnlineStatisticsVO getStatistics() { + int totalOnline = getAllOnlineUsers().size(); + + // 获取今日峰值 + Integer todayPeak = totalOnline; + Object peakObj = redisUtil.get(PEAK_KEY); + if (peakObj != null) { + todayPeak = Math.max(totalOnline, getIntValue(peakObj)); + } + + // 计算平均在线时长 + String averageOnlineTime = calculateAverageOnlineTime(); + + return OnlineStatisticsVO.builder() + .totalOnline(totalOnline) + .todayPeak(todayPeak) + .averageOnlineTime(averageOnlineTime) + .build(); + } + + @Override + public void kickUser(Long userId) { + // 删除Token(同时删除了在线信息) + jwtTokenUtil.removeToken(userId); + log.warn("用户已被强制下线: userId={}", userId); + } + + /** + * 更新今日峰值 + */ + private void updateTodayPeak() { + int currentOnline = getAllOnlineUsers().size(); + Object peakObj = redisUtil.get(PEAK_KEY); + int currentPeak = peakObj != null ? getIntValue(peakObj) : 0; + + if (currentOnline > currentPeak) { + // 更新峰值,保留到今天结束 + long secondsUntilMidnight = Duration.between( + LocalDateTime.now(), + LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0) + ).getSeconds(); + + redisUtil.set(PEAK_KEY, currentOnline, secondsUntilMidnight); + } + } + + /** + * 计算平均在线时长 + */ + private String calculateAverageOnlineTime() { + List users = getAllOnlineUsers(); + if (users.isEmpty()) { + return "0分钟"; + } + + long totalSeconds = users.stream() + .mapToLong(OnlineUserVO::getOnlineDuration) + .sum(); + + long avgSeconds = totalSeconds / users.size(); + + long hours = avgSeconds / 3600; + long minutes = (avgSeconds % 3600) / 60; + + if (hours > 0) { + return String.format("%d小时%d分钟", hours, minutes); + } else { + return String.format("%d分钟", minutes); + } + } + + /** + * 安全地获取Long值 + */ + private Long getLongValue(Object obj) { + if (obj == null) return null; + if (obj instanceof Long) return (Long) obj; + if (obj instanceof Integer) return ((Integer) obj).longValue(); + if (obj instanceof String) return Long.parseLong((String) obj); + return null; + } + + /** + * 安全地获取Integer值 + */ + private Integer getIntValue(Object obj) { + if (obj == null) return 0; + if (obj instanceof Integer) return (Integer) obj; + if (obj instanceof Long) return ((Long) obj).intValue(); + if (obj instanceof String) return Integer.parseInt((String) obj); + return 0; + } +} diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql index 31246a62..a70490fd 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql @@ -102,7 +102,9 @@ VALUES -- 部门管理 (5, '部门管理', '/system/departments', 'System/Department/List', 'ApartmentOutlined', 'system:department', 2, 1, 40, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE), -- 权限管理(隐藏菜单) -(6, '权限管理', '/system/permissions', 'System/Permission/List', 'SafetyOutlined', 'system:permission', 2, 1, 50, TRUE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE); +(6, '权限管理', '/system/permissions', 'System/Permission/List', 'SafetyOutlined', 'system:permission', 2, 1, 50, TRUE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE), +-- 在线用户管理 +(7, '在线用户', '/system/online', 'System/Online/List', 'UsersRound', 'system:online:view', 2, 1, 60, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE); -- ==================== 初始化角色数据 ==================== DELETE FROM sys_role WHERE id < 100; @@ -206,6 +208,10 @@ INSERT INTO sys_permission (id, create_time, menu_id, code, name, type, sort) VA (45, NOW(), 5, 'system:department:delete', '部门删除', 'FUNCTION', 5), (46, NOW(), 5, 'system:department:tree', '获取部门树', 'FUNCTION', 6), +-- 在线用户管理 (menu_id=7) +(51, NOW(), 7, 'system:online:view', '查看在线用户', 'FUNCTION', 1), +(52, NOW(), 7, 'system:online:kick', '强制下线', 'FUNCTION', 2), + -- 运维管理权限 -- 团队管理 (menu_id=201) (101, NOW(), 201, 'deploy:team:list', '团队查询', 'FUNCTION', 1), diff --git a/backend/src/test/java/com/qqchen/deploy/backend/notification/NotificationConfigJsonTest.java b/backend/src/test/java/com/qqchen/deploy/backend/notification/NotificationConfigJsonTest.java new file mode 100644 index 00000000..fd3d7b35 --- /dev/null +++ b/backend/src/test/java/com/qqchen/deploy/backend/notification/NotificationConfigJsonTest.java @@ -0,0 +1,80 @@ +package com.qqchen.deploy.backend.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qqchen.deploy.backend.notification.dto.NotificationChannelDTO; +import com.qqchen.deploy.backend.notification.entity.config.EmailNotificationConfig; +import com.qqchen.deploy.backend.notification.entity.config.WeworkNotificationConfig; +import com.qqchen.deploy.backend.notification.enums.NotificationChannelTypeEnum; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 通知配置 JSON 序列化/反序列化测试 + * 验证重构后的 BaseNotificationConfig 多态机制是否正常 + */ +@SpringBootTest +public class NotificationConfigJsonTest { + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void testWeworkConfigSerialization() throws Exception { + NotificationChannelDTO dto = new NotificationChannelDTO(); + dto.setName("测试企微渠道"); + dto.setChannelType(NotificationChannelTypeEnum.WEWORK); + dto.setEnabled(true); + + WeworkNotificationConfig config = new WeworkNotificationConfig(); + config.setKey("test-webhook-key-12345"); + dto.setConfig(config); + + String json = objectMapper.writeValueAsString(dto); + System.out.println("Wework Serialization: " + json); + + assertTrue(json.contains("\"channelType\":\"WEWORK\"")); + assertTrue(json.contains("\"key\":\"test-webhook-key-12345\"")); + + NotificationChannelDTO deserialized = objectMapper.readValue(json, NotificationChannelDTO.class); + + assertNotNull(deserialized.getConfig()); + assertTrue(deserialized.getConfig() instanceof WeworkNotificationConfig); + assertEquals("test-webhook-key-12345", ((WeworkNotificationConfig) deserialized.getConfig()).getKey()); + } + + @Test + public void testEmailConfigSerialization() throws Exception { + NotificationChannelDTO dto = new NotificationChannelDTO(); + dto.setName("测试邮件渠道"); + dto.setChannelType(NotificationChannelTypeEnum.EMAIL); + dto.setEnabled(true); + + EmailNotificationConfig config = new EmailNotificationConfig(); + config.setSmtpHost("smtp.example.com"); + config.setSmtpPort(465); + config.setUsername("noreply@example.com"); + config.setPassword("password123"); + config.setFrom("noreply@example.com"); + config.setFromName("Deploy Platform"); + config.setUseSsl(true); + dto.setConfig(config); + + String json = objectMapper.writeValueAsString(dto); + System.out.println("Email Serialization: " + json); + + assertTrue(json.contains("\"channelType\":\"EMAIL\"")); + assertTrue(json.contains("\"smtpHost\":\"smtp.example.com\"")); + + NotificationChannelDTO deserialized = objectMapper.readValue(json, NotificationChannelDTO.class); + + assertNotNull(deserialized.getConfig()); + assertTrue(deserialized.getConfig() instanceof EmailNotificationConfig); + EmailNotificationConfig deserializedConfig = (EmailNotificationConfig) deserialized.getConfig(); + assertEquals("smtp.example.com", deserializedConfig.getSmtpHost()); + assertEquals(465, deserializedConfig.getSmtpPort()); + assertEquals("noreply@example.com", deserializedConfig.getUsername()); + } +}