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.ymlapplication.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,拥抱现代化开发模式
  • ✅ 配置外化,便于多环境部署

这些改进看似“不增加功能”,实则为后续快速迭代打下坚实基础。架构优化不是炫技,而是为了减少技术债,让系统更健壮、更易维护


原始标题:Fourth Round of Improvements to the Reddit App

« 上一篇: Baeldung周报第36期
» 下一篇: Baeldung周报第37期