十一、“仿小红书”单体全栈项目开发实战(五)

31 阅读29分钟

1.1 点赞模块功能概述

在原有的小红书项目基础上实现点赞功能,需要从数据库设计、后端API、前端交互三个层面进行改造。

下面是完整的实现方案:

  • 点赞/取消点赞
  • 获取笔记的点赞状态
  • 获取笔记的点赞数

通过以上实现,你可以在原有的小红书项目中完整实现点赞功能,包括点赞状态切换、点赞数统计和用户交互反馈。

1.2 点赞功能的数据库设计,掌握JPA关联映射

设计实体

首先需要添加点赞相关的实体和关系,新建点赞实体Like.java如下:

package com.waylau.rednote.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * Like 点赞实体
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/06/15
 **/
@Entity
@Table(name = "t_like")
@Data // @Data集合了 @ToString, @EqualsAndHashCode,所有字段的 @Getter和所有非final字段的 @Setter, @RequiredArgsConstructor
@NoArgsConstructor // 无参构造器
@AllArgsConstructor // 包含所有参数的构造器
public class Like {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long likeId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "note_id", nullable = false)
    private Note note;

    @Column(updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

}

在Note实体中添加反向关联:

@Entity
@Table(name = "t_note")
@Data // @Data集合了 @ToString, @EqualsAndHashCode,所有字段的 @Getter和所有非final字段的 @Setter, @RequiredArgsConstructor
@NoArgsConstructor // 无参构造器
@AllArgsConstructor // 包含所有参数的构造器
public class Note {

    // ...为节约篇幅,此处省略非核心内容
    
    @OneToMany(mappedBy = "note", cascade = CascadeType.REMOVE)
    private List<Like> likes = new ArrayList<>();

    // 计算点赞数的Transient字段
    @Transient
    public long getLikeCount() {
        return likes.size();
    }
    // 判断当前用户是否已点赞
    @Transient
    public boolean isLikedByUser(Long userId) {
        if (userId == null) {
            return false;
        }
        return likes.stream().anyMatch(like -> like.getUser().getUserId().equals(userId));
    }
}

JPA 关联映射解析:@OneToMany(mappedBy = "note", cascade = CascadeType.REMOVE)

这句代码是 JPA(Java Persistence API)中定义一对多关联关系的核心注解,主要用于建立实体间的双向关联。让我们从多个维度深入解析其含义和作用。

@OneToMany(mappedBy = "note", cascade = CascadeType.REMOVE) 这行代码的核心作用是:

  1. 建立双向关联:让NoteLike实体能够互相引用
  2. 定义级联行为:删除笔记时自动清理相关点赞记录
  3. 优化数据模型:避免数据库中冗余的关联字段

基本概念如下:

1. @OneToMany 注解

  • 语义:表示一个Note(笔记)实体可以关联多个Like(点赞)实体
  • 关系方向:定义在“一”方(Note),映射到“多”方(Like)
  • 默认FetchTypeLAZY(延迟加载),即访问note.getLikes()时才查询数据库

2. mappedBy 属性

  • 作用:指定双向关联的反向端字段
  • 原理:关联关系的控制权在Like实体的note字段上,Note实体仅作为反向映射
  • 避免冗余:防止 JPA 在数据库中生成两个关联字段(如note_idlike_note_id

3. cascade = CascadeType.REMOVE

  • 级联操作:当删除Note时,自动删除所有关联的Like记录
  • 避免孤儿数据:防止删除笔记后,点赞记录仍然存在于数据库中
  • 其他可选值
    • ALL:所有操作都级联(PERSIST、MERGE、REMOVE 等)
    • PERSIST:级联持久化(保存父实体时自动保存子实体)
    • MERGE:级联合并(更新父实体时自动更新子实体)

常见问题与最佳实践

1. 双向关联维护

  • 必须同时设置双方引用
    // 正确方式
    Note note = new Note();
    Like like = new Like();
    
    note.getLikes().add(like); // 设置正向关联
    like.setNote(note);        // 设置反向关联
    

2. 避免循环引用

  • JSON序列化问题:双向关联可能导致无限递归
  • 解决方案
    • 使用@JsonIgnore@JsonBackReference注解
    • 定义 DTO 层,选择性序列化需要的字段

3. 替代关联方式

  • 单向关联:如果只需要从Like访问Note,可以省略Note.likes字段
  • 多对多关联:如果需要更灵活的关系管理(如用户与笔记的收藏关系),可以使用@ManyToMany

与其他关联注解对比

注解关系类型控制权典型场景
@OneToMany一对多在多的一端笔记与点赞、订单与订单项
@ManyToOne多对一在多的一端评论与用户、订单与客户
@OneToOne一对一通常在主实体用户与用户详情、订单与支付记录
@ManyToMany多对多通过中间表用户与角色、学生与课程

在实际应用中,需要根据业务场景权衡级联操作的范围,处理好双向关联的维护,并注意可能出现的性能问题。通过合理使用 JPA 关联注解,可以构建出高效、健壮的实体关系模型。

@Transient字段的用意

在Java开发中,@Transient字段是一个重要的注解,主要用于ORM(对象关系映射)框架(如Hibernate、MyBatis等)中,指示该字段不需要映射到数据库表中。下面从多个方面详细解析其用意和应用场景:

核心作用:控制字段与数据库的映射关系

@Transient的核心功能是阻止字段被映射到数据库表,即:

  • 该字段不会在数据库表中生成对应的列;
  • 数据库查询结果也不会填充该字段的值。

具体应用场景

  1. 临时计算字段(非持久化数据)
  • 字段值由其他字段计算或拼接而来,无需存储到数据库。
  • 例:用户的全名(fullName)由firstNamelastName组合而成。
  1. 缓存或临时状态字段
  • 用于存储对象在运行时的临时状态(如缓存数据、权限标记等),无需持久化。
@Transient
private boolean isAdmin; // 运行时根据权限判断,无需存入数据库
  1. 避免敏感数据存储
  • 防止敏感信息(如密码明文、临时令牌)被误存入数据库。
@Transient
private String temporaryToken; // 临时令牌,不存入数据库
  1. 优化性能(减少数据库列)
  • 当对象包含大量无需持久化的字段时,使用@Transient可减少数据库表的列数,提升查询性能。

总结

@Transient的核心价值在于分离业务对象与数据库表的映射关系,使实体类能更灵活地处理临时数据、计算字段或敏感信息,同时优化数据库设计和系统性能。在使用时需根据具体框架选择合适的注解,并注意字段的生命周期和序列化问题。

1.3 实现LikeRepository处理点赞数据的存储

package com.waylau.rednote.repository;

import com.waylau.rednote.entity.Like;
import org.springframework.data.repository.Repository;

import java.util.Optional;

/**
 * LikeRepository 点赞资源库
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/21
 **/
public interface LikeRepository extends Repository<Like, Long> {

    Like save(Like like);

    void delete(Like like);

    Optional<Like> findByUserUserIdAndNoteNoteId(Long userId, Long noteId);

    long countByNoteNoteId(Long noteId);
}

1.4 点赞服务的核心设计要领

点赞服务接口

package com.waylau.rednote.service;

import com.waylau.rednote.entity.User;

/**
 * LikeService 点赞服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/21
 **/
public interface LikeService {
    /**
     * 点赞\取消点赞
     *
     * @param noteId
     * @param user
     * @return
     */
    boolean toggleLike(Long noteId, User user);

    /**
     * 获取笔记的点赞数
     *
     * @param noteId
     * @return
     */
    long getLikeCount(Long noteId);
}

点赞服务实现

package com.waylau.rednote.service.impl;

import com.waylau.rednote.entity.Like;
import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.exception.NoteNotFoundException;
import com.waylau.rednote.repository.LikeRepository;
import com.waylau.rednote.repository.NoteRepository;
import com.waylau.rednote.service.LikeService;
import com.waylau.rednote.service.NoteService;
import com.waylau.rednote.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Optional;

/**
 * LikeServiceImpl 点赞服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/21
 **/
@Service
public class LikeServiceImpl implements LikeService {

    @Autowired
    private NoteService noteService;

    @Autowired
    private LikeRepository likeRepository;

    @Override
    public boolean toggleLike(Long noteId, User user) {
        // 判断笔记是否存在
        Optional<Note> optionalNote = noteService.findNoteById(noteId);
        if (!optionalNote.isPresent()) {
            throw new NoteNotFoundException("");
        }

        // 查询用户是否已点赞
        Optional<Like> optionalLike = likeRepository.findByUserUserIdAndNoteNoteId(user.getUserId(), noteId);
        if (optionalLike.isPresent()) {
            // 已点赞,取消点赞
            likeRepository.delete(optionalLike.get());
            return false;
        } else {
            // 未点赞,添加点赞
            Like like = new Like();
            like.setUser(user);
            like.setNote(optionalNote.get());

            likeRepository.save(like);
            return true;
        }
    }

    @Override
    public long getLikeCount(Long noteId) {
        return likeRepository.countByNoteNoteId(noteId);
    }
}

1.5 Spring MVC控制器来处理点赞请求及安全配置要点

点赞控制器层

package com.waylau.rednote.controller;

import com.waylau.rednote.dto.LikeResponseDto;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.service.LikeService;
import com.waylau.rednote.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * LikeController 点赞控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/21
 **/
@Controller
@RequestMapping("/like")
public class LikeController {
    @Autowired
    private LikeService likeService;

    @Autowired
    private UserService userService;

    /**
     * 处理点赞、取消点赞请求
     *
     * @param noteId
     * @return
     */
    @PostMapping("/{noteId}")
    public ResponseEntity<LikeResponseDto> toggleLike(@PathVariable Long noteId) {
        User currentUser = userService.getCurrentUser();

        boolean isLiked = likeService.toggleLike(noteId, currentUser);
        long likeCount = likeService.getLikeCount(noteId);

        return ResponseEntity.ok(new LikeResponseDto(isLiked, likeCount));
    }
}

DTO

package com.waylau.rednote.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

/**
 * LikeResponseDto 点赞响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/21
 **/
@Getter
@Setter
@AllArgsConstructor
public class LikeResponseDto {
    /**
     * 是否点赞
     */
    private boolean isLiked;

    /**
     * 点赞数量
     */
    private long likeCount;
}

修改NoteExploreDto:

/**
    * 是否(被当前用户)点赞
    */
private boolean isLiked;

/**
    * 点赞量
    */
private long likeCount;

/*public static NoteExploreDto toExploreDto(Note note) {
    NoteExploreDto dto = new NoteExploreDto();
    dto.noteId = note.getNoteId();
    dto.title = note.getTitle();
    dto.cover = note.getImages().get(0);
    dto.username = note.getAuthor().getUsername();
    dto.avatar = note.getAuthor().getAvatar();
    dto.userId = note.getAuthor().getUserId();

    return dto;
}*/

public static NoteExploreDto toExploreDto(Note note, User user) {
    NoteExploreDto dto = new NoteExploreDto();
    dto.noteId = note.getNoteId();
    dto.title = note.getTitle();
    dto.cover = note.getImages().get(0);
    dto.username = note.getAuthor().getUsername();
    dto.avatar = note.getAuthor().getAvatar();
    dto.userId = note.getAuthor().getUserId();
    dto.likeCount = note.getLikeCount();
    dto.isLiked = note.isLikedByUser(user.getUserId());

    return dto;
}

其中,toExploreDto() 方法增加了User对象。

修改ExploreController

@Autowired
private UserService userService;

@GetMapping("/note")
public ResponseEntity<NoteResponseDto> getNotesByCategory(@RequestParam(defaultValue = "1") int page,
                                                              @RequestParam(required = false) String category,
                                                              @RequestParam(required = false) String query) {
        // 注意:把分类“推荐”当成null
        if (DEFAULT_CATEGORY.equals(category)) {
            category = null;
        }

        // 分页查询笔记
        Page<Note> notes = null;
        // 判定query是否为空
        if (query != null && query.trim().length() > 0) {
            notes = noteService.getNotesByPageAndQuery(page, PAGE_SIZE, category, query);
        } else {
            notes = noteService.getNotesByPage(page, PAGE_SIZE, category);
        }

        NoteResponseDto noteResponseDto = new NoteResponseDto();
        /*noteResponseDto.setNotes(notes.getContent());*/
        noteResponseDto.setHasMore(notes.hasNext());

        User user = userService.getCurrentUser();

        // 处理序列化问题
        List<NoteExploreDto> noteExploreDtoLst = new ArrayList<>();
        for (Note note : notes.getContent()) {
            /*noteExploreDtoLst.add(NoteExploreDto.toExploreDto(note));*/
            noteExploreDtoLst.add(NoteExploreDto.toExploreDto(note, user));
        }
        noteResponseDto.setNotes(noteExploreDtoLst);

        return ResponseEntity.ok(noteResponseDto);
    }
}

获取当前用户,并赋值给NoteExploreDto.toExploreDto() 方法。

安全配置

  1. 在 Spring Security 配置类中,进一步细化点赞API的访问权限
  2. 确保只有普通用户角色可以访问点赞API

修改WebSecurityConfig如下:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // ...为节约篇幅,此处省略非核心内容    
            .authorizeHttpRequests(authorize -> authorize
                    // ...为节约篇幅,此处省略非核心内容

                    // 允许USER角色的用户访问 /like/** 的资源
                    .requestMatchers("/like/**").hasRole("USER")
                    // 其他请求需要认证
                    .anyRequest().authenticated()
            )

 
    ;

    return http.build();
}            

修改explore.html设置并获取 CSRF 令牌:

<!-- 确保有一个meta标签来存储CSRF令牌 -->
<meta name="_csrf" th:content="${_csrf.token}"></meta>

1.6 掌握无刷新更新点赞前端设计的核心要点,精通data属性用法

前端实现

修改explore.html中内容。

1. 添加点赞按钮样式

/* 点赞按钮样式 */
.liked {
    color: #ff2442;
}

.like-btn {
    cursor: pointer;
}

2. 添加点赞状态显示及按钮事件

在笔记卡片中添加点赞按钮:

// 创建笔记卡片元素
function createNoteElement(note) {
    // 判定笔记是否点赞,来设置点赞图标的样式
    let likeIconClass = note.liked ? "fa fa-heart like-btn liked" : "fa fa-heart-o like-btn";

    const noteElement = document.createElement("div");
    noteElement.className = "masonry-item";
    noteElement.innerHTML = `
        <!--<div class="note-image-container">-->
            <!-- 点击跳转到笔记详情页 -->
            <a href="/note/${note.noteId}">
                <!--<img class="note-image" src="${note.cover}" alt="${note.title}">-->
                <img class="masonry-note-image" src="${note.cover}" alt="${note.title}">
            </a>
        <!--</div>-->
        <div class="note-content">
            <div class="note-title">${note.title}</div>
            <div class="note-author-stats">
                <!-- 点击跳转到用户详情页 -->
                <a href="/user/profile/${note.userId}">
                    <div class="note-author">
                        <img class="author-avatar" src="${note.avatar ? note.avatar : '/images/rn_avatar.png'}" alt="${note.username}">
                        <span class="author-name">${note.username}</span>
                    </div>
                </a>

                <div class="note-stats">
                    <div class="stat-item">
                        <!--<i class="fa fa-heart-o">${numberFormat(1024)}</i>-->
                        <i class="${likeIconClass}" data-node-id="${note.noteId}"
                            onclick="handleLike(this)">${numberFormat(note.likeCount)}</i>
                    </div>
                </div>
            </div>
        </div>
    `;

    return noteElement;
}

点赞按钮的样式变量className,其值是根据是否点赞而动态设置。

3. 实现点赞交互

// 点赞按钮的点击事件处理函数
function handleLike(element) {
    // 从data-*获取笔记ID
    const noteId = element.dataset.nodeId;

    // 禁用按钮放置重复点击
    element.disabled = true;

    // 发送请求
    fetch(`/like/${noteId}`, {
        method: 'POST',
        // 添加请求头, 用于Spring Security CSRF
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
        }
    })
    .then(response => response.json())
    .then(data => {
        if (data.liked) {
            // 设置按钮为点赞样式
            element.classList.remove('fa-heart-o');
            element.classList.add('fa-heart');
            element.classList.add('liked');
        } else {
            // 恢复按钮为未点赞样式
            element.classList.remove('fa-heart');
            element.classList.remove('liked');
            element.classList.add('fa-heart-o');
        }

        // 点赞量显示处理
        element.textContent = numberFormat(data.likeCount);
    })
    .catch(error => {
        console.error('Error:', error);
        alert('点赞失败,请稍后再试');
    });

    // 启用按钮
    element.disabled = false;
}

Thymeleaf 中的 th:data-* 属性详解

th:data-note-id 是 Thymeleaf 模板引擎中的数据属性绑定语法,用于将后端数据注入到 HTML 元素的 data-* 属性中。这类属性主要用于存储页面的自定义数据,便于 JavaScript 读取和操作。

1. 基础语法

  • th:data-* 是 Thymeleaf 的标准属性处理器
  • * 部分会被转换为 HTML 中的 data-* 属性
  • 例如:th:data-note-id="${note.id}"<div data-note-id="123">

2. 核心作用

  • 数据传递:将服务器端数据(如 Java 对象的属性)传递到前端
  • DOM 与数据解耦:避免直接在 JavaScript 中硬编码数据
  • 增强交互性:为前端事件处理提供必要的上下文信息

3. 其他 Thymeleaf 属性的对比

Thymeleaf 属性作用应用场景
th:text设置元素的文本内容显示标题、描述等文本信息
th:value设置表单元素的值填充输入框、下拉框初始值
th:attr通用属性设置设置非标准属性(如 aria-*
th:data-*设置 HTML5 的 data-* 自定义数据属性为 JavaScript 提供数据上下文

4. JavaScript 中获取 data-* 属性的方法

  1. 标准方式(dataset 属性)
const element = document.querySelector('.like-btn');
const noteId = element.dataset.noteId; // 推荐方式
  1. 传统方式(getAttribute)
const noteId = element.getAttribute('data-note-id');
  1. 批量获取所有 data- 属性*
const allData = element.dataset; // 返回 DOMStringMap 对象
// 例如 data-note-id="123" 会变成 allData.noteId === "123"

5. 总结

th:data-note-id 是 Thymeleaf 中用于将后端数据注入到 HTML 元素的 data-note-id 属性的语法。其核心价值在于:

  1. 数据传递:实现服务器端数据与前端 DOM 的绑定
  2. 事件驱动:为 JavaScript 事件处理提供必要的上下文
  3. 解耦设计:避免在 JavaScript 中硬编码数据 ID,提高代码可维护性

在实际项目中,合理使用 data-* 属性可以简化前端与后端的数据交互流程,特别是在传统的服务端渲染项目中尤为实用。

运行调测

在首页查看笔记未点赞时效果,如下图13-1所示。

图13-1 笔记未点赞时效果

在首页查看笔记未点赞时效果,如下图13-2所示。

图13-2 笔记点赞时效果

1.7 笔记详情页的点赞处理

修改note-detail.html中内容。

添加点赞按钮样式

/* 点赞按钮样式 */
.liked {
    color: #ff2442;
}

点赞按钮设置属性及点击

点赞按钮设置属性:

<!-- 点赞 -->
<button class="btn btn-light btn-sm" >
    <i th:class="${note.isLikedByUser(#authentication.principal?.userId)} ? 'fa fa-heart liked' : 'fa fa-heart-o'"
        th:onclick="handleLike(this)"
        th:data-note-id="${note.noteId}">[[${note.likeCount}]]</i>
</button>

在按钮上设置数据属性data-note-id。点赞按钮的样式class,其值是根据是否点赞而动态设置。

设置点击事件handleLike。

#authentication.principal?.userId是为了在authentication.principal上获取userId,具体的实现方式会在后续课程介绍。

实现点赞交互

发送点赞请求,并根据响应结果更新UI显示。

// 点赞按钮的点击事件处理
function handleLike(element) {
    // 从data属性里面获取笔记ID
    const noteId = element.dataset.noteId;

    // 禁用按钮放置重复点击
    element.disabled = true;

    // 发送请求
    fetch(`/like/${noteId}`, {
        method: 'POST',
        // 添加请求头, 用于Spring Security CSRF
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
        }
    })
    .then(response => response.json())
    .then(data => {
        if (data.liked) {
            // 设置按钮为点赞样式
            element.classList.remove('fa-heart-o');
            element.classList.add('fa-heart');
            element.classList.add('liked');
        } else {
            // 恢复按钮为未点赞样式
            element.classList.remove('fa-heart');
            element.classList.remove('liked');
            element.classList.add('fa-heart-o');
        }

        // 点赞量显示处理
        element.textContent = numberFormat(data.likeCount);
    })
    .catch(error => {
        console.error('Error:', error);
        alert('点赞失败,请稍后再试');
    });

    // 启用按钮
    element.disabled = false;
}

// 数字格式化,自动转换为k、w单位
function numberFormat(num) {
    if (num > 100000) {
        return (num / 10000).toFixed(1) + 'w';
    } else if (num > 1000) {
        return (num / 1000).toFixed(1) + 'k';
    } else {
        return num;
    }
};

1.8 自定义UserDetails破解在界面获取不到用户ID的难题

此时如何运行应用,前端解析note.isLikedByUser(#authentication.principal?.userId报错,报错信息是“Property or field 'id' cannot be found on object of type 'org.springframework.security.core.userdetails.User'”,那么要如何解决呢?

问题分析

这个错误是由于Spring Security返回的UserDetails对象结构与你的代码期望不匹配导致的。具体原因如下:

  1. 类型不匹配

    • #authentication.principal 返回的是org.springframework.security.core.userdetails.User对象
    • 这个对象默认只有usernamepasswordauthorities等属性,没有你期望的userId字段
  2. 解决方案

    • 需要自定义UserDetails实现类,包含用户ID字段
    • 或者通过用户名从数据库中查询完整用户信息

自定义 Spring Security UserDetails 实现方案

可以通过继承 org.springframework.security.core.userdetails.User 类来扩展自定义属性,这样既能保留 Spring Security 的默认行为,又能添加自己需要的字段(如用户ID)。以下是具体实现方案:

创建自定义 UserDetails 类

package com.waylau.rednote.config;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

/**
 * CustomUserDetails 自定义UserDetails
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/06/16
 **/
public class CustomUserDetails extends User {
    // 新增用户ID字段
    private final Long userId;

    public CustomUserDetails(Long userId,
                             String username,
                             String password,
                             Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);

        this.userId = userId;
    }

    public Long getUserId() {
        return userId;
    }

}

修改 UserDetailsService 实现

修改 UserDetailsService 实现,返回CustomUserDetails对象:

package com.waylau.rednote.config;

import com.waylau.rednote.common.ExceptionType;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.Optional;

/**
 * UserDetailsServiceImpl UserDetailsService实现
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户,判定用户是否存在
        Optional<User> optionalUser = userRepository.findByUsername(username);
        if (!optionalUser.isPresent()) {
            // 抛出用户不存在的异常
            throw new UsernameNotFoundException(ExceptionType.USERNAME_NOT_FOUND);
        }

        User user = optionalUser.get();

        /*
        // 将User转为UserDetails对象
        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .disabled(false)
                // 设置所有的数据库里面的用户都是USER角色
                .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                .build();
        */

        // 将User转为自定义的UserDetails对象
        return new CustomUserDetails(
                user.getUserId(),
                user.getUsername(),
                user.getPassword(),
                // 设置所有的数据库里面的用户都是USER角色
                AuthorityUtils.createAuthorityList("ROLE_USER"));
    }
}

关键点说明

  1. 继承而非替代

    • 继承 org.springframework.security.core.userdetails.User 保留了 Spring Security 的默认行为
    • 无需重写所有方法,只需添加需要的字段和 getter
  2. 构造函数传递

    • 确保在构造函数中调用父类构造函数并传递必要参数
    • 可以根据需要添加更多构造函数变体
  3. 安全上下文集成

    • Spring Security 会自动将自定义 UserDetails 放入安全上下文中
    • 在 Thymeleaf 中通过 #authentication.principal 访问时,可直接获取扩展属性

总结

通过继承或包装 Spring Security 的 User 类,你可以轻松添加自定义属性(如用户ID),并在 Thymeleaf 模板中直接访问。这种方法既保持了与 Spring Security 的兼容性,又满足了业务需求,是处理此类问题的标准做法。

1.9 最佳实践及优化建议

最佳实践

1. 数据类型转换

  • data-* 属性存储的是字符串类型
  • 如果需要数值类型,需手动转换:
    const noteId = parseInt(element.dataset.noteId, 10);
    

2. 安全性考虑

  • 不要在 data-* 属性中存储敏感数据(如密码、token)
  • 对用户输入进行转义处理,防止 XSS 攻击(Thymeleaf 默认会转义)

3. 性能优化

  • 避免在大型列表中为每个元素添加大量 data-* 属性
  • 复杂数据建议使用 JSON 序列化后存储:
    <div th:data-user="${#strings.replace(#jsession(user), '\'', '\\\'')}"></div>
    
    const user = JSON.parse(element.dataset.user);
    

优化建议

  1. 性能优化

    • 使用Redis缓存点赞数,定期同步到数据库
    • 实现点赞异步处理,提高响应速度
  2. 防刷机制

    • 添加点赞频率限制(如每分钟不超过10次)
    • 记录IP地址,防止恶意刷赞
  3. 用户体验

    • 添加点赞动画效果
    • 显示最近点赞的用户头像
  4. 数据统计

    • 添加点赞排行榜
    • 分析用户点赞行为,提供个性化推荐
  5. 级联操作的风险

  • 大规模删除:删除一个包含大量点赞的笔记可能导致性能问题
  • 替代方案
    • 手动控制删除顺序:先删除关联的Like,再删除Note
    • 使用数据库触发器处理级联操作

1.10 返回友好的错误信息给用户

1.11

2.1 模块功能概述

在原有项目基础上实现评论功能,需要从数据库设计、后端API、前端交互三个层面进行改造。下面是完整的实现功能:

  • 评论框
  • 提交评论
  • 展示评论列表
  • 删除评论
  • 回复评论窗口
  • 回复评论
  • 展示回复列表
  • 删除回复

2.2 评论功能的数据库设计

首先需要添加评论相关的实体和关系:

package com.waylau.rednote.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * Comment 评论实体
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
@Entity
@Table(name = "t_comment")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long commentId;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "note_id", nullable = false)
    private Note note;

    @Column(updatable = false)
    private LocalDateTime createAt = LocalDateTime.now();

    /**
     * 父级评论,用于回复功能
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Comment parent;

    /**
     * 子级评论,也就是回复
     */
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> replies = new ArrayList<>();
}

在Note实体中添加反向关联:

@Entity
@Table(name = "t_note")
@Data // @Data集合了 @ToString, @EqualsAndHashCode,所有字段的 @Getter和所有非final字段的 @Setter, @RequiredArgsConstructor
@NoArgsConstructor // 无参构造器
@AllArgsConstructor // 包含所有参数的构造器
public class Note {
    
    // ...为节约篇幅,此处省略非核心内容

    @OneToMany(mappedBy = "note", cascade = CascadeType.REMOVE, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();
    
    // 计算评论数的Transient字段
    @Transient
    public long getCommentCount() {
        return comments.size();
    }
}

2.3 实现评论CommentRepository用于保存、查询评论数据

package com.waylau.rednote.repository;

import com.waylau.rednote.entity.Comment;
import org.springframework.data.repository.Repository;

import java.util.List;
import java.util.Optional;

/**
 * CommentRepository 评论资源库
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
public interface CommentRepository extends Repository<Comment, Long> {
    Comment save(Comment comment);

    Optional<Comment> findByCommentId(Long commentId);

    void delete(Comment comment);

    /**
     * 查找根评论
     *
     * @param noteId
     * @return
     */
    List<Comment> findByParentIsNullAndNoteNoteIdOrderByCreateAtDesc(Long noteId);

    /**
     * 根据父评论ID获取它的子评论
     *
     * @param parentCommentId
     * @return
     */
    List<Comment> findByParentCommentId(Long parentCommentId);

}

2.4 掌握点评论服务设计的核心要点

1. 评论服务接口

package com.waylau.rednote.service;

import com.waylau.rednote.entity.Comment;
import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;

import java.util.List;
import java.util.Optional;

/**
 * CommentService 评论服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
public interface CommentService {
    /**
     * 创建评论
     *
     * @param note
     * @param user
     * @param content
     * @return
     */
    Comment createComment(Note note, User user, String content);

    /**
     * 删除评论
     *
     * @param comment
     */
    void deleteComment(Comment comment);

    /**
     * 根据评论ID获取评论
     *
     * @param commentId
     * @return
     */
    Optional<Comment> findCommentById(Long commentId);

    /**
     * 根据笔记ID获取笔记的根评论
     *
     * @return
     */
    List<Comment> getCommentsByNoteId(Long noteId);

    /**
     * 回复评论
     *
     * @param note
     * @param parentComment
     * @param user
     * @param content
     * @return
     */
    Comment replyToComment(Note note, Comment parentComment, User user, String content);
}

2. 评论服务实现

package com.waylau.rednote.service.impl;

import com.waylau.rednote.entity.Comment;
import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.repository.CommentRepository;
import com.waylau.rednote.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

/**
 * CommentServiceImpl 评论服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
@Service
public class CommentServiceImpl implements CommentService {
    @Autowired
    private CommentRepository commentRepository;

    @Override
    public Comment createComment(Note note, User user, String content) {
        Comment comment = new Comment();
        comment.setNote(note);
        comment.setUser(user);
        comment.setContent(content);

        return commentRepository.save(comment);
    }

    @Override
    public void deleteComment(Comment comment) {
        commentRepository.delete(comment);
    }

    @Override
    public Optional<Comment> findCommentById(Long commentId) {
        return commentRepository.findByCommentId(commentId);
    }

    @Override
    public List<Comment> getCommentsByNoteId(Long noteId) {
        return commentRepository.findByParentIsNullAndNoteNoteIdOrderByCreateAtDesc(noteId);
    }

    @Override
    public Comment replyToComment(Note note, Comment parentComment, User user, String content) {
        Comment reply = new Comment();
        reply.setNote(note);
        reply.setUser(user);
        reply.setContent(content);
        reply.setParent(parentComment);

        parentComment.getReplies().add(reply);

        return commentRepository.save(reply);
    }
}

2.5 创建处理评论相关请求的控制器

控制器层

package com.waylau.rednote.controller;

import com.waylau.rednote.dto.CommentResponseDto;
import com.waylau.rednote.entity.Comment;
import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.exception.CommentNotFoundException;
import com.waylau.rednote.exception.NoteNotFoundException;
import com.waylau.rednote.service.CommentService;
import com.waylau.rednote.service.NoteService;
import com.waylau.rednote.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * CommentController 评论控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
@Controller
@RequestMapping("/comment")
public class CommentController {
    @Autowired
    private NoteService noteService;

    @Autowired
    private UserService userService;

    @Autowired
    private CommentService commentService;

    // 处理创建评论的请求
    @PostMapping("/{noteId}")
    public ResponseEntity<CommentResponseDto> createComment(@PathVariable("noteId") Long noteId,
                                                            @RequestBody String content) {
        // 判定笔记是否存在
        Optional<Note> optionalNote = noteService.findNoteById(noteId);
        if (!optionalNote.isPresent()) {
            throw new NoteNotFoundException("");
        }

        Note note = optionalNote.get();
        User user = userService.getCurrentUser();

        Comment comment = commentService.createComment(note, user, content);

        // 将Comment对象转换成DTO对象
        CommentResponseDto commentResponseDto = CommentResponseDto.toCommentResponseDto(comment);

        return ResponseEntity.ok(commentResponseDto);
    }

    // 处理获取笔记评论列表的请求
    @GetMapping("/{noteId}")
    public ResponseEntity<List<CommentResponseDto>> getCommentsByNoteId(@PathVariable("noteId") Long noteId) {
        // 判定笔记是否存在
        Optional<Note> optionalNote = noteService.findNoteById(noteId);
        if (!optionalNote.isPresent()) {
            throw new NoteNotFoundException("");
        }

        List<Comment> comments = commentService.getCommentsByNoteId(noteId);

        // 将Comment对象转换成DTO对象
        List<CommentResponseDto> commentResponseDtoList = comments.stream().map(CommentResponseDto::toCommentResponseDto)
                .collect(Collectors.toUnmodifiableList());

        return ResponseEntity.ok(commentResponseDtoList);
    }

    // 处理创建回复的请求
    @PostMapping("/{noteId}/reply/{parentCommentId}")
    public ResponseEntity<CommentResponseDto> replyToComment(@PathVariable("noteId") Long noteId,
                                                             @PathVariable("parentCommentId") Long parentCommentId,
                                                             @RequestBody String content) {
        // 判定笔记是否存在
        Optional<Note> optionalNote = noteService.findNoteById(noteId);
        if (!optionalNote.isPresent()) {
            throw new NoteNotFoundException("");
        }

        // 判定父级评论是否存在
        Optional<Comment> optionalParentComment = commentService.findCommentById(parentCommentId);
        if (!optionalParentComment.isPresent()) {
            throw new CommentNotFoundException("");
        }

        // 判定父级评论是否属于该笔记
        Comment parentComment = optionalParentComment.get();
        if (!parentComment.getNote().getNoteId().equals(noteId)) {
            throw new CommentNotFoundException("评论与笔记不匹配");
        }

        Note note = optionalNote.get();
        User user = userService.getCurrentUser();
        Comment reply = commentService.replyToComment(note, parentComment, user, content);

        // 将Comment对象转换成DTO对象
        CommentResponseDto commentResponseDto = CommentResponseDto.toCommentResponseDto(reply);

        return ResponseEntity.ok(commentResponseDto);
    }

    // 处理删除评论(包含回复)的请求
    @DeleteMapping("/{commentId}")
    public ResponseEntity<Void> deleteComment(@PathVariable("commentId") Long commentId) {
        // 判定评论是否存在
        Optional<Comment> optionalComment = commentService.findCommentById(commentId);
        if (!optionalComment.isPresent()) {
            throw new CommentNotFoundException("");
        }

        // 判定评论是否是自己的
        Comment comment = optionalComment.get();
        User user = userService.getCurrentUser();
        if (!comment.getUser().getUserId().equals(user.getUserId())) {
            throw new CommentNotFoundException("无权删除他人的评论");
        }

        commentService.deleteComment(comment);

        return ResponseEntity.noContent().build();
    }
}

这里需要注意,不能直接返回实体Comment给前端,需要转为CommentResponseDto对象,否则报以下序列化错误:


com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: com.waylau.rednote.entity.Comment["note"]->com.waylau.rednote.entity.Note["author"]->com.waylau.rednote.entity.User$HibernateProxy["hibernateLazyInitializer"])
	at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1359) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:415) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:52) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:29) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:503) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:342) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ObjectWriter$Prefetch.serialize(ObjectWriter.java:1587) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ObjectWriter.writeValue(ObjectWriter.java:1061) ~[jackson-databind-2.19.0.jar:2.19.0]
	at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:485) ~[spring-web-6.2.7.jar:6.2.7]
	at org.springframework.http.converter.AbstractGenericHttpMessageConverter.write(AbstractGenericHttpMessageConverter.java:126) ~[spring-web-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:345) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.HttpEntityMethodProcessor.handleReturnValue(HttpEntityMethodProcessor.java:263) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78) ~[spring-web-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:136) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:986) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:891) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) ~[spring-webmvc-6.2.7.jar:6.2.7]

JSON序列化错误解决方案:使用DTO(数据传输对象)隔离实体

这个错误是由于Jackson在序列化Hibernate代理对象时遇到问题导致的。当Hibernate加载实体时,会使用代理(Proxy)延迟加载关联对象,而Jackson默认无法处理这些代理对象。

推荐使用DTO来控制序列化的数据,避免直接序列化实体:

package com.waylau.rednote.dto;

import com.waylau.rednote.entity.Comment;
import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * CommentResponseDto 评论的响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
@Getter
@Setter
public class CommentResponseDto {

    // 以下是从Comment对象中获取
    private Long commentId;
    private String content;
    private LocalDateTime createAt;

    // 以下是从User对象中获取
    private Long userId;
    private String username;
    private String avatar;

    // 以下是从Note对象中获取
    private Long noteId;

    // 以下是从Comment的parent对象中获取
    private Long parentCommentId;

    // 以下是从Comment的parent对象中的User对象中获取
    private String parentCommentUsername;

    // 子评论
    private List<CommentResponseDto> replies = new ArrayList<>();

    public static CommentResponseDto toCommentResponseDto(Comment comment) {
        if (comment == null) {
            return null;
        }

        CommentResponseDto commentResponseDto = new CommentResponseDto();
        commentResponseDto.setCommentId(comment.getCommentId());
        commentResponseDto.setContent(comment.getContent());
        commentResponseDto.setCreateAt(comment.getCreateAt());

        User user = comment.getUser();
        commentResponseDto.setUserId(user.getUserId());
        commentResponseDto.setUsername(user.getUsername());
        commentResponseDto.setAvatar(user.getAvatar());

        Note note = comment.getNote();
        commentResponseDto.setNoteId(note.getNoteId());

        Comment parentComment = comment.getParent();
        if (parentComment != null) {
            commentResponseDto.setParentCommentId(parentComment.getCommentId());

            User parentCommentUser = parentComment.getUser();
            commentResponseDto.setParentCommentUsername(parentCommentUser.getUsername());
        }

        List<Comment> replies = comment.getReplies();
        for (Comment reply : replies) {
            CommentResponseDto replyDto = toCommentResponseDto(reply);
            commentResponseDto.getReplies().add(replyDto);
        }

        return commentResponseDto;
    }
}

通过以上方法,你应该能够解决JSON序列化时的代理对象问题。选择最适合你项目的方案,通常结合使用DTO和注解配置是最推荐的做法。

2.6 扩展全局异常处理器,处理评论模块中可能出现的异常

package com.waylau.rednote.exception;

/**
 * CommentNotFoundException 评论不存在异常
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/22
 **/
public class CommentNotFoundException extends ValidationException {
    public CommentNotFoundException(String message) {
        super("评论不存在异常. " + message);
    }

    public CommentNotFoundException(String message, Throwable cause) {
        super("评论不存在异常. " + message, cause);
    }
}
@ControllerAdvice
public class GlobalExceptionHandler {

    //  ...为节约篇幅,此处省略非核心内容

    // 评论不存在异常
    @ExceptionHandler(CommentNotFoundException.class)
    public String handleCommentNotFoundException(CommentNotFoundException ex, Model model) {
        logger.error("评论不存在异常: {}", ex.getMessage(), ex);

        model.addAttribute("errorCode", 404);
        model.addAttribute("errorMessage", "异常信息: " + ex.getMessage());
        
        return "400-error";
    }
}

2.7 在安全配置类中,配置评论模块的访问权限

安全配置

  1. 在 Spring Security 配置类中,进一步细化评论API的访问权限
  2. 确保只有普通用户角色可以访问评论API

修改WebSecurityConfig如下:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // ...为节约篇幅,此处省略非核心内容    
            .authorizeHttpRequests(authorize -> authorize
                    // ...为节约篇幅,此处省略非核心内容

                    // 允许USER角色的用户访问 /comment/** 的资源
                    .requestMatchers("/comment/**").hasRole("USER")
                    // 其他请求需要认证
                    .anyRequest().authenticated()
            )

 
    ;

    return http.build();
}            

2.8 发布评论及评论列表展示的功能实现

前端实现

1. HTML模板修改

在笔记卡片评论区域的”发送“按钮上添加点击事件,并设置data属性、增加评论列表div:

<!-- 评论区 -->
<div class="comments-section">
    <div class="comments-header">
        <div class="comments-title">
            评论区
        </div>
    </div>

    <!-- 评论输入框 -->
    <div class="comment-input">
        <img class="comment-avatar" src="../static/images/rn_avatar.png" th:src="@{/images/rn_avatar.png}"
                alt="头像">
        <textarea class="comment-textarea" placeholder="分享你的想法..."></textarea>
        <div class="comment-btn" th:data-note-id="${note.noteId}" th:onclick="sendComment([[${note.noteId}]])">
            发送
        </div>
    </div>

    <!-- 评论列表 -->
    <div class="comment-list" id="commentList"></div>
</div>

2. JavaScript实现评论交互

// 首次加载评论
const noteId = document.querySelector('.comment-btn').dataset.noteId;
loadComments(noteId);

// 处理评论发送按钮事件
function sendComment(noteId) {
    const textarea = document.querySelector('.comment-textarea');

    // 获取评论内容
    const commentContent = textarea.value.trim();

    if (commentContent === '') {
        alert('评论内容不能为空');
        return;
    }

    // 发送请求
    fetch(`/comment/${noteId}`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
        },
        body: commentContent
    })
    .then(response => {
            if (response.ok) {
                // 加载评论列表
                loadComments(noteId);

                // 清空评论输入框
                textarea.value = '';
            } else  {
                alert('评论失败,请重试');
            }
        })
        .catch(error => {
            console.error('评论错误:', error);
            alert('评论失败,请稍后重试');
        });
}

// 加载评论列表
function loadComments(noteId) {
    // 获取评论列表容器
    const commentList = document.getElementById('commentList');

    // 清空评论列表
    commentList.innerHTML = '';

    // 发送请求获取评论列表数据
    fetch(`/comment/${noteId}`)
    .then(response => response.json())
    .then(data => {
        // 判定返回的数据是否为空
        if (data.length > 0) {
            // 遍历评论列表,生成评论项并添加到列表容器中
            data.forEach(comment => {
                const commentElement = createCommentElement(comment);
                commentList.appendChild(commentElement);
            })
        } else {
            // 添加一个提示元素
            const noCommentElement = createNoCommentElement();
            commentList.appendChild(noCommentElement);
        }
    })
}

// 创建一个评论项元素
function createCommentElement(comment) {
    const commentElement = document.createElement('div');
    commentElement.className = 'comment-item';
    commentElement.dataset.commentId = comment.commentId;

    // 格式化日期
    const date = new Date(comment.createAt);
    const formattedDate = date.toLocaleString();

    commentElement.innerHTML = `
    <div class="comment-header">
        <img src="${comment.avatar ? comment.avatar : '/images/rn_avatar.png'}" alt="用户头像" class="comment-user-avatar">
        <div class="comment-user-info">
            <div class="comment-username">${comment.username}</div>
            <div class="comment-time">${formattedDate}</div>
        </div>

        <!-- TODO 回复评论-->
        <button class="reply-btn">
            <i class="fa fa-comment-o"></i>
        </button>

        <!-- TODO 删除评论-->
        <button class="delete-comment">
            <i class="fa fa-trash-o"></i>
        </button>
    </div>

    <div class="comment-content">${comment.content}</div>
    `;

    return commentElement;
}

// 添加一个暂无评论的提示元素
function createNoCommentElement() {
    const commentElement = document.createElement('div');
    commentElement.innerHTML = `<p class="empty-comments">暂无评论,快来发表你的看法吧</p>`;

    return commentElement;
}

3. CSS样式美化界面

添加必要的CSS样式:

/* 评论区(第二部分)*/
.comment-user-avatar {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    margin-right: 10px;
}

.comment-user-info {
    flex: 1;
}

.comment-username {
    font-weight: bold;
}

.comment-time {
    font-size: 12px;
    color: #999;
}

.comment-content {
    margin-left: 42px;
    margin-bottom: 10px;
}


.reply-btn, .delete-comment {
    background: none;
    border: none;
    color: #999;
    cursor: pointer;
    font-size: 12px;
}

.delete-comment {
    margin-left: 10px;
}

.empty-comments {
    color: #999;
    text-align: center;
    padding: 20px 0;
}

运行调测

通过以上实现,可以在项目中完整实现评论区功能,包括评论发布、评论列表展示和用户交互反馈等,演示效果如下图14-1所示。

图14-1 评论区效果

2.9 删除评论的功能实现

删除评论按钮

在删除评论按钮上添加数据属性绑定,并根据判定动态设置样式:

// 创建一个评论项元素
function createCommentElement(comment) {
    const commentElement = document.createElement('div');
    commentElement.className = 'comment-item';
    commentElement.dataset.commentId = comment.commentId;

    // 格式化日期
    const date = new Date(comment.createAt);
    const formattedDate = date.toLocaleString();

    commentElement.innerHTML = `
    <div class="comment-header">
        <img src="${comment.avatar ? comment.avatar : '/images/rn_avatar.png'}" alt="用户头像" class="comment-user-avatar">
        <div class="comment-user-info">
            <div class="comment-username">${comment.username}</div>
            <div class="comment-time">${formattedDate}</div>
        </div>

        <!-- TODO 回复评论-->
        <button class="reply-btn">
            <i class="fa fa-comment-o"></i>
        </button>

        <!-- 删除评论-->
        <button class="delete-comment" ${isCurrentUser(comment.userId) ? '' : 'style="display:none"'}
            onclick="deleteComment(${comment.commentId})">
            <i class="fa fa-trash-o"></i>
        </button>
    </div>

    <div class="comment-content">${comment.content}</div>
    `;

    return commentElement;
}

只有评论的作者自己才能删除。

检查是否是当前用户

确保在 HTML 模板中有一个 meta 标签来存储 当前用户ID:

<!-- 确保有一个meta标签来存储当前用户ID -->
<meta name="currentUserId" th:content="${#authentication.principal?.userId}"></meta>

检查是否是当前用户函数isCurrentUser()如下:

// 检查是否是当前用户
function isCurrentUser(userId) {
    const currentUserId = document.querySelector('meta[name="currentUserId"]').content;
    return userId.toString() === currentUserId;
}

调用删除评论的接口

调用删除评论的接口:

// 处理删除按钮点击事件
function deleteComment(commentId) {
    // 删除评论前先做确认提示
    if (!confirm("确定要删除此评论吗?")) {
        return;
    }

    // 发送删除请求
    fetch(`/comment/${commentId}`, {
        method: 'DELETE',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
        }
    }).then(response => {
            if (response.ok) {
                // 加载评论列表
                loadComments(noteId);
            } else  {
                alert('删除评论失败,请重试');
            }
        })
        .catch(error => {
            console.error('删除评论错误:', error);
            alert('删除评论失败,请稍后重试');
        });
}

运行调测

通过以上实现,可以在项目中完整实现评论删除功能,包括评论删除提前的提示,以及删除后评论列表的刷新。

如下图14-2所示的是评论删除提前的提示。

图14-2 评论删除提前的提示

如下图14-3所示的是评论删除后的列表刷新。

图14-3 评论删除后的列表刷新

2.10 掌握评论回复及多级评论的实现技巧

评论下面可能会有回复(也就是子评论),回复下面还可以继续回复。因此,如何处理多级嵌套的评论,是本节要处理的核心。

处理子评论下再有子评论(即多层级嵌套评论)需要从后端API、前端渲染几个层面进行调整。

以下是完整的解决方案。

后端API调整

修改评论服务CommentServiceImpl,支持递归获取所有层级的评论:

@Override
public List<Comment> getCommentsByNoteId(Long noteId) {
    /*return commentRepository.findByParentIsNullAndNoteNoteIdOrderByCreateAtDesc(noteId);*/
    List<Comment> rootComments = commentRepository.findByParentIsNullAndNoteNoteIdOrderByCreateAtDesc(noteId);

    // 递归加载所有的回复及其子回复到根评论上
    rootComments.forEach(rootComment -> {
        // 构建一个能包含回复及其子回复的列表,从1到N层的所有回复
        List<Comment> allReplies = new ArrayList<>();

        // 第一层的回复直接放到allReplies列表中
        List<Comment> firstLevelReplies = rootComment.getReplies();
        allReplies.addAll(firstLevelReplies);

        // 第二层及其后续的回复,就递归而后添加到allReplies列表中
        loadRepliesRecursively(firstLevelReplies, allReplies);

        // allReplies列表按照创建时间倒序排序
        rootComment.setReplies(allReplies.stream().sorted(Comparator.comparing(Comment::getCreateAt).reversed()).toList());
    });

    return rootComments;
}

// 递归加载回复
private void loadRepliesRecursively(List<Comment> replies, List<Comment> allReplies) {
    replies.forEach(reply -> {
        List<Comment> sonReplies = reply.getReplies();
        allReplies.addAll(sonReplies);

        loadRepliesRecursively(sonReplies, allReplies);
    });
}

整体上将评论内容分为了两层

  • 根评论
  • 子评论(子评论下层的子评论也会递归汇总到子评论上)

前端渲染实现

在createCommentElement函数内增加回复列表:

// 创建一个评论项元素
function createCommentElement(comment) {

    // ...为节约篇幅,此处省略非核心内容

    commentElement.innerHTML = `
        <div class="comment-header">

            <!-- ...为节约篇幅,此处省略非核心内容 -->

        </div>

        <div class="comment-content">${comment.content}</div>

        <!-- 回复列表 -->
        <div class="reply-list">
            ${renderReplies(comment.replies)}
        </div>
    `;

    return commentElement;
}

通过renderReplies函数来渲染回复列表:

// 渲染回复列表
function renderReplies(replies) {
    // 判定回复列表是否为空
    if (replies.length === 0) {
        return '';
    }

    return replies.map(reply => {
        const date = new Date(reply.createAt);
        const formattedDate = date.toLocaleString();

        return `
        <div class="reply-item">
            <div class="reply-header">
                <span class="reply-username">${reply.username}</span>
                <span class="reply-to">»</span>
                <span class="reply-target">${reply.parentCommentUsername ? reply.parentCommentUsername : '评论作者'}</span>
                <span class="reply-time">${formattedDate}</span>
            </div>
            <div class="reply-content">${reply.content}</div>

            <!-- 回复回复的按钮-->
            <button class="reply-btn">
                <i class="fa fa-comment-o"></i>
            </button>
            <!-- 删除回复的按钮-->
            <button class="delete-comment" ${isCurrentUser(reply.userId) ? '' : 'style="display:none"'}>
                <i class="fa fa-trash-o"></i>
            </button>
        </div>
        `;
    }).join('');
}

2.11 通用型回复弹窗的设计

回复弹窗

<body>标签底部、<script>标签之前添加回复弹窗:

<!-- 回复弹窗 -->
<div class="modal" id="replyModal" tabindex="-1" aria-labelledby="replyModalLabel">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title" id="replyModalLabel">回复 <span id="replyToUsername"></span></h5>
            </div>
            <div class="modal-body">
                <div class="reply-to-content"></div>
                <textarea class="form-control" id="replyContent" rows="3" placeholder="写下你的回复..."></textarea>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-light close-model" data-bs-dismiss="modal">取消</button>
                <button type="button" class="btn btn-danger" id="submitReply">提交回复</button>
            </div>
        </div>
    </div>
</div>

在回复评论按钮上增加点击事件

在回复评论按钮上增加点击事件以触发showReplyModal函数。

<!-- 回复评论 -->
<button class="reply-btn" onclick='showReplyModal(${JSON.stringify(comment)})'>
   <i class="fa fa-comment-o"></i>
</button>

需要注意,在代码中,不能写成showReplyModal(${comment}),这是错误的,主要原因如下:

  • ${comment} 是一个 JavaScript 对象,而不是一个简单的值
  • 在 HTML 的 onclick 属性中,这样的对象无法被正确解析和传递

正确的传递方式

  • 应该传递具体的属性值,如commentId、username等
  • 或者将整个对象以 JSON 字符串的形式传递

以下是上述两种传递方式的使用示例:

<!-- 方式1:传递具体属性 -->
<button class="reply-btn" onclick="showReplyModal(${comment.commentId}, '${comment.username}', '${comment.content}')">

<!-- 方式2:传递序列化的对象(推荐) -->
<button class="reply-btn" onclick='showReplyModal(${JSON.stringify(comment)})'>

showReplyModal函数

showReplyModal函数用于显示回复模态窗口,定义如下:

// 显示回复模态窗口
function showReplyModal(comment) {
   console.log("comment:" + comment);
   const commentId = comment.commentId;
   const username = comment.username;
   const content = comment.content;
   const noteId = comment.noteId;

   const modal = document.getElementById('replyModal');
   modal.querySelector('.reply-to-content').textContent = content;
   document.getElementById('replyToUsername').textContent = username;

   const submitReply = document.getElementById('submitReply');
   submitReply.dataset.commentId = commentId;
   submitReply.dataset.noteId = noteId;

   // 显示回复模态窗口
   modal.style.display = 'block';
   modal.querySelector('.close-model').onclick = function() {
      modal.style.display = 'none';
      modal.querySelector('.reply-to-content').textContent = '';
      document.getElementById('replyToUsername').textContent = '';
      submitReply.dataset.commentId = '';
      submitReply.dataset.noteId = '';
      document.getElementById('replyContent').value = '';
   }
}

CSS样式调整

添加层级缩进和视觉区分:

/*评论回复*/
.reply-list {
    margin-left: 42px;
    margin-top: 10px;
    padding-left: 10px;
    border-left: 2px solid #f5f5f5;
}

.reply-item {
    margin-bottom: 10px;
}

.reply-header {
    display: flex;
    align-items: center;
    font-size: 14px;
    color: #666;
}

.reply-username, .reply-target {
    font-weight: bold;
    margin-right: 5px;
}

.reply-to {
    margin-right: 5px;
}

.reply-time {
    margin-left: 10px;
    font-size: 12px;
    color: #999;
}

.reply-content {
    margin-top: 5px;
    margin-left: 0;
}

.submit-reply {
    background-color: #ff2442;
    color: white;
    border: none;
    padding: 8px 15px;
    border-radius: 4px;
    cursor: pointer;
}

2.12 提交回复及删除回复

设置提交回复事件处理

回复弹窗上的提交回复设置点击事件以触发handleSubmitReply函数执行:

<button type="button" class="btn btn-danger" id="submitReply" onclick="handleSubmitReply()">提交回复</button>

handleSubmitReply函数

handleSubmitReply函数用于处理发送提交回复的请求到后端API,代码如下:

// 处理提交回复点击事件
function handleSubmitReply() {
   const submitReply = document.getElementById('submitReply');
   const commentId  = submitReply.dataset.commentId;
   const noteId = submitReply.dataset.noteId;
   const replyContent = document.getElementById('replyContent').value;

   if (!replyContent) {
      return;
   }

   // 发送提交回复的请求
   fetch(`/comment/${noteId}/reply/${commentId}`, {
      method: "POST",
      headers: {
            "Content-Type": "application/json",
            'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
      },
      body: replyContent
   })
   .then(response => {
         if (response.ok) {
               // 加载评论列表
               loadComments(noteId);

               // 关闭回复模态窗口
               const modal = document.getElementById('replyModal');
               modal.style.display = 'none';
               document.getElementById('replyContent').value = '';
         } else  {
               alert('回复失败,请重试');
         }
      })
      .catch(error => {
         console.error('回复错误:', error);
         alert('回复失败,请稍后重试');
      });
}

在回复回复按钮上增加点击事件

在回复回复按钮上增加点击事件以触发showReplyModal函数。

<!-- 回复评论 -->
<button class="reply-btn" onclick='showReplyModal(${JSON.stringify(comment)})'>
   <i class="fa fa-comment-o"></i>
</button>

上述处理逻辑与在回复评论按钮上的点击事件处理一致,都是重用了回复弹窗处理回复。

在删除回复按钮上增加点击事件

在删除回复按钮上增加点击事件以触发deleteComment函数。

<!-- 删除回复的按钮-->
<button class="delete-comment" ${isCurrentUser(reply.userId) ? '' : 'style="display:none"'}
   onclick="deleteComment(${reply.commentId})">
   <i class="fa fa-trash-o"></i>
</button>

上述处理逻辑与在删除回复按钮上的点击事件处理一致,都是重用了删除评论的处理。

运行调测

通过以上实现,可以在项目中完整实现回复的功能,包括评论回复评论、回复列表展示、删除回复,以及删除回复后回复列表的刷新。

如下图14-4所示的是回复弹窗。

图14-4 回复弹窗

如下图14-5所示的是回复列表,能够显示完整的回复路径。

图14-5 回复列表能够显示完整的回复路径

通过以上实现,你可以支持无限层级的嵌套评论,同时保持良好的性能和用户体验。实际项目中,建议根据具体需求限制最大层级深度(如不超过5层),以避免界面过于复杂。

2.13 从评论列表跳转到作者详情页

前端修改

点击评论的作者头像时,我们希望就能跳转到该作者的详情页。实现方式,只需要在作者信息的标签外再套一层<a>标签即可。

// 创建一个评论项元素
function createCommentElement(comment) {
   const commentElement = document.createElement('div');
   commentElement.className = 'comment-item';
   commentElement.dataset.commentId = comment.commentId;

   // 格式化日期
   const date = new Date(comment.createAt);
   const formattedDate = date.toLocaleString();

   commentElement.innerHTML = `
   <div class="comment-header">
      <!-- 点击用户头像跳转到用户详情页 -->
      <a href="/user/profile/${comment.userId}">
            <img src="${comment.avatar ? comment.avatar : '/images/rn_avatar.png'}" alt="用户头像" class="comment-user-avatar">
      </a>

// ...为节约篇幅,此处省略非核心内容

回复内容也是类似处理,点击用户名跳转到用户详情页:

// 渲染回复列表
function renderReplies(replies) {
   // 判定回复列表是否为空
   if (replies.length === 0) {
      return '';
   }

   return replies.map(reply => {
      const date = new Date(reply.createAt);
      const formattedDate = date.toLocaleString();

      return `
      <div class="reply-item">
            <div class="reply-header">
               <!-- 点击用户名跳转到用户详情页 -->
               <a href="/user/profile/${reply.userId}">
                  <span class="reply-username">${reply.username}</span>
               </a>

// ...为节约篇幅,此处省略非核心内容

运行调试

如下图14-6所示的是回复列表,显示是能够可以跳转了。

图14-6 可以跳转的回复列表

2.14 最佳实践及优化建议

最佳实践

  1. 优先使用DTO:通过DTO控制序列化的数据,避免暴露敏感信息和循环引用
  2. 多级评论的实现:不管多少层,最终只呈现为两层

优化建议

  1. 性能优化

    • 实现评论分页加载,避免一次性加载过多评论
    • 使用WebSocket实现实时评论通知
    • 实现评论折叠/展开功能
  2. 防刷机制

    • 添加评论频率限制(如每分钟不超过5条)
    • 实现评论内容敏感词过滤
  3. 用户体验

    • 添加评论提交中的加载状态
    • 实现评论成功后的自动滚动到新评论位置
  4. 数据统计

    • 添加热门评论排序
    • 统计用户评论活跃度

3.1 模块功能概述

在原有项目基础上实现后台管理模块,需要从权限控制、路由设计、管理界面三个层面进行改造。下面是完整的实现方案:

  • 扩展为更灵活的用户角色与权限管理
  • 通过配置文件的方式初始化管理员账号
  • 自定义登录处理逻辑区分不同角色的登录
  • 创建专门处理后台管理请求的控制器类
  • 实现可重用的admin.html主模板

通过以上实现,你可以在原有的小红书项目中添加完整的后台管理模块,包括数据看板、用户管理、笔记管理、评论管理等功能,并通过权限系统确保只有管理员可以访问。

3.2 扩展为更灵活的用户角色与权限管理

设计角色

角色枚举如下:

package com.waylau.rednote.common;

/**
 * Role 角色枚举
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/06/18
 **/
public enum Role {
    USER("用户"),
    ADMIN("管理员");

    private final String description;

    Role(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}

将用户关联角色

扩展User实体,添加角色字段:

@Entity
@Table(name = "t_user")
@Data // @Data集合了 @ToString, @EqualsAndHashCode,所有字段的 @Getter和所有非final字段的 @Setter, @RequiredArgsConstructor
@NoArgsConstructor // 无参构造器
@AllArgsConstructor // 包含所有参数的构造器
public class User {

    // ...为节约篇幅,此处省略非核心内容
    
    /**
     * 角色
     */
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role = Role.USER;

}


上述代码,在创建User时会自动默认赋值为USER角色。但原先数据库已存在的历史数据,则不一定会赋值USE角色(可能是ADMIN角色,取决于具体的数据库)。比如,在MySQL里,t_user表最终的结果如下:

```sql
mysql> DESCRIBE t_user;
+----------+----------------------+------+-----+---------+-------+
| Field    | Type                 | Null | Key | Default | Extra |
+----------+----------------------+------+-----+---------+-------+
| user_id  | bigint               | NO   | PRI | NULL    |       |
| avatar   | varchar(255)         | YES  |     | NULL    |       |
| bio      | varchar(255)         | YES  |     | NULL    |       |
| password | varchar(255)         | YES  |     | NULL    |       |
| phone    | varchar(255)         | YES  |     | NULL    |       |
| username | varchar(255)         | YES  |     | NULL    |       |
| role     | enum('ADMIN','USER') | NO   |     | NULL    |       |
+----------+----------------------+------+-----+---------+-------+
7 rows in set (0.037 sec)

t_user表的role字段已经是支持枚举类型了,但没有提供默认值(Default是NULL),因此,数据库会自动给role字段赋值一个默认值ADMIN或者USER。以下是在MySQL中,role字段赋值的默认值是ADMIN:

mysql> select * from t_user;
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
| user_id | avatar                                                                      | bio                                                                      | password                                                     | phone       | username | role  |
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
|       1 | /uploads/2025-08-21/35ff037f-043a-4551-8912-99b89eb11591_waylau_181_181.jpg | Life was like a box of chocolates, you never know what you're gonna get. | $2a$10$wfuboZNYniQBTNo5/3vuau6HKpbr0y3ktavpzH6L3jd6Yw7cbHwKm | 13411111111 | waylau   | ADMIN |
|       2 | NULL                                                                        | NULL                                                                     | $2a$10$5QzEQnaMCKP/jy4PCHy7Re2gJE18xzslF3JoBwgbubkYPprdtNQla | 13411111112 | bobo     | ADMIN |
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
2 rows in set (0.012 sec)

还需要手动执行如下脚本来将存量数据改为USER角色:

UPDATE t_user SET role = 'USER';

修改UserDetailsServiceImpl服务

修改UserDetailsServiceImpl服务,按照User实体上维护的实际的角色返回:

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户,判定用户是否存在
        Optional<User> optionalUser = userRepository.findByUsername(username);
        if (!optionalUser.isPresent()) {
            // 抛出用户不存在的异常
            throw new UsernameNotFoundException(ExceptionType.USERNAME_NOT_FOUND);
        }

        User user = optionalUser.get();

        // 将User转为自定义的UserDetails对象
        return new CustomUserDetails(
                user.getUserId(),
                user.getUsername(),
                user.getPassword(),
                /*// 设置所有的数据库里面的用户都是USER角色
                AuthorityUtils.createAuthorityList("ROLE_USER"));*/
                AuthorityUtils.createAuthorityList("ROLE_" + user.getRole().name()));
    }
}

3.3 通过配置文件的方式初始化管理员账号

配置文件中添加管理员信息

application.propertiesapplication.yml中添加管理员配置:

# 管理员配置
admin.username=admin
admin.password=admin123

初始化管理员账号

修改UserServiceImpl,只能加如下方法,在应用启动的时候,自动通过配置文件初始化管理员账号:

import jakarta.annotation.PostConstruct;
import com.waylau.rednote.common.Role;

// ...为节约篇幅,此处省略非核心内容

@Value("${admin.username}")
private String adminUsername;

@Value("${admin.password}")
private String adminPassword;

@PostConstruct
public void initAdminUser() {
    // 查询数据库是否存在管理员用户
    Optional<User> optionalAdminUser = findByUsername(adminUsername);
    User adminUser;
    if (!optionalAdminUser.isPresent()) {
        // 不存在,则创建管理员用户
        adminUser = new User();
        adminUser.setUsername(adminUsername);
    } else {
        // 存在,则获取管理员用户
        adminUser = optionalAdminUser.get();
    }

    // 明文密码加密
    String encodedPassword = passwordEncoder.encode(adminPassword);
    adminUser.setPassword(encodedPassword);

    adminUser.setRole(Role.ADMIN);

    updateUser(adminUser);
}

用户服务增加查询用户名的接口

修改UserService,增加如下接口:

/**
  * 根据用户名查找用户
  *
  * @param username
  * @return
  */
Optional<User> findByUsername(String username);

修改UserServiceImpl,增加如下接口实现:

@Override
public Optional<User> findByUsername(String username) {
    return userRepository.findByUsername(username);
}

增加后台管理控制器

增加后台管理控制器AdminController,以便显示后台管理界面。

package com.waylau.rednote.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * AdminController 后台管理控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/23
 **/
@Controller
@RequestMapping("/admin")
public class AdminController {
    @GetMapping()
    public String goToAdmin() {
        return "admin";
    }
}

运行调测

通过以上实现,可以在项目中完整实现了角色管理功能,包括可以使用管理员账号admin进行登录。

如下图15-1所示,是admin登录应用之后,访问/explore页面路径的效果,提示没有权限。

图15-1 访问/explore页面路径的效果

如下图15-2所示,是admin访问/admin页面路径的效果,显示有权限。

图15-2 访问/admin页面路径的效果

说明基于角色的访问控制已经生效了。

通过查询数据库表t_user数据,能够看到已经初始化了用户名为admin角色为ADMIN的用户了:

mysql> select * from t_user;
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
| user_id | avatar                                                                      | bio                                                                      | password                                                     | phone       | username | role  |
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
|       1 | /uploads/2025-08-21/35ff037f-043a-4551-8912-99b89eb11591_waylau_181_181.jpg | Life was like a box of chocolates, you never know what you're gonna get. | $2a$10$wfuboZNYniQBTNo5/3vuau6HKpbr0y3ktavpzH6L3jd6Yw7cbHwKm | 13411111111 | waylau   | USER  |
|       2 | NULL                                                                        | NULL                                                                     | $2a$10$5QzEQnaMCKP/jy4PCHy7Re2gJE18xzslF3JoBwgbubkYPprdtNQla | 13411111112 | bobo     | USER  |
|      52 | NULL                                                                        | NULL                                                                     | $2a$10$Zvc89ZXXwQSaF1.PTCAPreYVgQPwEvO1aE3yFw6V.PPsZJVD6POcu | NULL        | admin    | ADMIN |
+---------+-----------------------------------------------------------------------------+--------------------------------------------------------------------------+--------------------------------------------------------------+-------------+----------+-------+
3 rows in set (0.008 sec)

3.4 自定义登录处理逻辑区分不同角色的登录

为了区分普通用户和管理员登录,可以自定义登录成功后的处理逻辑。

package com.waylau.rednote.controller;

import com.waylau.rednote.common.Role;
import com.waylau.rednote.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * IndexController 首页控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/20
 **/
@Controller
@RequestMapping("/")
public class IndexController {
    @Autowired
    private UserService userService;

    @GetMapping
    public String index() {
        /*// 重定向到首页笔记探索页面
        return "redirect:/explore";*/

        // 判定当前用户角色,如果是管理员则跳转到管理页面,否则跳转到笔记探索页面
        return "redirect:/" + (userService.getCurrentUser().getRole() == Role.ADMIN ? "admin" : "explore");
    }
}

默认成功登录后会重定向到/,这里再判定角色:

  • 如果是ADMIN角色,就重定向到/admin
  • 否则就重定向到/explore

通过这种方式,你可以实现普通用户存储在数据库中,而管理员账号存储在配置文件中,同时使用统一的登录入口进行身份验证。

3.5 创建专门处理后台管理请求的控制器类

/admin路径视为后台管理请求的总路口,其他分为以下几个功能:

  • /admin/dashboard路径处理数据看板;
  • /admin/user路径处理用户管理;
  • /admin/note路径处理笔记管理;
  • /admin/comment路径处理评论管理;

AdminController修改如下:

package com.waylau.rednote.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * AdminController 后台管理控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/23
 **/
@Controller
@RequestMapping("/admin")
public class AdminController {
    @GetMapping()
    public String goToAdmin() {
        /*return "admin";*/
        // 重定向到第一个功能界面“数据看板”
        return "redirect:/admin/dashboard";
    }

    /**
     * 显示数据看板界面
     */
    @GetMapping("/dashboard")
    public String dashboard(Model model) {
        model.addAttribute("contentFragment", "admin-dashboard");
        return "admin";
    }

    /**
     * 显示用户管理界面
     */
    @GetMapping("/user")
    public String user(Model model) {
        model.addAttribute("contentFragment", "admin-user");
        return "admin";
    }

    /**
     * 显示笔记管理界面
     */
    @GetMapping("/note")
    public String note(Model model) {
        model.addAttribute("contentFragment", "admin-note");
        return "admin";
    }

    /**
     * 显示评论管理界面
     */
    @GetMapping("/comment")
    public String comment(Model model) {
        model.addAttribute("contentFragment", "admin-comment");
        return "admin";
    }
}

上述代码,当访问/admin路径时,会重定向到/admin/dashboard

四个功能都是使用相同的admin.html主模板,并在运行时自动替换为不通过功能的模板片段contentFragment。

3.6 实现可重用的admin.html主模板

admin.html主模板实现了:

  • 导航栏
  • 菜单
  • 内容区域
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RN - 后台管理</title>
    <!-- 引入 Bootstrap CSS -->
    <link href="https://cdn.bootcdn.net/ajax/libs/bootstrap/5.3.6/css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}" rel="stylesheet">

    <!-- 引入 Font Awesome -->
    <link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
          th:href="@{/css/font-awesome.min.css}" rel="stylesheet">

    <style>
        /* 全局样式 */
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background-color: #f5f5f5;
        }

        .bi {
            display: inline-block;
            width: 1rem;
            height: 1rem;
        }

        /*
        * Sidebar
        */
        @media (min-width: 768px) {
            .sidebar .offcanvas-lg {
                position: -webkit-sticky;
                position: sticky;
                top: 48px;
            }

            .navbar-search {
                display: block;
            }

            .sidebar .nav-link {
                font-size: .875rem;
                font-weight: 500;
            }

            .sidebar .nav-link.active {
                color: #2470dc;
            }

            .sidebar-heading {
                font-size: .75rem;
            }
        }
    </style>
</head>
<!--导航栏-->
<header class="navbar navbar-expand-lg">
    <div class="container">
        <a class="navbar-brand" href="/" th:href="@{/}">
            <img src="../static/images/rn_logo.png" th:src="@{/images/rn_logo.png}" alt="RN" height="24">
        </a>

        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu"
                aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</header>

<body>
<div class="container">
    <div class="row">
        <!--菜单-->
        <div class="sidebar border border-right col-md-3 col-lg-2 p-0 bg-body-tertiary">
            <div class="offcanvas-md offcanvas-end bg-body-tertiary" tabindex="-1" id="sidebarMenu"
                 aria-labelledby="sidebarMenuLabel">
                <div class="offcanvas-body d-md-flex flex-column p-0 pt-lg-3 overflow-y-auto">
                    <ul class="nav flex-column">
                        <li class="nav-item">
                            <a class="nav-link d-flex align-items-center gap-2 active" aria-current="page"
                               href="/admin/dashboard" th:href="@{/admin/dashboard}">
                                <i class="fa fa-tachometer"></i>
                                数据看板
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link d-flex align-items-center gap-2" aria-current="page"
                               href="/admin/user" th:href="@{/admin/user}">
                                <i class="fa fa-users"></i>
                                用户管理
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link d-flex align-items-center gap-2" aria-current="page"
                               href="/admin/note" th:href="@{/admin/note}">
                                <i class="fa fa-file-text"></i>
                                笔记管理
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link d-flex align-items-center gap-2" aria-current="page"
                               href="/admin/comment" th:href="@{/admin/comment}">
                                <i class="fa fa-comments"></i>
                                评论管理
                            </a>
                        </li>
                    </ul>
                    <hr class="my-3">
                    <ul class="nav flex-column mb-auto">
                        <li class="nav-item">
                            <form method="post" th:action="@{/logout}" action="/logout">
                                <input type="submit" class="nav-link" value="退出登录">
                            </form>
                        </li>
                    </ul>
                </div>
            </div>
        </div>
        <!--内容区域-->
        <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
            <!--代码片段-->
            <div th:replace="~{${contentFragment}}"></div>
        </main>
    </div>
</div>


<!-- Bootstrap JS -->
<script src="https://cdn.bootcdn.net/ajax/libs/bootstrap/5.3.6/js/bootstrap.bundle.min.js"
        th:src="@{/js/bootstrap.bundle.min.js}"></script>

</body>
</html>

其中,菜单可以跳转到不同的子功能的页面。子功能的页面内容区域通过th:replace="~{${contentFragment}}"来实现动态替换不同的HTML片段。

3.7 数据看板功能的实现

定义页面片段

新增admin-dashboard.html文件,数据看板HTML页面片段定义如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<body>
    <!-- 定义片段 -->
    <div th:fragment="admin-dashboard">
        <div class="card shadow mb-4">
            <div class="card-header py-3">
                <h2>数据看板</h2>
            </div>
            <div class="card-body">
                <!-- 统计卡片 -->
                <div class="col-xl-3 col-md-6 mb-4">
                    <div class="card border-left-primary shadow h-100 py-2">
                        <div class="card-body">
                            <div class="row no-gutters align-items-center">
                                <div class="col mr-2">
                                    <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">用户总数</div>
                                    <div class="h5 mb-0 font-weight-bold text-gray-800" th:text="${userCount}">0</div>
                                </div>
                                <div class="col-auto">
                                    <i class="fa fa-users fa-2x text-gray-300"></i>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="col-xl-3 col-md-6 mb-4">
                    <div class="card border-left-success shadow h-100 py-2">
                        <div class="card-body">
                            <div class="row no-gutters align-items-center">
                                <div class="col mr-2">
                                    <div class="text-xs font-weight-bold text-success text-uppercase mb-1">笔记总数</div>
                                    <div class="h5 mb-0 font-weight-bold text-gray-800" th:text="${noteCount}">0</div>
                                </div>
                                <div class="col-auto">
                                    <i class="fa fa-file-text fa-2x text-gray-300"></i>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="col-xl-3 col-md-6 mb-4">
                    <div class="card border-left-info shadow h-100 py-2">
                        <div class="card-body">
                            <div class="row no-gutters align-items-center">
                                <div class="col mr-2">
                                    <div class="text-xs font-weight-bold text-info text-uppercase mb-1">评论总数</div>
                                    <div class="h5 mb-0 font-weight-bold text-gray-800" th:text="${commentCount}">0
                                    </div>
                                </div>
                                <div class="col-auto">
                                    <i class="fa fa-comments fa-2x text-gray-300"></i>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>

</html>

上述片段的名称为“admin-dashboard”。

统计用户数

UserRepository新增如下接口:

/**
  * 统计用户数
  *
  * @return
  */
long count();

UserService新增如下接口:

/**
  * 统计用户数
  *
  * @return
  */
long countUsers();

UserServiceImpl新增如下方法:

@Override
public long countUsers() {
    return userRepository.count();
}

统计笔记数

NoteRepository新增如下接口:

/**
  * 统计笔记数
  *
  * @return
  */
long count();

NoteService新增如下接口:

/**
  * 统计笔记数
  *
  * @return
  */
long countNotes();

NoteServiceImpl新增如下方法:

@Override
public long countNotes() {
    return noteRepository.count();
}

统计评论数

CommentRepository新增如下接口:

/**
  * 统计评论数
  *
  * @return
  */
long count();

CommentService新增如下接口:

/**
  * 统计评论数
  *
  * @return
  */
long countComments();

CommentServiceImpl新增如下方法:

@Override
public long countComments() {
    return commentRepository.count();
}

修改控制器

AdminController修改如下:

@GetMapping("/dashboard")
public String dashboard(Model model) {
    // 统计数据
    long userCount = userService.countUsers();
    long noteCount = noteService.countNotes();
    long commentCount = commentService.countComments();

    model.addAttribute("userCount", userCount);
    model.addAttribute("noteCount", noteCount);
    model.addAttribute("commentCount", commentCount);

    model.addAttribute("contentFragment", "admin-dashboard");

    return "admin";
}

运行调测

如下图15-3所示,是账号admin访问/admin页面路径的效果,重定向到了/admin/dashboard页面。

图15-3 访问/admin页面路径的效果

admin.html模版采用了响应式的布局,即便在移动设备上,也能能有很好的适配。如下图15-4所示,是在移动设备上访问/admin页面的效果。

图15-4 移动设备上访问/admin页面路径的效果

点击右上角的按钮,也可以展示完整菜单,如下图15-5所示

图15-5 点击右上角的按钮展示完整菜单

3.8 设计用户管理功能的用户分页查询

定义页面片段

新增admin-user.html文件,用户管理功能HTML页面片段定义如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- 定义片段 -->
<div th:fragment="admin-user">
    <div class="card shadow mb-4">
        <div class="card-header py-3">
            <h2>用户列表</h2>
        </div>
        <div class="card-body">
            <div class="table-responsive small">
                <table class="table table-striped table-sm">
                    <thead>
                    <tr>
                        <th>ID</th>
                        <th>用户名</th>
                        <th>电话</th>
                        <th>角色</th>
                        <th>操作</th>
                    </tr>
                    </thead>
                    <tbody>
                    <tr th:each="user:${userPage.content}">
                        <td th:text="${user.userId}">1</td>
                        <td th:text="${user.username}">waylau</td>
                        <td th:text="${user.phone}">13412345678</td>
                        <td th:text="${user.role}">USER</td>
                        <td>
                            <button class="btn btn-sm btn-light">编辑</button>
                            <button class="btn btn-sm btn-danger">删除</button>
                        </td>
                    </tr>
                    </tbody>
                </table>
            </div>

            <!-- 分页控件 -->
            <div class="d-flex justify-content-center">
                <nav>
                    <ul class="pagination">
                        <li class="page-item" th:classappend="${userPage.first} ? 'disabled' : ''">
                            <a class="page-link" href="#" th:href="@{/admin/user(page=${userPage.number})}">
                                上一页
                            </a>
                        </li>
                        <li class="page-item" th:classappend="${userPage.number+1 == i} ? 'active' : ''"
                            th:each="i : ${#numbers.sequence(1,userPage.totalPages)}">
                            <a class="page-link" href="#" th:href="@{/admin/user(page=${i})}" th:text="${i}">
                                1
                            </a>
                        <li class="page-item" th:classappend="${userPage.last} ? 'disabled' : ''">
                            <a class="page-link" href="#" th:href="@{/admin/user(page=${userPage.number+1+1})}">
                                下一页
                            </a>
                        </li>
                    </ul>
                </nav>
            </div>
        </div>
    </div>
</div>
</body>
</html>

上述片段的名称为“admin-user”。

分页查询所有用户

UserRepository新增如下接口:

/**
  * 分页查询所有用户
  *
  * @param pageable
  * @return
  */
Page<User> findAll(Pageable pageable);

UserService新增如下接口:

/**
  * 分页查询所有用户
  *
  * @param page
  * @param size
  * @return
  */
Page<User> getAllUsers(int page, int size);

UserServiceImpl新增如下方法:

@Override
    public Page<User> getAllUsers(int page, int size) {
        // 构造Pageable对象,按照用户ID倒序排序
        Pageable pageable = PageRequest.of(page - 1, size, Sort.by("userId").descending());
        return userRepository.findAll(pageable);
    }
}

修改控制器

AdminController新增如下方法:

private static final int PAGE_SIZE = 10;

/**
 * 显示用户管理界面
 */
@GetMapping("/user")
/*public String user(Model model) {*/
public String user(Model model, @RequestParam(defaultValue = "1") int page) {
    // 分页查询所有用户数据
    Page<User> userPage = userService.getAllUsers(page, PAGE_SIZE);

    model.addAttribute("userPage", userPage);
    model.addAttribute("contentFragment", "admin-user");
    return "admin";
}

运行调测

如下图15-6所示,是账号admin访问/admin/user页面路径的效果。

图15-6 访问/admin/user页面路径的效果

如下图15-7所示,是在移动设备上访问/admin/user页面的效果。

图15-7 移动设备上访问/admin/user页面路径的效果

3.9 设计用户管理功能的编辑用户操作

“编辑”按钮上设置点击事件

在admin-user.html文件的“编辑”按钮上设置点击事件,以便跳转到编辑页面,修改如下:

<button class="btn btn-sm btn-light"
        th:onclick="'window.location.href=\'' + @{/admin/user/{userId}/edit(userId=${user.userId})} + '\''">
    编辑
</button>

上述代码会重定向到编辑页面。

编辑页面控制器

编辑页面控制器如下:

/**
 * 显示用户编辑界面
 */
@GetMapping("/user/{userId}/edit")
public String editUser(@PathVariable Long userId, Model model) {
    // 判定用户是否存在,不存在则抛出异常
    Optional<User> optionalUser = userService.findByUserId(userId);
    if (!optionalUser.isPresent()) {
        throw new UserNotFoundException("");
    }

    model.addAttribute("user", optionalUser.get());
    model.addAttribute("contentFragment", "admin-user-edit");

    return "admin";
}

根据userId查询到用户数据,并绑定到admin.html页面上。同时设置了代码片段为“admin-user-edit”。

新增代码片段admin-user-edit.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- 定义片段 -->
<div th:fragment="admin-user">
    <div class="card shadow mb-4">
        <div class="card-header py-3">
            <h2>编辑用户</h2>
        </div>
        <div class="card-body">
            <form th:action="@{/admin/user}" method="post" th:object="${user}">
                <!-- 隐藏用户ID -->
                <input type="hidden" name="userId" th:field="*{userId}">

                <div class="row">
                    <div class="col-lg-6">
                        <!-- 用户名不可编辑 -->
                        <div class="form-group">
                            <label for="username">用户名 <span class="text-danger">*</span></label>
                            <input type="text" class="form-control" id="username" name="username"
                                   th:field="*{username}" disabled>
                        </div>

                        <!-- 密码 -->
                        <div class="form-group">
                            <label for="password">密码</label>
                            <input type="password" class="form-control" id="password" name="password"
                                   th:field="*{password}" placeholder="不修改请留空">
                            <div class="small text-muted">留空则不修改密码</div>
                        </div>

                        <!-- 手机号 -->
                        <div class="form-group">
                            <label for="phone">手机号 <span class="text-danger">*</span></label>
                            <input type="text" class="form-control" id="phone" name="phone"
                                   th:field="*{phone}" placeholder="请输入手机号">
                        </div>
                    </div>
                </div>

                <!-- 操作按钮 -->
                <div class="mt-4">
                    <button type="submit" class="btn btn-primary mr-2">保存</button>
                    <button type="button" class="btn btn-secondary"
                            th:onclick="history.back()">取消
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>
</body>
</html>

运行调测

如下图15-8所示,访问编辑用户页面的效果。

图15-8 访问编辑用户页面

如下图15-9所示,是在移动设备上访问编辑用户页面。

图15-9 移动设备上访问编辑用户页面

保存编辑后的数据

当点击“保存”时,会发送保存数据到后台接口。后台控制器AdminController增加如下方法:

/**
 * 处理保存用户的请求
 */
@PostMapping("/user")
public String updateUser(@ModelAttribute User user) {
    // 判定用户是否存在,不存在则抛出异常
    Optional<User> optionalUser = userService.findByUserId(user.getUserId());
    if (!optionalUser.isPresent()) {
        throw new UserNotFoundException("");
    }

    User oldUser = optionalUser.get();

    // 更新用户
    userService.updateUserByAdmin(oldUser, user);
    return "redirect:/admin/user";
}

UserService新增如下接口:

/**
 * 管理员更新用户
 *
 * @param oldUser
 * @param user
 */
void updateUserByAdmin(User oldUser, User user);

UserServiceImpl新增如下方法:

@Override
public void updateUserByAdmin(User oldUser, User user) {
    // 更新基本信息
    oldUser.setPhone(user.getPhone());

    // 更新密码前先判定是否需要更新
    if (user.getPassword() != null && !user.getPassword().isEmpty()) {
        String encodedPassword = passwordEncoder.encode(user.getPassword());
        oldUser.setPassword(encodedPassword);
    }

    userRepository.save(oldUser);
}

3.10 设计用户管理功能的删除用户操作

“删除”按钮上设置点击事件

在admin-user.html文件的“删除”按钮上设置点击事件,以便跳转到删除请求,修改如下:

<button class="btn btn-sm btn-danger" th:onclick="deleteUser([[${user.userId}]])">
    删除
</button>

确保有一个meta标签来存储CSRF令牌:

<!-- 确保有一个meta标签来存储CSRF令牌 -->
<meta name="_csrf" th:content="${_csrf.token}"></meta>

事件处理逻辑如下:

<script th:inline="javascript">
    // 删除用户
    function deleteUser(userId) {
        // 先确认是否删除用户
        if (!confirm('确定要删除该用户吗?')) {
            return;
        }

        // 发送请求
        fetch('/admin/user/' + userId, {
            method: 'DELETE',
                // 添加请求头, 用于Spring Security CSRF
                headers: {
                    'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
                }
           })
           .then(response => {
               if (response.ok) {
                    response.json().then(data => {
                        // 从响应中获取提示信息
                        alert(data.message || '删除成功');

                        // 从响应中获取重定向URL
                        window.location.href = data.redirectUrl;
                    });
               } else  {
                   response.json().then(data => {
                       alert(data.message || '删除失败,请重试');
                   });
               }
           })
           .catch(error => {
               console.error('删除失败:', error);
               alert('删除失败,请稍后重试');
           })
    }
</script>

上述代码会向后端发送删除请求。

删除请求控制器

在AdminController中增加如下删除请求方法如下:

/**
 * 处理用户删除的请求
 */
@DeleteMapping("/user/{userId}")
public ResponseEntity<DeleteResponseDto> deleteUser(@PathVariable Long userId) {
    // 判定用户是否存在,不存在则抛出异常
    Optional<User> optionalUser = userService.findByUserId(userId);
    if (!optionalUser.isPresent()) {
        throw new UserNotFoundException("");
    }

    userService.deleteUser(userId);

    DeleteResponseDto deleteResponseDto = new DeleteResponseDto();
    deleteResponseDto.setMessage("用户删除成功");
    deleteResponseDto.setRedirectUrl("/admin/user");

    return ResponseEntity.ok(deleteResponseDto);
}

删除服务

UserRepository新增如下接口:

/**
  * 根据用户删除ID
  *
  * @param userId
  */
void deleteById(Long userId);

UserService新增如下接口:

/**
  * 删除用户
  *
  * @param userId
  */
void deleteUser(Long userId);

UserServiceImpl新增如下方法:

@Override
public void deleteUser(Long userId) {
    userRepository.deleteById(userId);
}

如下图15-10所示,是在移动设备上访问删除用户成功后的效果。

图15-10 移动设备上访问删除用户成功后的效果

3.11 其他功能的处理及安全总结、优化建议

其他功能的处理

受限于篇幅,其他功能如笔记管理、评论管理等,实现的过程与用户管理类似,这里就不再赘述。

笔记管理、评论管理等功能简单处理如下。

笔记管理定义页面片段

新增admin-note.html文件,HTML页面片段定义如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- 定义片段 -->
<div th:fragment="admin-note">
    <div class="card shadow mb-4">
        <div class="card-header py-3">
            <h2>笔记管理</h2>
        </div>
        <!-- 笔记管理 -->
        <div class="card-body">
            <p>暂未开放,敬请期待!</p>
        </div>

    </div>
</div>
</body>
</html>

评论管理定义页面片段

新增admin-note.html文件,HTML页面片段定义如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- 定义片段 -->
<div th:fragment="admin-comment">
    <div class="card shadow mb-4">
        <div class="card-header py-3">
            <h2>评论管理</h2>
        </div>
        <!-- 评论管理 -->
        <div class="card-body">
            <p>暂未开放,敬请期待!</p>
        </div>

    </div>
</div>
</body>
</html>

最终两个界面的效果如下图15-11、图15-12所示。

图15-11 笔记管理的界面效果

图15-12 评论管理的界面效果

安全总结

  1. 权限区分

    • 为管理员和普通用户设置不同的角色
  2. 密码加密

    • 在生产环境中,永远不要使用明文密码(如 {noop}
    • 使用BCrypt或Argon2等强哈希算法加密密码
    • 可以使用BCryptPasswordEncoder工具类生成加密密码:

优化建议

  1. 数据可视化:使用Chart.js或ECharts实现数据图表
  2. 搜索过滤:添加搜索和过滤功能
  3. 操作日志:记录管理员操作
  4. 批量操作:支持批量删除、审核等操作
  5. 权限细分:实现更细粒度的权限控制(如菜单权限、按钮权限)
  6. 多环境配置
    • 开发环境可以使用配置文件快速配置
    • 生产环境应使用数据库存储用户信息
  7. 安全风险
    • 配置文件中的密码可能会被意外提交到版本控制系统
    • 考虑使用环境变量或Spring Cloud Config等工具保护敏感信息
    • 限制管理员登录IP范围
    • 添加登录失败次数限制
    • 为管理员账号启用两步验证