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) 这行代码的核心作用是:
- 建立双向关联:让
Note和Like实体能够互相引用 - 定义级联行为:删除笔记时自动清理相关点赞记录
- 优化数据模型:避免数据库中冗余的关联字段
基本概念如下:
1. @OneToMany 注解
- 语义:表示一个
Note(笔记)实体可以关联多个Like(点赞)实体 - 关系方向:定义在“一”方(Note),映射到“多”方(Like)
- 默认FetchType:
LAZY(延迟加载),即访问note.getLikes()时才查询数据库
2. mappedBy 属性
- 作用:指定双向关联的反向端字段
- 原理:关联关系的控制权在
Like实体的note字段上,Note实体仅作为反向映射 - 避免冗余:防止 JPA 在数据库中生成两个关联字段(如
note_id和like_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的核心功能是阻止字段被映射到数据库表,即:
- 该字段不会在数据库表中生成对应的列;
- 数据库查询结果也不会填充该字段的值。
具体应用场景
- 临时计算字段(非持久化数据)
- 字段值由其他字段计算或拼接而来,无需存储到数据库。
- 例:用户的全名(
fullName)由firstName和lastName组合而成。
- 缓存或临时状态字段
- 用于存储对象在运行时的临时状态(如缓存数据、权限标记等),无需持久化。
@Transient
private boolean isAdmin; // 运行时根据权限判断,无需存入数据库
- 避免敏感数据存储
- 防止敏感信息(如密码明文、临时令牌)被误存入数据库。
@Transient
private String temporaryToken; // 临时令牌,不存入数据库
- 优化性能(减少数据库列)
- 当对象包含大量无需持久化的字段时,使用
@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() 方法。
安全配置
- 在 Spring Security 配置类中,进一步细化点赞API的访问权限
- 确保只有普通用户角色可以访问点赞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-* 属性的方法
- 标准方式(dataset 属性)
const element = document.querySelector('.like-btn');
const noteId = element.dataset.noteId; // 推荐方式
- 传统方式(getAttribute)
const noteId = element.getAttribute('data-note-id');
- 批量获取所有 data- 属性*
const allData = element.dataset; // 返回 DOMStringMap 对象
// 例如 data-note-id="123" 会变成 allData.noteId === "123"
5. 总结
th:data-note-id 是 Thymeleaf 中用于将后端数据注入到 HTML 元素的 data-note-id 属性的语法。其核心价值在于:
- 数据传递:实现服务器端数据与前端 DOM 的绑定
- 事件驱动:为 JavaScript 事件处理提供必要的上下文
- 解耦设计:避免在 JavaScript 中硬编码数据 ID,提高代码可维护性
在实际项目中,合理使用 data-* 属性可以简化前端与后端的数据交互流程,特别是在传统的服务端渲染项目中尤为实用。
运行调测
在首页查看笔记未点赞时效果,如下图13-1所示。
在首页查看笔记未点赞时效果,如下图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对象结构与你的代码期望不匹配导致的。具体原因如下:
-
类型不匹配:
#authentication.principal返回的是org.springframework.security.core.userdetails.User对象- 这个对象默认只有
username、password、authorities等属性,没有你期望的userId字段
-
解决方案:
- 需要自定义
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"));
}
}
关键点说明
-
继承而非替代:
- 继承
org.springframework.security.core.userdetails.User保留了 Spring Security 的默认行为 - 无需重写所有方法,只需添加需要的字段和 getter
- 继承
-
构造函数传递:
- 确保在构造函数中调用父类构造函数并传递必要参数
- 可以根据需要添加更多构造函数变体
-
安全上下文集成:
- Spring Security 会自动将自定义
UserDetails放入安全上下文中 - 在 Thymeleaf 中通过
#authentication.principal访问时,可直接获取扩展属性
- Spring Security 会自动将自定义
总结
通过继承或包装 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);
优化建议
-
性能优化:
- 使用Redis缓存点赞数,定期同步到数据库
- 实现点赞异步处理,提高响应速度
-
防刷机制:
- 添加点赞频率限制(如每分钟不超过10次)
- 记录IP地址,防止恶意刷赞
-
用户体验:
- 添加点赞动画效果
- 显示最近点赞的用户头像
-
数据统计:
- 添加点赞排行榜
- 分析用户点赞行为,提供个性化推荐
-
级联操作的风险
- 大规模删除:删除一个包含大量点赞的笔记可能导致性能问题
- 替代方案:
- 手动控制删除顺序:先删除关联的
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 在安全配置类中,配置评论模块的访问权限
安全配置
- 在 Spring Security 配置类中,进一步细化评论API的访问权限
- 确保只有普通用户角色可以访问评论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所示。
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-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-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所示的是回复列表,显示是能够可以跳转了。
2.14 最佳实践及优化建议
最佳实践
- 优先使用DTO:通过DTO控制序列化的数据,避免暴露敏感信息和循环引用
- 多级评论的实现:不管多少层,最终只呈现为两层
优化建议
-
性能优化:
- 实现评论分页加载,避免一次性加载过多评论
- 使用WebSocket实现实时评论通知
- 实现评论折叠/展开功能
-
防刷机制:
- 添加评论频率限制(如每分钟不超过5条)
- 实现评论内容敏感词过滤
-
用户体验:
- 添加评论提交中的加载状态
- 实现评论成功后的自动滚动到新评论位置
-
数据统计:
- 添加热门评论排序
- 统计用户评论活跃度
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.properties或application.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-2所示,是admin访问/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页面。
admin.html模版采用了响应式的布局,即便在移动设备上,也能能有很好的适配。如下图15-4所示,是在移动设备上访问/admin页面的效果。
点击右上角的按钮,也可以展示完整菜单,如下图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-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-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所示,是在移动设备上访问删除用户成功后的效果。
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所示。
安全总结
-
权限区分:
- 为管理员和普通用户设置不同的角色
-
密码加密:
- 在生产环境中,永远不要使用明文密码(如
{noop}) - 使用BCrypt或Argon2等强哈希算法加密密码
- 可以使用
BCryptPasswordEncoder工具类生成加密密码:
- 在生产环境中,永远不要使用明文密码(如
优化建议
- 数据可视化:使用Chart.js或ECharts实现数据图表
- 搜索过滤:添加搜索和过滤功能
- 操作日志:记录管理员操作
- 批量操作:支持批量删除、审核等操作
- 权限细分:实现更细粒度的权限控制(如菜单权限、按钮权限)
- 多环境配置:
- 开发环境可以使用配置文件快速配置
- 生产环境应使用数据库存储用户信息
- 安全风险:
- 配置文件中的密码可能会被意外提交到版本控制系统
- 考虑使用环境变量或Spring Cloud Config等工具保护敏感信息
- 限制管理员登录IP范围
- 添加登录失败次数限制
- 为管理员账号启用两步验证