1. 概述
本文将继续优化我们正在构建的简易 Reddit 应用,这是 Spring 构建 Reddit 应用公开案例研究 的延续。本轮重点不在于新增功能,而是对架构、安全性和用户体验进行系统性增强,属于典型的“重构+加固”阶段。
如果你正在维护一个逐渐复杂的 Spring Web 项目,这类优化非常值得参考,很多都是“踩坑”后才意识到的必要改进。
2. 管理后台的表格优化
为了让管理后台的表格体验与前端用户页面保持一致,我们引入 jQuery DataTables 插件,实现服务端分页、排序和动态加载。
2.1 服务层:支持分页查询用户列表
首先在服务层添加分页查询接口:
public List<User> getUsersList(int page, int size, String sortDir, String sort) {
PageRequest pageReq = new PageRequest(page, size, Sort.Direction.fromString(sortDir), sort);
return userRepository.findAll(pageReq).getContent();
}
public PagingInfo generatePagingInfo(int page, int size) {
return new PagingInfo(page, size, userRepository.count());
}
✅ PagingInfo
用于封装分页元数据(总条数、当前页等),通过响应头返回给前端。
2.2 引入 User DTO
⚠️ 之前接口直接返回 User
实体给前端,存在暴露敏感字段和循环引用风险。现在统一使用 DTO。
定义 UserDto
:
public class UserDto {
private Long id;
private String username;
private Set<Role> roles;
private long scheduledPostsCount;
}
✅ DTO 只暴露必要字段,更安全、更灵活。
2.3 控制器层:返回分页用户列表
实现分页接口,返回 DTO 列表:
@GetMapping("/admin/users")
public List<UserDto> getUsersList(
@RequestParam(value = "page", required = false, defaultValue = "0") int page,
@RequestParam(value = "size", required = false, defaultValue = "10") int size,
@RequestParam(value = "sortDir", required = false, defaultValue = "asc") String sortDir,
@RequestParam(value = "sort", required = false, defaultValue = "username") String sort,
HttpServletResponse response) {
response.addHeader("PAGING_INFO", userService.generatePagingInfo(page, size).toString());
List<User> users = userService.getUsersList(page, size, sortDir, sort);
return users.stream()
.map(this::convertUserEntityToDto)
.collect(Collectors.toList());
}
转换逻辑:
private UserDto convertUserEntityToDto(User user) {
UserDto dto = modelMapper.map(user, UserDto.class);
dto.setScheduledPostsCount(scheduledPostService.countScheduledPostsByUser(user));
return dto;
}
✅ 使用 ModelMapper
减少手动 set/get,提升开发效率。
2.4 前端:集成 DataTables
前端使用 DataTables 实现服务端分页:
<table>
<thead>
<tr>
<th>Username</th>
<th>Scheduled Posts Count</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
</table>
<script>
$(function(){
$('table').dataTable({
"processing": true,
"searching": false,
"columnDefs": [
{ "name": "username", "targets": 0 },
{ "name": "scheduledPostsCount", "targets": 1, "orderable": false },
{ "targets": 2, "data": "roles", "width":"20%", "orderable": false,
"render": function (data, type, full, meta) {
return extractRolesName(data);
}
},
{ "targets": 3, "data": "id", "render": function (data, type, full, meta) {
return '<a onclick="showEditModal('+data+',\'' +
extractRolesName(full.roles)+'\')">Modify User Roles</a>';
}}
],
"columns": [
{ "data": "username" },
{ "data": "scheduledPostsCount" }
],
"serverSide": true,
"ajax": function(data, callback, settings) {
$.get('/admin/users', {
size: data.length,
page: (data.start / data.length),
sortDir: data.order[0].dir,
sort: data.columns[data.order[0].column].name
}, function(res, textStatus, request) {
var pagingInfo = request.getResponseHeader('PAGING_INFO');
var total = pagingInfo.split(",")[0].split("=")[1];
callback({
recordsTotal: total,
recordsFiltered: total,
data: res
});
});
}
});
});
</script>
✅ serverSide: true
启用服务端处理,避免前端加载大量数据。
✅ 通过 PAGING_INFO
响应头获取总记录数,实现准确分页。
3. 用户禁用功能
为管理员提供禁用/启用用户的接口,增强系统管控能力。
3.1 User 实体添加 enabled 字段
private boolean enabled = true; // 默认启用
3.2 Security 认证中判断用户状态
在 UserPrincipal
中实现:
@Override
public boolean isEnabled() {
return user.isEnabled();
}
⚠️ Spring Security 会自动调用此方法,若返回 false
,即使密码正确也无法登录。
3.3 提供启用/禁用用户的接口
接口定义:
@PreAuthorize("hasRole('USER_WRITE_PRIVILEGE')")
@PutMapping("/users/{id}")
@ResponseStatus(HttpStatus.OK)
public void setUserEnabled(
@PathVariable("id") Long id,
@RequestParam(value = "enabled") boolean enabled) {
userService.setUserEnabled(id, enabled);
}
✅ 使用 @PreAuthorize
保证只有具备写权限的管理员才能调用。
服务层实现:
@Transactional
public void setUserEnabled(Long userId, boolean enabled) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
user.setEnabled(enabled);
userRepository.save(user);
}
✅ 加上 @Transactional
避免并发问题。
4. 处理会话超时
配置会话超时及跳转逻辑,提升用户体验。
4.1 设置会话超时时间
通过 HttpSessionListener
设置:
public class SessionListener implements HttpSessionListener {
@Override
public void sessionCreated(HttpSessionEvent event) {
event.getSession().setMaxInactiveInterval(5 * 60); // 5分钟
}
}
4.2 Spring Security 配置超时跳转
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 其他配置
.sessionManagement()
.invalidSessionUrl("/?invalidSession=true")
.sessionFixation().none();
}
}
✅ invalidSessionUrl
指定会话失效后跳转地址。
✅ sessionFixation().none()
禁用会话固定保护(根据业务权衡)。
⚠️ 5分钟超时较短,仅用于演示。生产环境建议设为15-30分钟。
5. 增强注册流程
完善用户注册的健壮性,包含邮箱确认、密码重置等关键安全功能。
5.1 邮箱确认机制
新用户注册后需点击邮件链接激活账户。
注册接口:
@PostMapping("/register")
public void register(
HttpServletRequest request,
@RequestParam("username") String username,
@RequestParam("email") String email,
@RequestParam("password") String password) {
String appUrl = "http://" + request.getServerName() + ":" +
request.getServerPort() + request.getContextPath();
userService.registerNewUser(username, email, password, appUrl);
}
服务层:创建未激活用户并发送确认邮件
@Override
@Transactional
public void registerNewUser(String username, String email, String password, String appUrl) {
// ... 校验逻辑
User user = new User(username, email, passwordEncoder.encode(password));
user.setEnabled(false); // 关键:未激活
userRepository.save(user);
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user, appUrl));
}
✅ 通过事件机制解耦,邮件发送由监听器处理。
确认链接处理:
@GetMapping("/user/registrationConfirm")
public String confirmRegistration(
Model model,
@RequestParam("token") String token) {
String result = userService.confirmRegistration(token);
if (result == null) {
return "redirect:/?msg=registration confirmed successfully";
}
model.addAttribute("msg", result);
return "submissionResponse";
}
服务层验证 Token:
@Transactional
public String confirmRegistration(String token) {
VerificationToken vt = tokenRepository.findByToken(token);
if (vt == null) return "Invalid Token";
if (isTokenExpired(vt)) return "Token Expired";
User user = vt.getUser();
user.setEnabled(true);
userRepository.save(user);
return null;
}
✅ Token 有过期时间(通常24小时),增强安全性。
5.2 触发密码重置
用户可请求重置密码,系统发送重置链接至邮箱。
@PostMapping("/users/passwordReset")
@ResponseStatus(HttpStatus.OK)
public void passwordReset(
HttpServletRequest request,
@RequestParam("email") String email) {
String appUrl = "http://" + request.getServerName() + ":" +
request.getServerPort() + request.getContextPath();
userService.resetPassword(email, appUrl);
}
服务层生成 Token 并发邮件:
@Transactional
public void resetPassword(String userEmail, String appUrl) {
User user = userRepository.findByEmail(userEmail);
if (user == null) throw new UserNotFoundException("User not found");
String token = UUID.randomUUID().toString();
PasswordResetToken prToken = new PasswordResetToken(token, user);
passwordResetTokenRepository.save(prToken);
SimpleMailMessage email = constructResetTokenEmail(appUrl, token, user);
mailSender.send(email);
}
✅ 使用独立的 PasswordResetToken
实体,与注册 Token 分离。
5.3 执行密码重置
用户点击邮件链接后进入重置页面。
@GetMapping("/users/resetPassword")
public String resetPassword(
Model model,
@RequestParam("id") long id,
@RequestParam("token") String token) {
String result = userService.checkPasswordResetToken(id, token);
if (result == null) {
return "updatePassword"; // 跳转到新密码输入页
}
model.addAttribute("msg", result);
return "submissionResponse";
}
服务层验证并自动登录:
@Transactional(readOnly = true)
public String checkPasswordResetToken(long userId, String token) {
PasswordResetToken prToken = passwordResetTokenRepository.findByToken(token);
if (prToken == null || prToken.getUser().getId() != userId) {
return "Invalid Token";
}
if (isTokenExpired(prToken)) {
return "Token Expired";
}
// 自动登录用户,允许其修改密码
UserPrincipal userPrincipal = new UserPrincipal(prToken.getUser());
Authentication auth = new UsernamePasswordAuthenticationToken(
userPrincipal, null, userPrincipal.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
return null;
}
✅ 修改密码前无需再次登录,提升体验。
5.4 修改当前密码
用户登录状态下修改密码,需验证旧密码。
@PostMapping("/users/changePassword")
@ResponseStatus(HttpStatus.OK)
public void changeUserPassword(
@RequestParam("password") String password,
@RequestParam("oldpassword") String oldPassword) {
User user = userService.getCurrentUser();
if (!userService.checkIfValidOldPassword(user, oldPassword)) {
throw new InvalidOldPasswordException("Invalid old password");
}
userService.changeUserPassword(user, password);
}
服务层密码校验与更新:
@Transactional
public void changeUserPassword(User user, String password) {
user.setPassword(passwordEncoder.encode(password));
userRepository.save(user);
}
✅ 必须验证旧密码,防止越权修改。
6. 项目升级为 Spring Boot
将传统 Spring MVC 项目升级为 Spring Boot,简化配置和部署。
6.1 修改 pom.xml
引入 spring-boot-starter-parent
和 Web 依赖:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version> <!-- 更新为较新版本 -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<!-- 其他依赖 -->
</dependencies>
6.2 创建启动类
@SpringBootApplication
public class Application {
@Bean
public SessionListener sessionListener() {
return new SessionListener();
}
@Bean
public RequestContextListener requestContextListener() {
return new RequestContextListener();
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
✅ @SpringBootApplication
自动启用组件扫描、自动配置等。
✅ 原监听器通过 @Bean
注册即可。
⚠️ 升级后访问地址从
http://localhost:8080/reddit-scheduler
变为http://localhost:8080
。
7. 外部化配置属性
利用 Spring Boot 的 @ConfigurationProperties
管理第三方服务配置。
7.1 定义 RedditProperties
@ConfigurationProperties(prefix = "reddit")
@Component
public class RedditProperties {
private String clientID;
private String clientSecret;
private String accessTokenUri;
private String userAuthorizationUri;
private String redirectUri;
// getters and setters
}
7.2 在配置中使用
@Configuration
@EnableConfigurationProperties(RedditProperties.class)
public class OAuth2Config {
@Autowired
private RedditProperties redditProperties;
@Bean
public OAuth2ProtectedResourceDetails reddit() {
AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails();
details.setClientId(redditProperties.getClientID());
details.setClientSecret(redditProperties.getClientSecret());
details.setAccessTokenUri(redditProperties.getAccessTokenUri());
details.setUserAuthorizationUri(redditProperties.getUserAuthorizationUri());
details.setPreEstablishedRedirectUri(redditProperties.getRedirectUri());
// ... 其他设置
return details;
}
}
✅ 类型安全,避免硬编码。
✅ 支持 application.yml
或 application.properties
配置:
reddit.clientID=abc123
reddit.clientSecret=xyz789
reddit.accessTokenUri=https://reddit.com/api/v1/access_token
reddit.userAuthorizationUri=https://reddit.com/api/v1/authorize
reddit.redirectUri=http://localhost:8080/login
8. 总结
本轮优化聚焦于 架构合理性、安全加固和运维便利性,属于项目成长过程中的关键一步:
- ✅ 后台表格使用服务端分页,提升大数据量下的性能
- ✅ 用户状态管理(启用/禁用)增强系统控制力
- ✅ 注册与密码重置流程完善,符合安全最佳实践
- ✅ 升级 Spring Boot,拥抱现代化开发模式
- ✅ 配置外化,便于多环境部署
这些改进看似“不增加功能”,实则为后续快速迭代打下坚实基础。架构优化不是炫技,而是为了减少技术债,让系统更健壮、更易维护。