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

0 阅读52分钟

1.1 基于全栈的角度进行项目需求分析与架构设计

需求分析

  • 用户模块
    • 注册功能
    • 登录功能
    • 信息管理:修改个人资料、修改密码、分页展示笔记
  • 笔记模块
    • 发布功能
    • 查询功能
    • 编辑功能
    • 删除功能
  • 文件服务器
    • 上传图片
    • 删除图片
  • 首页模块
    • 瀑布流展示笔记
    • 搜索笔记
  • 点赞模块
    • 执行点赞
    • 取消点赞
  • 评论模块
    • 增加评论
    • 删除评论
    • 回复评论
    • 删除回复
  • 后台管理模块
    • 数据看板
    • 用户管理

架构设计

三层架构。

2-1.drawio.png

1.2 如何让AI成为你的贴身编程导师?

全球人工智能市场正快速发展,中美作为主要引领者,推动技术、产品和应用的多轮驱动。大语言模型(包括多模态大模型)的崛起提升了AI能力和使用时间,而中国在应用场景方面展现了显著优势。Java程序员应如何把握时代机遇?

全球AI市场

如下图2-2所示,全球AI产业在技术、产品与应用多轮驱动推动市场不断发展,其中,中美是产业引领者。

2-2.png

  • 全球人工智能市场规模正在持续扩大,预计到2027年将迎来普适AI的时代,届时AI软件市场规模将达到1569亿美元,2028年将超2154亿美元。
  • 大型模型正在推动AI能力的提升与边界的扩展,能力水平不断提高,AI使用时间也不断增加。
  • 美国和中国已成为主要竞争主体,而国内在应用场景方面具备显著优势。

中国AI产业

如下图2-3所示,国内AI产业应用正在从百模大战向应用驱动转型,推动AI应用生态的发展。

2-3.png

  • 中国AI产业正处于快速发展的阶段,国内AI应用场景日益丰富,尤其是面向消费者(To C)的市场迅速崛起。目前国内形成了传统互联网企业、传统AI企业和初创企业为代表的三个不同背景企业组成的产业生态。
  • 本土化大模型的加速落地,显著推动了AI应用的爆发和市场规模增长,预计2028年中国AI软件市场规模将达到35.4亿美元。

处于AI时代的浪巅,Java程序员应躬身入局

要让AI成为你的贴身Java编程导师,让AI成为你的利用工具。关键在于构建高效的互动模式,充分利用AI的知识储备和即时反馈能力。以下是具体方法:

1. 明确学习目标,定向提问

  • 基础学习:针对语法细节提问,例如:"Java中==和equals()的区别是什么?请用代码示例说明"
  • 进阶提升:聚焦设计模式、性能优化等,例如:"如何用工厂模式重构这段Java代码?"
  • 项目实践:带着具体问题求助,例如:"我的Spring Boot项目启动时报错'No qualifying bean',可能的原因有哪些?"

2. 善用代码交互功能

  • 直接提供代码片段,让AI分析问题:"这段多线程代码有线程安全问题吗?如何修复?"
  • 要求AI生成示例代码并解释:"请写一个Java 8 Stream API处理集合的例子,并逐行解释"
  • 对比不同实现方案:"用for循环和递归两种方式实现斐波那契数列,各有什么优劣?"

3. 模拟实战场景

  • 让AI扮演面试官:"请出5道Java并发编程的面试题,并给出参考答案"
  • 模拟项目需求:"我需要开发一个简单的图书管理系统,用Java实现,该如何设计类结构?"
  • 代码评审:"这是我写的用户登录功能代码,请从安全性和可读性角度点评并优化"

4. 建立知识体系

  • 要求AI梳理知识框架:"请帮我整理Java集合框架的核心接口和实现类的关系"
  • 关联前后知识:"之前学了ArrayList,它和LinkedList的底层实现有什么不同?"
  • 查漏补缺:"除了try-catch-finally,Java还有哪些异常处理方式?"

5. 利用AI的迭代指导

  • 逐步深入:从"什么是泛型"到"如何实现泛型擦除的绕过"
  • 跟踪学习进度:"基于我之前问的关于HashMap的问题,现在该学习哪些相关知识点?"
  • 错题复盘:"我在这个Java考试题上答错了,能帮我分析错误原因吗?"

通过这种有针对性的互动,AI可以成为随叫随到的导师,既解决即时问题,又能帮助构建系统的Java知识体系,同时培养独立编程思维。关键是要主动思考、明确问题,并结合实践不断验证和深化理解。

6. 利用AI辅助编程工具

利用AI辅助编程工具:

  1. CodeGeeX
  2. 通义灵码

1.3 AI辅助编程工具CodeGeeX安装及使用

CodeGeeX是一款北京智谱华章科技股份有限公司出品的基于大模型的全能的智能编程助手。它可以实现代码的生成与补全、自动添加注释、代码翻译以及智能问答等功能,能够帮助开发者显著提高工作效率。CodeGeeX支持Python、Java、C++、JavaScript、Go等数十种常见编程语言,并适配Visual Studio Code及IntelliJ IDEA、PyCharm、GoLand等JetBrains IDE。CodeGeeX插件对个人用户完全免费,同时也提供面向企业的CodeGeeX私有化部署服务。

一、CodeGeeX 核心功能

  1. 智能代码生成:根据注释或代码上下文生成完整函数、类或代码块,支持多种编程场景
  2. 实时代码补全:在编写过程中提供上下文相关的代码建议,减少重复编码
  3. 代码解释:对已有代码进行逐行或整体解释,帮助理解复杂逻辑
  4. 代码翻译:在不同编程语言间进行代码转换(如Java转Python)
  5. 调试与优化:识别代码潜在问题并提供优化建议,支持单元测试生成

二、安装方法(以IntelliJ IDEA为例)

  1. 打开IntelliJ IDEA,进入File > Settings > Plugins
  2. 在搜索框输入"CodeGeeX",找到对应的插件
  3. 点击"Install"安装,等待安装完成后重启IDE,如下图2-4所示
  4. 首次使用需在IDE右侧的CodeGeeX面板中完成账号注册或登录(支持GitHub、微信等方式)

2-4.png

三、基本使用方法

  1. 代码生成

    • 编写注释描述需要实现的功能,如:
      // 实现一个Java方法,计算两个整数的最大公约数
      
    • 按下快捷键(默认Alt+\),CodeGeeX会自动生成对应的实现代码
  2. 代码补全

    • 在编写代码时,工具会自动提示可能的代码续写
    • 例如输入public static int gcd(,会自动补全参数和可能的实现逻辑
  3. 代码解释

    • 选中需要解释的代码段
    • 右键选择"CodeGeeX > 解释代码",会生成详细的代码说明
  4. 跨语言转换

    • 选中Java代码
    • 右键选择"CodeGeeX > 代码翻译",选择目标语言(如Python),即可得到转换后的代码
  5. 集成使用技巧

    • 配合Spring Boot开发时,可通过注释快速生成Controller、Service等层代码
    • 调试时,选中报错代码,使用"优化建议"功能获取修复方案
    • 在重构时,利用"代码简化"功能优化冗余代码

四、优势特点

  • 对中文注释支持友好,更符合国内开发者习惯
  • 模型针对多编程语言优化,尤其在Java企业级开发场景表现出色
  • 可离线使用(部分功能),保护代码隐私
  • 持续更新迭代,不断优化对新框架和语法的支持

通过合理使用CodeGeeX,Java开发者可以将更多精力集中在业务逻辑设计上,减少重复劳动,提升代码质量和开发效率。

1.4 AI辅助编程工具通义灵码安装及使用

通义灵码是阿里巴巴达摩院研发的AI辅助编程工具,专注于提升开发者的编码效率,尤其在Java、Python等主流编程语言以及云原生、微服务等企业级开发场景中表现出色。它能通过理解代码上下文和业务意图,提供实时补全、代码生成、智能推荐等功能,同时对阿里系技术栈(如Spring Cloud Alibaba、Dubbo等)有深度适配。

一、通义灵码核心功能

  1. 智能代码补全:根据当前编码上下文,实时提供函数、变量、语句等补全建议,支持整行或多行间的连续补全。
  2. 代码生成:通过自然语言注释或不完整代码片段,生成完整的函数、类、甚至模块代码(如自动生成Spring Boot的Controller、Service层代码)。
  3. 阿里技术栈适配:针对阿里云、钉钉开发、支付宝生态等场景提供专属代码模板和最佳实践建议。
  4. 代码优化与重构:识别冗余代码、性能问题或不符合编码规范的片段,提供优化方案和重构建议。
  5. 文档生成:自动为类、方法生成注释文档,支持JavaDoc、Python Docstring等规范。
  6. 跨语言转换:支持Java与其他语言(如Python、Go)的代码互转,便于多语言项目协作。

二、安装方法(以IntelliJ IDEA为例)

  1. 打开IntelliJ IDEA,进入 File > Settings > Plugins
  2. 在插件市场搜索“通义灵码”(或“Tongyi Lingma”),找到对应插件。
  3. 点击“Install”安装,等待安装完成后就可以直接使用,无需重启IDE,如下图2-5所示
  4. 首次使用需登录:在IDE右侧的“通义灵码”面板中,选择“登录”,支持阿里云账号、淘宝账号或钉钉扫码登录(需完成实名认证)。

2-5.png

三、基本使用方法

1. 实时代码补全

  • 编写代码时,工具会自动在光标处显示补全建议,按 Tab 键即可采纳。
  • 示例(Java):输入 public List<String> getUserNames( 时,会自动补全参数列表、返回逻辑甚至异常处理代码。

2. 通过注释生成代码

  • 编写自然语言注释描述功能,按下 Alt + \(默认快捷键)触发生成。
    // 功能:从数据库查询指定用户ID的订单列表,按创建时间倒序排列
    // 参数:userId - 用户ID;pageNum - 页码;pageSize - 每页条数
    // 返回:分页后的订单列表
    public Page<Order> getUserOrders(Long userId, int pageNum, int pageSize) {
        // 按下Alt+\后,通义灵码会生成基于MyBatis或JPA的实现代码
    }
    

3. 代码优化与解释

  • 选中需要优化的代码段,右键选择“通义灵码 > 优化代码”,工具会给出简化、性能提升或规范调整建议。
  • 选择“解释代码”可获取代码逻辑的逐行说明,适合理解复杂逻辑或第三方库代码。

4. 适配阿里技术栈的专属功能

  • 在Spring Cloud Alibaba项目中,输入 @DubboService 后,会自动补全服务暴露的配置模板。
  • 开发阿里云OSS相关功能时,可通过注释快速生成文件上传、下载的完整代码(包含签名验证、异常处理)。

四、使用技巧

  • 自定义快捷键:进入 Settings > Keymap > 通义灵码 可修改生成、补全的触发快捷键,适配个人编码习惯。
  • 隐私保护:支持本地模式(部分功能),敏感代码可在本地处理,避免上传云端。
  • 项目适配:首次打开项目时,工具会自动分析技术栈(如识别是Spring Boot还是Dubbo项目),后续建议会更精准。

通义灵码尤其适合Java开发者在企业级应用开发中提升效率,其对国内技术生态的深度适配和中文语境的理解能力,能有效减少“重复编码”和“查文档”的时间成本。

2.1 Spring Boot概述

一、什么是 Spring Boot?

Spring Boot 是由 Pivotal 团队开发的开源框架,基于 Spring 框架构建,旨在简化 Spring 应用的初始搭建和开发过程。它通过“自动配置(Auto-configuration)”和“起步依赖(Starter Dependencies)”等特性,大幅减少了传统 Spring 项目的样板代码和配置工作量,让开发者可以更专注于业务逻辑实现。

核心目标

  • 快速创建独立运行的 Spring 应用(可直接通过 java -jar 启动)。
  • 简化配置,减少 XML 或 Java 配置的样板代码。
  • 集成主流技术栈(如 Web、数据库、消息中间件等),实现“开箱即用”。
  • 内置服务器(如 Tomcat、Jetty),方便部署和测试。

二、核心特性

  1. 自动配置(Auto-configuration)
    Spring Boot 会根据项目引入的依赖(如 spring-boot-starter-web),自动为应用配置对应的 Bean(如 Tomcat 服务器、Spring MVC 组件等),避免手动编写大量配置类。

    • 示例:引入 spring-boot-starter-jdbc 后,会自动配置数据库连接池和 JdbcTemplate
    • 可通过 @Conditional 注解实现条件化配置,确保仅在需要时加载特定 Bean。
  2. 起步依赖(Starter Dependencies)
    通过“一站式”依赖管理,简化 Maven/Gradle 配置。每个 Starter 对应一类功能(如 Web、数据访问、安全等),开发者只需引入对应的 Starter,无需手动管理复杂的依赖关系。

    • 常见 Starter:
      • spring-boot-starter-web:Web 开发(含 Spring MVC、Tomcat 等)。
      • spring-boot-starter-data-jpa:JPA 数据访问。
      • spring-boot-starter-security:安全认证。
  3. Actuator(应用监控)
    内置监控端点,可实时查看应用运行状态(如健康检查、性能指标、环境变量等),方便调试和运维。

    • 端点示例:
      • /health:检查应用健康状态。
      • /metrics:查看性能指标(如内存、CPU 使用率)。
      • /env:查看环境变量和配置属性。
  4. 嵌入式服务器
    支持嵌入式 Tomcat、Jetty 或 Undertow,无需手动部署 WAR 包,直接通过可执行 JAR 运行应用。

    • 示例启动命令:
      java -jar my-spring-boot-app.jar
      
  5. 生产级特性

    • 支持配置文件(application.properties/application.yml),可轻松实现不同环境(开发、测试、生产)的配置管理。
    • 集成日志系统(默认使用 Logback,可切换为 Log4j 2)。
    • 支持外部化配置(如从环境变量、命令行参数读取配置)。

三、Spring Boot 与传统 Spring 的对比

特性传统 SpringSpring Boot
配置方式大量 XML 或 Java 配置类自动配置 + 少量自定义配置
依赖管理手动管理依赖版本和冲突Starter 自动管理依赖传递
部署方式需部署到外部服务器(如 Tomcat)内置服务器,直接运行 JAR/WAR
开发效率样板代码多,启动流程复杂快速启动,专注业务逻辑
监控与运维需集成第三方工具(如 Micrometer)内置 Actuator,开箱即用

四、应用场景

Spring Boot 适用于 各类 Java 企业级应用,尤其适合:

  • 快速原型开发:无需复杂配置即可搭建项目骨架。
  • 微服务架构:Spring Cloud 基于 Spring Boot 构建,方便实现服务治理、分布式配置等功能。
  • 中小型项目:简化开发流程,减少维护成本。
  • 云原生应用:支持容器化部署(如 Docker、Kubernetes),适配云环境。

五、核心模块

Spring Boot 中有多个模块,以下是快速概览:

1. spring-boot

核心库,提供支持 Spring Boot 其他部分的功能,包括:

  • SpringApplication 类:提供静态便捷方法,用于编写独立的 Spring 应用程序。其主要职责是创建并刷新合适的 Spring 应用上下文(ApplicationContext)。
  • 嵌入式 Web 应用:可选择容器(Tomcat、Jetty 或 Undertow)。
  • 一流的外部化配置支持
  • 便捷的应用上下文初始化器:包括对合理日志默认值的支持。

2. spring-boot-autoconfigure

Spring Boot 可根据类路径内容自动配置典型应用的大部分内容。单个 @EnableAutoConfiguration 注解会触发 Spring 上下文的自动配置。
自动配置会尝试推断用户可能需要的 Bean。例如,如果 HSQLDB 在类路径上,且用户未配置任何数据库连接,则系统可能会自动配置一个内存数据库。当用户开始定义自己的 Bean 时,自动配置会自动退出。

3. spring-boot-starters

Starter 是一组便捷的依赖描述符,可包含在应用程序中。它们为所需的所有 Spring 及相关技术提供“一站式”依赖管理,无需搜索示例代码或复制粘贴大量依赖描述符。例如,如果想使用 Spring 和 JPA 进行数据库访问,只需在项目中包含 spring-boot-starter-data-jpa 依赖即可。

4. spring-boot-actuator

Actuator 端点用于监控和与应用程序交互。Spring Boot Actuator 提供端点所需的基础设施,包含对端点的注解支持。该模块提供了许多端点,例如 HealthEndpointEnvironmentEndpointBeansEndpoint 等。

5. spring-boot-actuator-autoconfigure

该模块基于类路径内容和一组属性为 Actuator 端点提供自动配置。例如,如果 Micrometer 在类路径上,它会自动配置 MetricsEndpoint。它包含通过 HTTP 或 JMX 暴露端点的配置。与 Spring Boot 自动配置类似,当用户开始定义自己的 Bean 时,此配置会自动退出。

6. spring-boot-test

该模块包含测试应用程序时有用的核心工具和注解。

7. spring-boot-test-autoconfigure

与其他 Spring Boot 自动配置模块类似,spring-boot-test-autoconfigure 基于类路径为测试提供自动配置。它包含许多注解,可自动配置需要测试的应用程序部分。

8. spring-boot-loader

Spring Boot Loader 提供了核心技术,允许将应用程序构建为单个可通过 java -jar 启动的 JAR 文件。通常无需直接使用 spring-boot-loader,而是通过 Gradle 或 Maven 插件间接使用。

9. spring-boot-devtools

spring-boot-devtools 模块提供额外的开发时功能(如自动重启),以实现更流畅的应用程序开发体验。在运行完整打包的应用程序时,开发工具会自动禁用。

六、总结

Spring Boot 通过“约定大于配置”的理念,大幅降低了 Spring 开发的门槛,成为现代 Java 开发的事实标准。其核心优势在于快速开发低配置成本高可扩展性,尤其适合微服务和云原生场景。掌握 Spring Boot 是学习 Spring Cloud、分布式系统等进阶技术的基础。

2.2 实战:基于Spring Boot初始化仿“小红书”单体项目

初始化项目

通过 Spring Initializr(start.spring.io/)在线生成项目(选择 Java 版本、Starter 等), 如下图3-1所示。

3-1.png

添加如下Starter:

  • Spring Web
  • Thymeleaf
  • Spring Data JPA
  • MySQL Driver
  • Validation
  • Spring Security
  • Lombok
  • Spring Boot Devtools
  • Spring Boot Actuator

最终自动生成如下图3-2所示的目录结构。

3-2.png

:除了src目录下的文件及pom.xml文件,其他文件可以删除。

其中,pom.xml文件内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.5.4</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.waylau</groupId>
	<artifactId>rednote</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>rednote</name>
	<description>RedNote. 仿“小红书”项目</description>
	<url/>
	<licenses>
		<license/>
	</licenses>
	<developers>
		<developer/>
	</developers>
	<scm>
		<connection/>
		<developerConnection/>
		<tag/>
		<url/>
	</scm>
	<properties>
		<java.version>24</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-actuator</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity6</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>com.mysql</groupId>
			<artifactId>mysql-connector-j</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<annotationProcessorPaths>
						<path>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</path>
					</annotationProcessorPaths>
				</configuration>
			</plugin>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

带有 @SpringBootApplication 注解的主类(启动类)RednoteApplication代码如下:

package com.waylau.rednote;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RednoteApplication {

	public static void main(String[] args) {
		SpringApplication.run(RednoteApplication.class, args);
	}

}

应用配置文件application.properties内容如下:

spring.application.name=rednote

2.3 实战:AI辅助编程自动创建数据库连接配置

上一节所初始化的应用,因为缺乏数据库的配置,因而无法正常启动。本节将来通过AI辅助编程工具的帮助下,来生成配置文件。

初始化数据库

首先启动MySQL数据库服务器。

其次,通过MySQL客户端创建名为“rednote”新的数据库:

mysql> CREATE DATABASE rednote;
Query OK, 1 row affected (0.19 sec)

设置数据库链接

当我们试图在应用配置文件application.properties里面写下“# 数据库配置”注释的时候,AI辅助编程工具(以通义灵码为例)会自动提醒,生成推荐的配置信息,界面如下。

3-3.png

此时,只需要点击“Tab”按键,即可接收建议,形成正式的配置文件,界面如下。

3-4.png

其他配置也如法炮制。当然,也要注意甄别AI辅助编程提供的数据库信息是否准确,如果不准确则需要修正。比如数据库的密码,AI辅助编程工具是没法“猜对”的。

最终,应用配置如下:

spring.application.name=rednote

# 数据库配置
spring.datasource.url=jdbc:mysql://localhost:3306/rednote?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&useSSL=false&allowPublicKeyRetrieval=true&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=
spring.datasource.username=root
spring.datasource.password=123456

运行应用

可以在IDE中直接启动主类(main 方法),或者通过Maven打包后通过命令行运行:

mvn package
java -jar target/rednote-0.0.1-SNAPSHOT.jar

应用启动之后,访问浏览器地址:http://localhost:8080,如果能看到如下图3-5所示的界面,则证明应用启动正常。

3-5.png

3.1 模块功能概述

用户模块概述

用户模块,包括注册、登录、信息管理等功能。

具体的设计涉及:

  1. 表结构(实体)设计
  2. 后台接口设计
  3. 前台页面设计

注册表单页面功能概述

这个注册表单页面具有以下特点:

  1. 视觉风格:采用小红书的红色主色调,结合圆润的边角设计和简洁的布局,符合小红书的品牌形象。

  2. 功能完整

    • 包含用户名、手机号、验证码、密码等必填字段
    • 实现了基本的前端表单验证(如手机号格式、密码强度等)
    • 提供获取验证码的倒计时功能
    • 支持多种社交登录方式
  3. 交互体验

    • 表单元素使用圆角设计,增强视觉舒适度
    • 输入框有明确的焦点状态和错误提示
    • 按钮有悬停和点击反馈效果
    • 验证码按钮有倒计时功能,防止重复点击
  4. 响应式设计:使用 Bootstrap 的响应式布局,确保在不同设备上都有良好的显示效果。

3.2 使用Bootstrap、Font Awesome以及Thymeleaf轻松实现注册表单页面

  1. 引入 Bootstrap 和 Font Awesome 样式和脚本
  2. 使用 Bootstrap 的表单组件(Form、Input、Button 等)创建注册表单
  3. 设计表单布局,包括用户名、密码、确认密码、手机号等输入框和注册按钮
  4. 添加表单验证规则,如用户名长度、密码强度、手机号格式等
  5. 在 Thymeleaf 模板中配置静态资源路径

关键技术点说明

  1. Thymeleaf 表单绑定

    • th:object="${user}":绑定表单对象
    • th:field="*{property}":绑定表单字段,自动生成 idname 属性
  2. 错误显示机制

    • th:if="${#fields.hasErrors('fieldName')}":判断字段是否有错误
    • th:errors="*{fieldName}":显示对应字段的错误信息
    • th:errorclass="is-invalid":当字段有错误时添加 Bootstrap 的错误样式类
  3. Bootstrap 样式集成

    • is-invalid:为输入框添加红色边框
    • invalid-feedback:显示错误消息的容器

借助 Bootstrap 生成小红书风格注册表单实现方案

以下是基于 Bootstrap 实现的仿小红书注册表单页面。这个表单包含基本的用户注册字段(用户名、手机号、密码等),并融入了小红书的视觉风格和交互体验。

src/main/resources/templates目录下创建 registration-form.html 文件,内容如下:

<!DOCTYPE html>

<html lang="en">
<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.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
    <!-- 引入 Font Awesome -->
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
    <!-- 自定义样式 -->
    <style>
        body {
            background-color: #fef6f6;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }

        .form-container {
            background-color: white;
            border-radius: 16px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
            padding: 32px;
            max-width: 400px;
            margin: 0 auto;
        }

        .logo {
            text-align: center;
            margin-bottom: 32px;
        }

        .logo img {
            width: 64px;
            height: 64px;
        }

        .form-title {
            font-size: 24px;
            font-weight: 700;
            color: #333;
            margin-bottom: 24px;
            text-align: center;
        }

        .form-control {
            border-radius: 12px;
            border: 1px solid #e8e8e8;
            padding: 12px 16px;
            height: auto;
            font-size: 14px;
        }

        .form-control:focus {
            border-color: #ff2442;
            box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
        }

        .btn-primary {
            background-color: #ff2442;
            border-color: #ff2442;
            border-radius: 12px;
            padding: 12px;
            font-size: 16px;
            font-weight: 600;
            transition: all 0.3s ease;
        }

        .btn-primary:hover,
        .btn-primary:focus {
            background-color: #e61e3a;
            border-color: #e61e3a;
            box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
        }

        .btn-outline-secondary {
            border-radius: 12px;
            padding: 12px;
            font-size: 14px;
            color: #666;
            border-color: #e8e8e8;
        }

        .btn-outline-secondary:hover {
            background-color: #f8f8f8;
            border-color: #ddd;
        }

        .form-footer {
            text-align: center;
            margin-top: 24px;
            font-size: 14px;
            color: #666;
        }

        .form-footer a {
            color: #ff2442;
            text-decoration: none;
        }

        .form-footer a:hover {
            text-decoration: underline;
        }

        .divider {
            display: flex;
            align-items: center;
            margin: 24px 0;
            color: #999;
            font-size: 14px;
        }

        .divider::before,
        .divider::after {
            content: '';
            flex: 1;
            border-bottom: 1px solid #e8e8e8;
        }

        .divider::before {
            margin-right: 16px;
        }

        .divider::after {
            margin-left: 16px;
        }

        .social-login {
            display: flex;
            justify-content: center;
            gap: 24px;
            margin-top: 24px;
        }

        .social-btn {
            width: 48px;
            height: 48px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 1px solid #e8e8e8;
            transition: all 0.3s ease;
        }

        .social-btn:hover {
            background-color: #f8f8f8;
            transform: translateY(-2px);
        }

        .social-btn i {
            font-size: 20px;
            color: #666;
        }

        .policy {
            font-size: 12px;
            color: #999;
            text-align: center;
            margin-top: 16px;
        }

        .policy a {
            color: #999;
            text-decoration: underline;
        }

        .error-message {
            color: #ff2442;
            font-size: 12px;
            margin-top: 4px;
            /* display: none; */
        }
    </style>
</head>
<body class="d-flex align-items-center min-vh-100 py-4">
<div class="container">
    <div class="form-container">
        <!-- Logo -->
        <div class="logo">
            <!-- Logo图片 -->
            <img src="https://picsum.photos/64/64"  alt="Logo" class="rounded-circle">
        </div>

        <!-- 表单标题 -->
        <h2 class="form-title">欢迎注册RN</h2>

        <!-- 注册表单 -->
        <form id="registrationForm" method="post">
            <!-- 用户名 -->
            <div class="mb-3">
                <input type="text" class="form-control" id="username" placeholder="请设置用户名" required>-
                <div class="error-message" id="usernameError"></div>
            </div>

            <!-- 手机号 -->
            <div class="mb-3">
                <input type="tel" class="form-control" id="phone" placeholder="请输入手机号" required>
                <div class="error-message" id="phoneError"></div>
            </div>

            <!-- 验证码 -->
            <div class="mb-3">
                <div class="input-group">
                    <input type="text" class="form-control" id="verificationCode" placeholder="请输入验证码" required>
                    <button type="button" class="btn btn-outline-secondary" id="getCodeBtn">获取验证码</button>
                </div>
                <div class="error-message" id="codeError"></div>
            </div>

            <!-- 密码 -->
            <div class="mb-3">
                <input type="password" class="form-control" id="password" placeholder="请设置密码" required>
                <div class="error-message" id="passwordError"></div>
            </div>

            <!-- 注册按钮 -->
            <button class="btn btn-primary w-100">立即注册</button>
        </form>
        
        <!-- 已有账号 -->
        <div class="form-footer">
            已有账号?<a href="#">立即登录</a>
        </div>

        <!-- 其他登录方式 -->
        <div class="divider">
            <span>其他登录方式</span>
        </div>

        <!-- 社交登录 -->
        <div class="social-login">
            <a href="#" class="social-btn">
                <i class="fa fa-weixin"></i>
            </a>
            <a href="#" class="social-btn">
                <i class="fa fa-weibo"></i>
            </a>
            <a href="#" class="social-btn">
                <i class="fa fa-qq"></i>
            </a>
        </div>

        <!-- 用户协议、隐藏政策 -->
        <div class="policy">
            注册即表示同意<a href="#">用户协议</a><a href="#">隐藏政策</a>
        </div>
    </div>
</div>

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
<!-- ...以下省略表单验证逻辑 -->

</body>
</html>

4-1.png

使用 Thymeleaf 模拟引擎

<!DOCTYPE html>
<!-- 引入 Thymeleaf -->
<!--<html lang="en">-->
<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.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">-->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <!-- 引入 Font Awesome -->
    <!--<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">-->
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" th:href="@{/css/font-awesome.min.css}" rel="stylesheet">
    <!-- 自定义样式 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</head>
<body class="d-flex align-items-center min-vh-100 py-4">
<div class="container">
    <div class="form-container">
        <!-- Logo -->
        <div class="logo">
            <!-- Logo图片 -->
            <!-- <img src="https://picsum.photos/64/64"  alt="Logo" class="rounded-circle">-->
            <img src="https://picsum.photos/64/64"  th:src="@{/images/rn_avatar.png}" alt="Logo" class="rounded-circle">
        </div>

        <!-- 表单标题 -->
        <h2 class="form-title">欢迎注册RN</h2>

        <!-- 注册表单 -->
        <!--<form id="registrationForm" method="post">-->
        <form id="registrationForm" th:action="@{/auth/register}" th:object="${user}" method="post">
            <!-- 用户名 -->
            <div class="mb-3">
                <!-- <input type="text" class="form-control" id="username" placeholder="请设置用户名" required>-->
                <input type="text" class="form-control" id="username" placeholder="请设置用户名" th:field="*{username}" required>
                <!--<div class="error-message" id="usernameError">用户名长度应为4-20个字符</div>-->
                <div class="error-message" id="usernameError" th:errors="*{username}"></div>
            </div>

            <!-- 手机号 -->
            <div class="mb-3">
                <!-- <input type="tel" class="form-control" id="phone" placeholder="请输入手机号" required>-->
                <input type="tel" class="form-control" id="phone" name="phone" placeholder="请输入手机号" th:field="*{phone}" required>
                <!--<div class="error-message" id="phoneError">请输入正确的手机号</div>-->
                <div class="error-message" id="phoneError" th:errors="*{phone}"></div>
            </div>

            <!-- 验证码 -->
            <div class="mb-3">
                <div class="input-group">
                    <!-- <input type="text" class="form-control" id="verificationCode" placeholder="请输入验证码" required>-->
                    <input type="text" class="form-control" id="verificationCode" name="verificationCode" placeholder="请输入验证码" th:field="*{verificationCode}" required>
                    <button type="button" class="btn btn-outline-secondary" id="getCodeBtn">获取验证码</button>
                </div>
                <!--<div class="error-message" id="codeError">验证码不正确</div>-->
                <div class="error-message" id="codeError"
                     th:errors="*{verificationCode}"></div>
            </div>

            <!-- 密码 -->
            <div class="mb-3">
                <!-- <input type="password" class="form-control" id="password" placeholder="请设置密码" required>-->
                <input type="password" class="form-control" id="password" name="password" placeholder="请设置密码" th:field="*{password}" required>
                <!--<div class="error-message" id="passwordError">密码长度应为8-20个字符,包含字母和数字</div>-->
                <div class="error-message" id="passwordError"
                     th:errors="*{password}"></div>
            </div>

            <!-- 注册按钮 -->
            <button class="btn btn-primary w-100">立即注册</button>
        </form>
        
        <!-- ...为节约篇幅,此处省略非核心内容 -->
    </div>
</div>

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


<script>
//...TODO -->
</script>
</body>
</html>

上述改动点

  1. CSS、JS、字体文件、图片等都放在了src/main/resources/static目录(如下图4-3所示),以进一步优化减少网络请求。
  2. 引入了 Thymeleaf 模拟引擎,与后台模型做绑定。

4-3.png

3.3 AI加持下快速实现表单输入校验及验证码的获取

实现表单验证逻辑

<!-- 表单验证逻辑 -->
<script>
// 表单验证逻辑
document.getElementById('registrationForm').addEventListener('submit', function (event) {
    // 阻止表单提交
    event.preventDefault();

    // 验证用户名,用户名长度应为4-20个字符
    const username = document.getElementById('username').value;
    if (username.length < 4 || username.length > 20) {
        document.getElementById('usernameError').textContent = '用户名长度应为4-20个字符';
    } else {
        document.getElementById('usernameError').textContent = '';
    }

    // 验证手机号,手机号长度应为11个字符, 手机号格式为数字
    const phone = document.getElementById('phone').value;
    if (!/^[0-9]+$/.test(phone)) {
        document.getElementById('phoneError').textContent = '手机号格式为数字';
    } else {
        document.getElementById('phoneError').textContent = '';
    }
    if (phone.length !== 11) {
        document.getElementById('phoneError').textContent = '手机号长度应为11个字符';
    } else {
        document.getElementById('phoneError').textContent = '';
    }

    // 验证验证码,验证码长度应为6个字符, 验证码格式为数字
    const verificationCode = document.getElementById('verificationCode').value;
    if (!/^[0-9]+$/.test(verificationCode)) {
        document.getElementById('verificationCodeError').textContent = '验证码格式为数字';
    } else {
        document.getElementById('verificationCodeError').textContent = '';
    }
    if (verificationCode.length !== 6) {
        document.getElementById('verificationCodeError').textContent = '验证码长度应为6个字符';
    }

    // 验证密码,密码长度应为8-20个字符,密码格式为数字、字母
    const password = document.getElementById('password').value;
    if (!/^[0-9a-zA-Z]+$/.test(password)) {
        document.getElementById('passwordError').textContent = '密码格式为数字、字母';
    } else {
        document.getElementById('passwordError').textContent = '';
    }
    if (password.length < 8 || password.length > 20) {
        document.getElementById('passwordError').textContent = '密码长度应为8-20个字符';
    } else {
        document.getElementById('passwordError').textContent = '';
    }

    // 所有验证通过,提交表单
    this.submit();
});
</script>

注册表单校验效果如下图4-2所示。

4-2.png

你可以根据实际需求进一步调整样式或添加更多功能,如密码强度指示器、图形验证码等。

实现获取验证码倒计时

// 获取验证码倒计时
let countdown = 60;
let timer;
document.getElementById('getCodeBtn').addEventListener('click', function () {
    if (countdown === 60) {
        timer = setInterval(function () {
            countdown--;
            document.getElementById('getCodeBtn').textContent = countdown + '秒后重新获取';
            if (countdown === 0) {
                clearInterval(timer);
                countdown = 60;
                document.getElementById('getCodeBtn').textContent = '获取验证码';
            } else {
                document.getElementById('getCodeBtn').disabled = true;
            }
        }, 1000)
    }
})

国内CDN加速

国外的CDN服务器在国内可能访问比较慢,可以替换为国内的地址以提升访问速度。

<!-- 引入 Bootstrap CSS -->
<!--<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css"
        th:href="@{/css/bootstrap.min.css}" rel="stylesheet">-->
<!-- 替换为BootCDN -->
<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.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css"
        th:href="@{/css/font-awesome.min.css}" rel="stylesheet">-->
<!-- 替换为BootCDN -->
<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">

<!-- Bootstrap JS -->
<!--<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
        th:src="@{/js/bootstrap.bundle.min.js}"></script>-->
<!-- 替换为BootCDN -->
<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>

3.4 创建一个Spring MVC控制器类处理注册请求的方法

实现说明

  1. 控制器功能

    • showRegistrationForm:显示注册表单页面
    • processRegistrationForm:处理注册请求,包含表单验证和业务逻辑
    • showLoginForm:显示登录页面
  2. 表单验证

    • 使用 JSR-303 注解(如 @NotBlank, @Size, @Pattern)进行字段验证
    • 检查手机号和用户名是否已存在
    • 验证失败时返回错误信息到表单页面
  3. 业务逻辑

    • 密码加密存储(使用 Spring Security 的 PasswordEncoder
    • 用户信息持久化到数据库
    • 注册成功后重定向到登录页面
  4. 依赖关系

    • 需要 UserRepository 接口实现数据库操作
    • 需要 PasswordEncoder Bean 进行密码加密
    • 需要 User 实体类映射数据库表

实际项目中,你还需要添加短信验证码服务、密码加密配置、数据库配置等相关功能。这个控制器提供了基本的注册流程框架,可以根据具体需求进行扩展。

Spring MVC 注册控制器实现

以下是一个处理用户注册请求的 Spring MVC 控制器类 AuthController,包含用户注册的基本逻辑和表单验证:

package com.waylau.rednote.controller;

import com.waylau.rednote.dto.UserRegistrationDto;
import com.waylau.rednote.service.UserService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * AuthController 认证控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Controller
@RequestMapping("/auth")
public class AuthController {

    @Autowired
    private UserService userService;

    /**
     * 显示注册表单页面
     */
    @GetMapping("/register")
    public String showRegistrationForm(Model model) {
        model.addAttribute("user", new UserRegistrationDto());
        return "registration-form";
    }

    /**
     * 处理注册表单提交
     */
    @PostMapping("/register")
    public String processRegistrationForm(@Valid @ModelAttribute("user") UserRegistrationDto registrationDto,
                                          BindingResult bindingResult,
                                          Model model) {
        // 检查用户名是否已存在
        if (userService.existsByUsername(registrationDto.getUsername())) {
            bindingResult.rejectValue("username", null, "该用户名已被使用");
        }

        // 检查手机号是否已注册
        if (userService.existsByPhone(registrationDto.getPhone())) {
            bindingResult.rejectValue("phone", null, "该手机号已被注册");
        }

        // 检查手机验证码是否校验通过
        if (!userService.verifyCode(registrationDto.getPhone(), registrationDto.getVerificationCode())) {
            bindingResult.rejectValue("verificationCode", null, "验证码不正确");
        }

        // 检查用户名、手机号、验证码是否通过
        // 如果有错误,则返回注册页面
        if (bindingResult.hasErrors()) {
            model.addAttribute("user", registrationDto);
            return "registration-form";
        }

        // 注册用户
        userService.registerUser(registrationDto);

        // 注册成功,跳转到登录页面
        return "redirect:/auth/login";
    }

    /**
     * 显示登录页面
     */
    @GetMapping("/login")
    public String showLoginForm() {
        return "login";
    }
}

在 Spring MVC 中,BindingResult 用于存储表单验证的错误信息。

相关支持类

1. 用户注册 DTO

package com.waylau.rednote.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

/**
 * UserRegistrationDto 用户注册DTO
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Getter
@Setter
public class UserRegistrationDto {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 4, max = 20, message = "用户名长度应为4-20个字符")
    private String username;

    @NotBlank(message = "手机号不能为空")
    @Size(min = 11, max = 11, message = "手机号长度应为11个字符")
    @Pattern(regexp = "^[1][3,4,5,7,8][0-9]{9}$", message = "手机号格式不正确")
    private String phone;

    @NotBlank(message = "验证码不能为空")
    @Size(min = 6, max = 6, message = "验证码长度应为6个字符")
    @Pattern(regexp = "^[0-9]{6}$", message = "验证码格式不正确")
    private String verificationCode;

    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 20, message = "密码长度应为8-20个字符")
    @Pattern(regexp = "^[a-zA-Z0-9_]{8,20}$", message = "密码格式不正确")
    private String password;
}

2. 用户服务接口

package com.waylau.rednote.service;

import com.waylau.rednote.dto.UserRegistrationDto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

/**
 * UserService 用户服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
public interface UserService {
    /**
     * 检查用户名是否已存在
     */
    boolean existsByUsername(@NotBlank(message = "用户名不能为空") @Size(min = 4, max = 20, message = "用户名长度应为4-20个字符") String username);

    /**
     * 检查手机号是否已注册
     */
    boolean existsByPhone(@NotBlank(message = "手机号不能为空") @Size(min = 11, max = 11, message = "手机号长度应为11个字符") @Pattern(regexp = "^[1][3,4,5,7,8][0-9]{8}$", message = "手机号格式不正确") String phone);

    /**
     * 验证短信验证码
     */
    boolean verifyCode(@NotBlank(message = "手机号不能为空") @Size(min = 11, max = 11, message = "手机号长度应为11个字符") @Pattern(regexp = "^[1][3,4,5,7,8][0-9]{9}$", message = "手机号格式不正确") String phone, @NotBlank(message = "验证码不能为空") @Size(min = 6, max = 6, message = "验证码长度应为6个字符") @Pattern(regexp = "^[0-9]{6}$", message = "验证码格式不正确") String verificationCode);

    /**
     * 注册新用户
     */
    void registerUser(@Valid UserRegistrationDto registrationDto);
}

3.5 掌握Repository设计模式来实现UserRepository

本节内容:

  1. 用户表结构设计
  2. 创建用户实体类,使用 @Entity@Table 等注解映射到用户表
  3. 创建继承自 Repository 的UserRepository接口,用于操作用户表

用户实体

package com.waylau.rednote.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * User 用户实体
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Entity
@Table(name = "t_user")
// @Data集合了@Getter @Setter @ToString @EqualsAndHashCode
@Data
// 无参构造器
@NoArgsConstructor
// 包含所有参数的构造器
@AllArgsConstructor
public class User {
    /**
     * 用户ID
     */
    @jakarta.persistence.Id
    @jakarta.persistence.GeneratedValue(strategy = GenerationType.AUTO)
    private Long userId;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 手机号
     */
    private String phone;
}

‌GenerationType ‌是 Java Persistence API (JPA)中的一个枚举类型,用于指定数据库中自动生成主键值的策略。它包含了以下四种类型:

  • AUTO ‌:由持久化提供者自动选择生成策略,默认为此选项。根据底层数据库的支持情况,可能会选择 IDENTITY 、 SEQUENCE 或 TABLE 。
  • IDENTITY ‌:使用数据库的自增长特性生成主键值。适用于支持自增长列的数据库,如 MySQL 、 SQL Server 等。
  • SEQUENCE ‌:使用数据库的序列生成主键值。适用于支持序列的数据库,如 Oracle 、 PostgreSQL 等。
  • TABLE ‌:使用一个特定的数据库表来生成主键值。它会创建一个表来保存生成的主键值,并通过表中的行锁来保证唯一性。适用于不支持自增长列或序列的数据库‌

这些类型可以通过在实体类的主键字段上使用@GeneratedValue注解来指定。

用户资源库

package com.waylau.rednote.repository;

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

import java.util.Optional;

/**
 * UserRepository 用户资源库
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
public interface UserRepository extends Repository<User, Long> {
    /**
     * 保存用户
     *
     * @param user
     * @return
     */
    User save(User user);

    /**
     * 根据手机号查找用户
     *
     * @param phone
     * @return
     */
    Optional<User> findByPhone(String phone);

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

3.6 实现UserServiceImpl服务,调用UserRepository接口方法

用户服务实现

package com.waylau.rednote.service.impl;

import com.waylau.rednote.dto.UserRegistrationDto;
import com.waylau.rednote.entity.User;
import com.waylau.rednote.repository.UserRepository;
import com.waylau.rednote.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * UserServiceImpl 用户服务
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserRepository userRepository;

    @Override
    public boolean existsByUsername(String username) {
        return userRepository.findByUsername(username).isPresent();
    }

    @Override
    public boolean existsByPhone(String phone) {
        return userRepository.findByPhone(phone).isPresent();
    }

    @Override
    public boolean verifyCode(String phone, String verificationCode) {
        // 实际项目中会验证验证码逻辑。
        // 模拟验证码校验成功。简化处理,仅返回true
        return true;
    }

    @Override
    public void registerUser(UserRegistrationDto registrationDto) {
        // 创建新用户
        User user = new User();
        user.setUsername(registrationDto.getUsername());
        user.setPassword(registrationDto.getPassword());
        user.setPhone(registrationDto.getPhone());

        // 保存用户
        userRepository.save(user);
    }
}

3.7 创建Spring Security配置类,允许任何请求都不需要授权

创建Spring Security配置类,允许任何请求都不需要授权。

package com.waylau.rednote.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

/**
 * WebSecurityConfig 安全配置
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    // 添加安全过滤器链
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 禁用CSRF防护
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authorize -> authorize
                        // 允许所有请求不需要授权
                        .anyRequest().permitAll()
                )
                .formLogin(Customizer.withDefaults());

        return http.build();
    }
}

3.8 增加应用配实现表结构自动更新

修改应用配置

在 application.properties 文件种,增加如下配置,以便于开发、测试:

# 每次运行程序,没有表会新建表,表内有数据会清空
spring.jpa.properties.hibernate.hbm2ddl.auto=create
# 显示SQL
spring.jpa.show-sql=true

# Thymeleaf配置
spring.thymeleaf.cache=false
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8

# Spring Boot Devtools
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=true

当希望每次修改代码之后想重启应用,在上述配置基础上,IntelliJ IDEA执行构建项目(Build -> Build Project)会触发自动重启。

可选值及含义

spring.jpa.properties.hibernate.hbm2ddl.auto 是 Spring Boot 中用于配置 Hibernate 自动生成或更新数据库表结构的属性。它通过 Hibernate 的 hbm2ddl 工具在应用程序启动时自动执行 DDL(数据定义语言)操作,如创建、更新或验证数据库表结构。

  1. none

    • 作用:禁用自动 DDL 操作。
    • 适用场景:完全手动管理数据库结构(如使用 Flyway/Liquibase)。
  2. create

    • 作用:启动时删除并重新创建所有表(基于实体类映射)。
    • 数据丢失:原有数据会被清空。
    • 适用场景:仅用于开发/测试环境,尤其是内存数据库(如 H2)。
  3. create-drop

    • 作用:与 create 类似,但在应用关闭时删除所有表
    • 适用场景:临时测试或演示环境。
  4. update

    • 作用:Hibernate 会对比实体类与现有表结构,仅执行必要的变更(如添加列)。
    • 限制:不会删除未使用的列或表,复杂变更可能需手动处理。
    • 风险:生产环境慎用,可能导致数据丢失或结构不一致。
  5. validate

    • 作用:仅验证实体类与表结构是否匹配,不执行任何变更
    • 适用场景:生产环境,确保部署前结构一致。

注意事项:

  1. 生产环境风险

    • create/create-drop/update 可能导致数据丢失,生产环境建议使用数据库迁移工具(如 Flyway)。
  2. spring.jpa.hibernate.ddl-auto 的关系

    • 在 Spring Boot 中,spring.jpa.hibernate.ddl-auto 是更简洁的等效配置(底层同样设置 hbm2ddl.auto)。
  3. Hibernate 版本差异

    • 不同 Hibernate 版本对 update 的支持可能不同,复杂变更建议手动编写 SQL。
  4. 日志调试

    • 启用 Hibernate SQL 日志可查看生成的 DDL 语句:
      spring.jpa.show-sql=true
      logging.level.org.hibernate.SQL=DEBUG
      logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
      

总结

  • 开发环境updatecreate-drop(方便快速迭代)。
  • 生产环境validatenone(结合迁移工具)。
  • 关键原则:始终备份数据,避免在生产环境使用破坏性操作。

表单提交数据校验改为后台校验

因为已经有后台校验,所以可以把前台校验的内容注释掉:

// 表单验证逻辑
document.getElementById('registrationForm').addEventListener('submit', function (event) {
    // 阻止表单提交
    event.preventDefault();

    /*
    // 验证用户名,用户名长度应为4-20个字符
    const username = document.getElementById('username').value;
    if (username.length < 4 || username.length > 20) {
        document.getElementById('usernameError').textContent = '用户名长度应为4-20个字符';
    } else {
        document.getElementById('usernameError').textContent = '';
    }

    // 验证手机号,手机号长度应为11个字符, 手机号格式为数字
    const phone = document.getElementById('phone').value;
    if (!/^[0-9]+$/.test(phone)) {
        document.getElementById('phoneError').textContent = '手机号格式为数字';
    } else {
        document.getElementById('phoneError').textContent = '';
    }
    if (phone.length !== 11) {
        document.getElementById('phoneError').textContent = '手机号长度应为11个字符';
    } else {
        document.getElementById('phoneError').textContent = '';
    }

    // 验证验证码,验证码长度应为6个字符, 验证码格式为数字
    const verificationCode = document.getElementById('verificationCode').value;
    if (!/^[0-9]+$/.test(verificationCode)) {
        document.getElementById('verificationCodeError').textContent = '验证码格式为数字';
    } else {
        document.getElementById('verificationCodeError').textContent = '';
    }
    if (verificationCode.length !== 6) {
        document.getElementById('verificationCodeError').textContent = '验证码长度应为6个字符';
    }

    // 验证密码,密码长度应为8-20个字符,密码格式为数字、字母
    const password = document.getElementById('password').value;
    if (!/^[0-9a-zA-Z]+$/.test(password)) {
        document.getElementById('passwordError').textContent = '密码格式为数字、字母';
    } else {
        document.getElementById('passwordError').textContent = '';
    }
    if (password.length < 8 || password.length > 20) {
        document.getElementById('passwordError').textContent = '密码长度应为8-20个字符';
    } else {
        document.getElementById('passwordError').textContent = '';
    }
    */
    // 所有验证通过,提交表单
    this.submit();
});

运行应用进行测试

访问浏览器地址:http://localhost:8080/auth/register,开源看到如下注册界面。

4-4.png

点击“立即注册”按钮,可以在应用控制台日志里面看到如下SQL语句的执行:

Hibernate: select u1_0.user_id,u1_0.password,u1_0.phone,u1_0.username from t_user u1_0 where u1_0.username=?
Hibernate: select u1_0.user_id,u1_0.password,u1_0.phone,u1_0.username from t_user u1_0 where u1_0.phone=?
Hibernate: select next_val as id_val from t_user_seq for update
Hibernate: update t_user_seq set next_val= ? where next_val=?
Hibernate: insert into t_user (password,phone,username,user_id) values (?,?,?,?)

通过MySQL客户端工具,查询数据库用户表的数据,则可以看到查询内容如下:

mysql> SELECT * FROM t_user;
+---------+------------+-------------+----------+
| user_id | password   | phone       | username |
+---------+------------+-------------+----------+
|       1 | 12345qwert | 13711111111 | waylau   |
+---------+------------+-------------+----------+
1 row in set (0.017 sec)

因此,验证用户注册的数据已经能完整记录进数据库了。

我们可以将相同的数据再注册一遍,以验证数据校验功能是否生效。

4-5.png

如上图4-5所示,相同数据不允许再此注册,从而证明数据校验功能是生效的。

3.9 使用BCryptPasswordEncoder对用户密码进行加密和验证

BCryptPasswordEncoder 是 Spring Security 提供的一个强大密码加密工具,它使用 BCrypt 哈希算法对密码进行加密,具有自动生成盐值、自适应迭代因子等安全特性。以下是具体实现方法:

配置 BCryptPasswordEncoder Bean

在安全配置类种,注册 BCryptPasswordEncoder Bean:

package com.waylau.rednote.config;

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

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * WebSecurityConfig 安全配置类
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/05/30
**/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    // ...为节约篇幅,此处省略非核心内容

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 默认强度为10
        // 或指定强度:new BCryptPasswordEncoder(12)
    }
}

在服务层使用密码加密

修改UserServiceImpl,在用户注册时加密密码:

@Override
public User registerUser(UserRegistrationDto registrationDto) {
    // 创建新用户
    User user = new User();
    user.setUsername(registrationDto.getUsername());
    user.setPhone(registrationDto.getPhone());

    // 加密密码
    // user.setPassword(registrationDto.getPassword());
    String encodedPassword = passwordEncoder.encode(registrationDto.getPassword());
    user.setPassword(encodedPassword);

    // 保存用户
    return userRepository.save(user);
}

允许调试

启动应用后,在应用里面执行注册。

注册完成之后,通过MySQL客户端工具,查询数据库用户表的数据,则可以看到查询内容如下:

mysql> SELECT * FROM t_user;
+---------+--------------------------------------------------------------+-------------+----------+
| user_id | password                                                     | phone       | username |
+---------+--------------------------------------------------------------+-------------+----------+
|       1 | $2a$10$596iAK4CnjYZJBfssZS16uZ0vfXqer6YNnutih8MlX0wvScEF8Cby | 13711111111 | waylau   |
+---------+--------------------------------------------------------------+-------------+----------+
1 row in set (0.019 sec)

可以看到,明文输入的密码“12345qwert”被加密为密文“2a2a10$596iAK4CnjYZJBfssZS16uZ0vfXqer6YNnutih8MlX0wvScEF8Cby”保存进了数据库。

注意事项

  1. 盐值自动生成

    • BCrypt 会自动生成随机盐值,并将其包含在加密后的密码字符串中
    • 无需手动管理盐值,相同密码每次加密结果都不同
  2. 密码强度

    • 默认强度为 10,数值越大加密越慢但安全性更高
    • 可通过 new BCryptPasswordEncoder(12) 指定强度
  3. 存储要求

    • 加密后的密码长度固定为 60 个字符
    • 数据库字段应设置为 VARCHAR(60) 或更大
  4. 安全建议

    • 永远不要存储明文密码
    • 使用 HTTPS 保护密码传输
    • 定期升级 BCrypt 强度

通过 BCryptPasswordEncoder,你可以轻松实现安全的密码加密和验证机制,保护用户账户安全。

3.10 善用AI,掌握校验、数据库操作、加密及性能优化技巧

校验技巧

  1. 前端校验不可信,改为后端校验
  2. 使用Validation简化校验
  3. 善用AI,但不要迷信AI

数据库操作技巧

  1. JPA简化数据库操作
  2. 开发环境:通常使用create-drop或create、update,以便每次启动应用时都有最新的数据库结构。
  3. 测试环境:使用create-drop或create,确保每次测试都是在一个干净的环境中。
  4. 生产环境:通常使用none或validate,以避免在生产数据库中不小心更改表结构或数据。
  5. 开发、测试时可以打印SQL,以便于观察数据SQL的执行情况

加密技巧

  1. 盐值自动生成

    • BCrypt 会自动生成随机盐值,并将其包含在加密后的密码字符串中
    • 无需手动管理盐值,相同密码每次加密结果都不同
  2. 密码强度

    • 默认强度为 10,数值越大加密越慢但安全性更高
    • 可通过 new BCryptPasswordEncoder(12) 指定强度
  3. 存储要求

    • 加密后的密码长度固定为 60 个字符
    • 数据库字段应设置为 VARCHAR(60) 或更大
  4. 安全建议

    • 永远不要存储明文密码
    • 使用 HTTPS 保护密码传输
    • 定期升级 BCrypt 强度

前端性能优化

  1. 使用压缩后的CSS、JS文件
  2. LOGO等图片尺寸尽可能压缩到合适的最新尺寸。比如一张300*300分辨率的图片,占用的体积是7.97KB,经过压缩,将尺寸调整为64*64分辨率,则占用的体积为783B,仅原来的十分之一。

4.1 登录功能概述

基于 Bootstrap 和 Thymeleaf 实现的仿小红书登录表单。这个表单采用了小红书的品牌风格,包括红色主色调、简洁的布局和现代化的交互效果。

这个登录表单具有以下特点:

  1. 视觉风格

    • 采用小红书标志性的红色作为主色调
    • 圆润的边角设计和简洁的布局
    • 清晰的视觉层次和留白
  2. 功能特点

    • 支持用户名登录
    • 密码可见性切换功能
    • 记住我选项
    • 社交账号快速登录
    • 错误提示和表单验证
  3. 交互体验

    • 输入框焦点状态变化
    • 密码显示/隐藏切换动画
    • 按钮悬停效果
    • 平滑的错误提示展示
  4. 响应式设计

    • 使用 Bootstrap 的响应式布局
    • 在不同设备上都能良好展示

表单集成了 Thymeleaf 的错误处理机制,可以显示服务器端返回的登录错误信息。你可以根据实际需求进一步调整样式或添加更多功能。

4.2 使用Bootstrap、Font Awesome以及Thymeleaf轻松实现登录表单

可以在注册表单的基础上,进行代码的复用。

登录表单实现方案

创建login-form.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.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <!-- 引入 Font Awesome -->
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" th:href="@{/css/font-awesome.min.css}" rel="stylesheet">
    <!-- 自定义样式 -->
    <style>
        body {
            background-color: #fef6f6;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }

        .form-container {
            background-color: white;
            border-radius: 16px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
            padding: 32px;
            max-width: 400px;
            margin: 0 auto;
        }

        .logo {
            text-align: center;
            margin-bottom: 32px;
        }

        .logo img {
            width: 64px;
            height: 64px;
        }

        .form-title {
            font-size: 24px;
            font-weight: 700;
            color: #333;
            margin-bottom: 24px;
            text-align: center;
        }

        .form-control {
            border-radius: 12px;
            border: 1px solid #e8e8e8;
            padding: 12px 16px;
            height: auto;
            font-size: 14px;
        }

        .form-control:focus {
            border-color: #ff2442;
            box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
        }

        .btn-primary {
            background-color: #ff2442;
            border-color: #ff2442;
            border-radius: 12px;
            padding: 12px;
            font-size: 16px;
            font-weight: 600;
            transition: all 0.3s ease;
        }

        .btn-primary:hover,
        .btn-primary:focus {
            background-color: #e61e3a;
            border-color: #e61e3a;
            box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
        }

        .btn-outline-secondary {
            border-radius: 12px;
            padding: 12px;
            font-size: 14px;
            color: #666;
            border-color: #e8e8e8;
        }

        .btn-outline-secondary:hover {
            background-color: #f8f8f8;
            border-color: #ddd;
        }

        .form-footer {
            text-align: center;
            margin-top: 24px;
            font-size: 14px;
            color: #666;
        }

        .form-footer a {
            color: #ff2442;
            text-decoration: none;
        }

        .form-footer a:hover {
            text-decoration: underline;
        }

        .divider {
            display: flex;
            align-items: center;
            margin: 24px 0;
            color: #999;
            font-size: 14px;
        }

        .divider::before,
        .divider::after {
            content: '';
            flex: 1;
            border-bottom: 1px solid #e8e8e8;
        }

        .divider::before {
            margin-right: 16px;
        }

        .divider::after {
            margin-left: 16px;
        }

        .social-login {
            display: flex;
            justify-content: center;
            gap: 24px;
            margin-top: 24px;
        }

        .social-btn {
            width: 48px;
            height: 48px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 1px solid #e8e8e8;
            transition: all 0.3s ease;
        }

        .social-btn:hover {
            background-color: #f8f8f8;
            transform: translateY(-2px);
        }

        .social-btn i {
            font-size: 20px;
            color: #666;
        }

        .policy {
            font-size: 12px;
            color: #999;
            text-align: center;
            margin-top: 16px;
        }

        .policy a {
            color: #999;
            text-decoration: underline;
        }

        .error-message {
            color: #ff2442;
            font-size: 12px;
            margin-top: 4px;
        }
    </style>
</head>
<body class="d-flex align-items-center min-vh-100 py-4">
<div class="container">
    <div class="form-container">
        <!-- Logo -->
        <div class="logo">
            <img src="../static/images/rn_avatar.png" th:src="@{/images/rn_avatar.png}" alt="Logo"
                 class="rounded-circle">
        </div>

        <!-- 表单标题 -->
        <h2 class="form-title">欢迎登录RN</h2>

        <!-- 注册表单 -->
        <form id="loginForm" th:action="@{/auth/login}" th:object="${user}" method="post">
            <!-- 用户名输入框 -->
            <div class="mb-3">
                <input type="text" class="form-control" id="username" name="username" th:field="*{username}"
                       placeholder="请输入用户名" required>
                <div class="error-message" id="usernameError" th:errors="*{username}"></div>
            </div>

            <!-- 密码输入框 -->
            <div class="mb-3">
                <div class="input-group">
                    <input type="password" class="form-control" id="password" name="password" th:field="*{password}"
                           placeholder="请设置密码" required>
                    <!-- 切换密码显示模式 -->
                    <button type="button" class="btn btn-outline-secondary" id="togglePassword">
                        <i class="fa fa-eye-slash"></i>
                    </button>
                </div>

                <div class="error-message" id="passwordError" th:errors="*{password}"></div>
            </div>

            <!-- 记住我 -->
            <div class="form-check mb-3">
                <input type="checkbox" class="form-check-input" id="rememberMe">
                <label class="form-check-label" for="rememberMe">记住我</label>
            </div>

            <!--登录按钮 -->
            <button class="btn btn-primary w-100">登录</button>
        </form>


    </div>
    <!-- 忘记密码 -->
    <div class="form-footer">
        <a href="#">忘记密码</a>
    </div>

    <!-- 其他登录方式 -->
    <div class="divider">
        <span>其他登录方式</span>
    </div>

    <!-- 社交登录 -->
    <div class="social-login">
        <a href="#" class="social-btn">
            <i class="fa fa-weixin"></i>
        </a>
        <a href="#" class="social-btn">
            <i class="fa fa-weibo"></i>
        </a>
        <a href="#" class="social-btn">
            <i class="fa fa-qq"></i>
        </a>
    </div>

    <!-- 注册链接 -->
    <div class="form-footer">
        还没有账号? <a href="/auth/register" th:href="@{/auth/register}">立即注册</a>
    </div>

    <!-- 用户协议、隐藏政策 -->
    <div class="policy">
        注册即表示同意<a href="#">用户协议</a><a href="#">隐藏政策</a>
    </div>
</div>

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


<!-- TODO 表单交互逻辑 -->

</body>
</html>

5-1.png

实现表单验证逻辑

<!-- 表单交互逻辑 -->
<script>
// 表单验证逻辑
document.getElementById('loginForm').addEventListener('submit', function (event) {
    // 阻止表单提交
    event.preventDefault();

    // 验证用户名,用户名长度应为4-20个字符
    const username = document.getElementById('username').value;
    if (username.length < 4 || username.length > 20) {
        document.getElementById('usernameError').textContent = '用户名长度应为4-20个字符';
    } else {
        document.getElementById('usernameError').textContent = '';
    }

    // 验证密码,密码长度应为8-20个字符,密码格式为数字、字母
    const password = document.getElementById('password').value;
    if (!/^[0-9a-zA-Z]+$/.test(password)) {
        document.getElementById('passwordError').textContent = '密码格式为数字、字母';
    } else {
        document.getElementById('passwordError').textContent = '';
    }
    if (password.length < 8 || password.length > 20) {
        document.getElementById('passwordError').textContent = '密码长度应为8-20个字符';
    } else {
        document.getElementById('passwordError').textContent = '';
    }

    // 所有验证通过,提交表单
    this.submit();
});
</script>

登录表单校验效果如下图5-2所示。

5-2.png

实现密码显示/隐藏切换

<!-- 表单交互逻辑 -->
<script>
// ...为节约篇幅,此处省略非核心内容

// 切换密码显示模式
document.getElementById('togglePassword').addEventListener('click', function () {
    // 获取密码输入框
    const passwordInput = document.getElementById('password');

    if (passwordInput.type === 'password') {
        // 切换为明文模式
        passwordInput.type = 'text';
        this.querySelector('i').classList.remove('fa-eye-slash');
        this.querySelector('i').classList.add('fa-eye');
    } else {
        // 切换为密文模式
        passwordInput.type = 'password';
        this.querySelector('i').classList.remove('fa-eye');
        this.querySelector('i').classList.add('fa-eye-slash');
    }
})
</script>

密码显示/隐藏切换效果如下图5-2所示。

5-3.png

4.3 创建控制器方法来处理登录请求

实现说明

  1. 控制器功能

    • showLoginForm:显示登录表单,处理登录错误消息
    • processLoginForm:处理登录请求,验证用户名和密码(简化示例)
  2. 表单验证

    • 使用 @ModelAttribute 获取表单参数
    • 实际项目中可使用 @ValidBindingResult 进行更复杂的表单验证
  3. 登录处理

    • 示例中使用简单的用户名密码比对
    • 实际项目中应使用 Spring Security 进行安全认证
    • 密码应使用 BCrypt 等强哈希算法加密存储
  4. 错误处理

    • 登录失败时通过重定向传递错误参数
    • 登录页面根据错误参数显示相应错误信息
  5. 安全建议

    • 永远不要明文存储密码
    • 使用 HTTPS 保护登录过程
    • 实现密码加密和盐值处理
    • 添加 CSRF 保护(Spring Security 默认启用)

在实际项目中,建议使用 Spring Security 处理认证和授权,这样可以获得更完善的安全功能,包括密码加密、会话管理、CSRF 保护等。上面的自定义控制器示例仅适用于简单场景或学习目的。

Spring MVC 登录控制器实现

修改AuthController,增加如下代码,用于处理表单验证、用户认证和登录失败等场景:

/**
 * 显示登录页面
 */
@GetMapping("/login")
public String showLoginForm(Model model) {
   model.addAttribute("user", new UserLoginDto());
   return "login-form";
}

/**
 * 处理登录表单的提交
 */
@PostMapping("/login")
public String processLoginForm(@Valid @ModelAttribute("user") UserLoginDto loginDto,
                              BindingResult bindingResult,
                              Model model) {
   // 检查用户名是否存在
   if (!userService.existsByUsername(loginDto.getUsername())) {
      bindingResult.rejectValue("username", null, "该用户名未注册");

      model.addAttribute("user", loginDto);
      return "login-form";
   }

   // 检查密码是否正确
   if (!userService.verifyPassword(loginDto.getUsername(), loginDto.getPassword())) {
      bindingResult.rejectValue("password", null, "密码错误");

      model.addAttribute("user", loginDto);
      return "login-form";
   }

   // 登录成功,重定向到首页或
   return "redirect:/";
}

检查用户名是否已存在:

  1. UserRepository接口中添加根据用户名查询用户的方法
  2. 在控制器的登录处理方法中,调用UserRepository接口的查询方法,从数据库中获取用户信息
  3. 处理查询结果,若用户不存在则返回登录失败信息

相关支持类

1. 用户登录 DTO

package com.waylau.rednote.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

/**
 * UserLoginDto 用户登录DTO
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Getter
@Setter
public class UserLoginDto {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 4, max = 20, message = "用户名长度应为4-20个字符")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 20, message = "密码长度应为8-20个字符")
    @Pattern(regexp = "^[a-zA-Z0-9_]{8,20}$", message = "密码格式不正确")
    private String password;

    private boolean rememberMe;
}

2. 用户服务接口

在UserService中增加如下接口用于验证密码:

/**
 * 验证密码
 */
boolean verifyPassword(@NotBlank(message = "用户名不能为空") @Size(min = 4, max = 20, message = "用户名长度应为4-20个字符") String username, @NotBlank(message = "密码不能为空") @Size(min = 8, max = 20, message = "密码长度应为8-20个字符") @Pattern(regexp = "^[a-zA-Z0-9_]{8,20}$", message = "密码格式不正确") String password);

4.4 校验用户输入的密码是否正确

代码实现

@Override
public boolean verifyPassword(String username, String password) {
    boolean isMatch = false;

    // 获取已有加密密码
    if(userRepository.findByUsername(username).isPresent()) {
        User user = userRepository.findByUsername(username).get();
        String encodedPassword = user.getPassword();

        // 验证密码是否与加密后的密码匹配
        isMatch = passwordEncoder.matches(password, encodedPassword);
    }

    return isMatch;
}

PasswordEncoder的matches方法用于验证用户输入的密码是否与存储在数据库中的加密密码匹配。‌

matches方法的作用

matches方法的主要作用是比较用户输入的未加密密码与数据库中存储的加密密码是否一致。该方法接受两个参数:未加密的原始密码(rawPassword)和加密后的密码(encodedPassword),返回一个布尔值,表示两者是否匹配。如果匹配成功,返回true;否则,返回false‌。

matches方法的实现原理

在验证密码时,matches方法会使用与加密密码时相同的算法对用户输入的未加密密码进行处理,生成一个临时的加密密码。然后,将这个临时加密密码与数据库中存储的加密密码进行比较。如果两者相同,说明用户输入的密码正确‌。

输入校验改为后端验证

修改login-form.html,将前端校验的代码注释掉:

/*
// 表单提交处理
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', function(e) {
    // 实际项目中会有更多前端验证逻辑
    if (!validateForm()) {
        e.preventDefault();
    }

    loginForm.submit();
});

function validateForm() {
    let isValid = true;

    // 验证用户名
    const username = document.getElementById('username').value;
    if (username.length < 4 || username.length > 20) {
        document.getElementById('usernameError').textContent = '用户名长度应为4-20个字符';
        isValid = false;
    } else {
        document.getElementById('usernameError').textContent = '';
    }

    // 验证密码
    const password = document.getElementById('password').value;
    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,20}$/;
    if (!passwordRegex.test(password)) {
        document.getElementById('passwordError').textContent = '密码长度应为8-20个字符,包含字母和数字';
        isValid = false;
    } else {
        document.getElementById('passwordError').textContent = '';
    }


    return isValid;
}
*/

4.5 调整安全规则以支持自定义登录界面及失败处理器

  1. 在 WebSecurityConfigurerAdapter 配置类中,调整安全规则以支持登录功能
  2. 在 配置登录页面的访问权限,允许匿名访问
  3. 自定义失败处理器

Spring Security 配置优化

为了支持完整的登录功能,需要对 Spring Security 配置进行调整。以下是一个优化后的安全配置类,包含登录、注册、权限控制、启用 CSRF 保护等功能:

package com.waylau.rednote.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

/**
 * WebSecurityConfig 安全配置
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    // 添加安全过滤器链
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 启用CSRF防护
                .csrf(Customizer.withDefaults())
                .authorizeHttpRequests(authorize -> authorize
                        // 允许指定资源的请求不需要认证
                        .requestMatchers("/auth/register", "/auth/login", "/css/**", "/js/**", "/fonts/**", "/images/**", "/favicon.ico").permitAll()
                        // 其他请求需求认证
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        // 指定登录页面
                        .loginPage("/auth/login")
                        // 指定执行登录的地址
                        .loginProcessingUrl("/auth/login")
                        // 自定义失败处理器
                        .failureHandler(authenticationFailureHandler())
                        // 指定登录成功后跳转的页面
                        .defaultSuccessUrl("/")
                        .permitAll()
                );

        return http.build();
    }

    // 注册 BCryptPasswordEncoder Bean
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 构造函数可以指定密码的强度,默认是10
        return new BCryptPasswordEncoder(10);
    }

    @Bean
    public CustomAuthenticationFailureHandler authenticationFailureHandler() {
        return new CustomAuthenticationFailureHandler();
    }
}

上述代码

  • requestMatchers("/auth/login", "/auth/register", "/css/**", "/js/**", "/fonts/**", "/images/**", "/favicon.ico").permitAll(),允许指定资源的请求不需认证,包括静态资源、登录页面及注册页面
  • formLogin用于自定义表单。failureHandler用于指定登录失败的处理器。
  • csrf用于启动启用CSRF防护。

登录失败的处理器

需要配置用户认证服务来验证用户身份。以下是一个从数据库加载用户的示例:

package com.waylau.rednote.config;

import com.waylau.rednote.common.ExceptionType;
import com.waylau.rednote.common.LoginErrorType;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import java.io.IOException;

/**
 * CustomAuthenticationFailureHandler 自定义的AuthenticationFailureHandler
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private static final Logger log = LoggerFactory.getLogger(CustomAuthenticationFailureHandler.class);

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 添加自定义的逻辑,比如日志记录、设置错误消息等
        String exceptionMessage = exception.getMessage();
        log.error("Authentication failed: {}", exceptionMessage);

        if (exceptionMessage.contains(ExceptionType.USERNAME_NOT_FOUND)) {
            // 处理用户名不存在的情况
            response.sendRedirect("/auth/login?error=" + LoginErrorType.USERNAME_NOT_FOUND);
        } else if (exceptionMessage.contains(ExceptionType.INCORRECT_PASSWORD)) {
            // 处理密码错误情况
            response.sendRedirect("/auth/login?error=" + LoginErrorType.INCORRECT_PASSWORD);
        } else {
            // 处理其他异常情况
            response.sendRedirect("/auth/login?error=unknown");
        }
    }
}

失败跳转到/auth/login,会根据不同的异常类型,返回不同的错误参数。

新增src/main/java/com/waylau/rednote/common/ExceptionType.java如下:

package com.waylau.rednote.common;

/**
 * ExceptionType 异常类型
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
public class ExceptionType {
    public static final String USERNAME_NOT_FOUND = "Username not found";
    public static final String INCORRECT_PASSWORD = "Incorrect password";
}

新增src/main/java/com/waylau/rednote/common/LoginErrorType.java如下:

package com.waylau.rednote.common;

/**
 * LoginErrorType 登录错误类型
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
public class LoginErrorType {
    public static final String USERNAME_NOT_FOUND = "usernameNotFound";
    public static final String INCORRECT_PASSWORD = "incorrectPassword";
}

4.6 实现登录信息的验证

  1. 实现 UserDetailsService 接口,从数据库中加载用户信息并封装成 UserDetails 对象
  2. 使用 BCryptPasswordEncoder 对用户输入的密码和数据库中存储的加密密码进行比对
  3. 若认证成功,将用户信息存入 Spring Security 的上下文;若失败,返回相应的错误信息

实现 UserDetailsService 接口

原有的登录处理是在@PostMapping("/login") public String processLogin() 方法中处理,但使用了formLogin自定义表单之后,实际的登录处理逻辑改为了的 UserDetailsService 接口实现类处理。

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.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)
                .authorities(Collections.emptyList())
                .build();
    }
}

自定义 AuthenticationProvider

自定义 AuthenticationProvider,用于处理在登录失败时,区分区分是用户名不存在还是密码错误引发的异常。

package com.waylau.rednote.config;

import com.waylau.rednote.common.ExceptionType;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * CustomAuthenticationProvider 自定义AuthenticationProvider
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public CustomAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        // 用户不存在则抛出异常
        if (userDetails == null) {
            throw new BadCredentialsException(ExceptionType.USERNAME_NOT_FOUND);
        }

        // 密码不匹配则抛出异常
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException(ExceptionType.INCORRECT_PASSWORD);
        }

        return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

修改WebSecurityConfig注入AuthenticationProvider

package com.waylau.rednote.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

/**
 * WebSecurityConfig 安全配置
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/16
 **/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Autowired
    private UserDetailsService userDetailsService;
    
    // ...为节约篇幅,此处省略非核心内容

    @Bean
    public CustomAuthenticationProvider authenticationProvider() {
        return new CustomAuthenticationProvider(userDetailsService, passwordEncoder());
    }
}

错误信息在登录界面展示

登录失败之后,会重定向到/auth/login,并会携带错误信息,因此需要修改@GetMapping("/login")public String showLoginForm()方法如下:

/**
 * 显示登录页面
 */
@GetMapping("/login")
public String showLoginForm(Model model,
                            @RequestParam(required = false) String error,
                            @Valid @ModelAttribute("user") UserLoginDto loginDto,
                            BindingResult bindingResult) {
    /*model.addAttribute("user", new UserLoginDto());*/
    model.addAttribute("user", loginDto);

    // 处理用户名未注册的错误
    if (LoginErrorType.USERNAME_NOT_FOUND.equals(error)) {
        bindingResult.rejectValue("username", null, "该用户名未注册");

        return "login-form";
    }

    // 处理密码错误
    if (LoginErrorType.INCORRECT_PASSWORD.equals(error)) {
        bindingResult.rejectValue("password", null, "密码错误");

        return "login-form";
    }

    return "login-form";
}

上述代码:

  • 请求error用于指示不同的错误类型
  • 通过BindingResult将错误信息返回给了前台展示。

初始化首页

为了验证访问首页是否会被权限拦截,在src/main/resources/templates目录下创建index.html文件,内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RN - 首页</title>
</head>
<body>
<h1>这是首页</h1>
</body>
</html>

运行测试

首先,访问首页地址:http://localhost:8080。此时,权限系统发挥作用,页面被重定向到了登录界面,如下5-4图所示。

5-4.png

先输入一个未注册的用户名进行登录,如下5-5图所示,界面给出了错误提示“该用户名未注册”。

5-5.png

接着,输入一个已注册的用户名进行登录,但是输入错误的密码,如下5-6图所示,界面给出了错误提示“密码错误”。

5-6.png

最终,输入正确的用户名和密码进行登录,如下5-7图所示,界面重定向到了首页。

5-7.png

4.7 掌握按照角色权限控制的实现技巧

本节示例将展示如何使用 Spring Security 实现细粒度的权限控制。

  1. 根据用户角色配置不同的访问权限
  2. 使用 @PreAuthorize 注解或 hasRole 方法进行细粒度的权限控制
  3. 错误控制器来处理 403 错误请求

分离关注点:将管理员账号与普通用户分离存储

初始化后台管理界面

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>
</head>
<body>
<body>

<h1>这是后台管理</h1>

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

当访问地址:http://localhost:8080/admin,会跳转到登录界面执行登录,登录成功后会重定向到后台管理界面,如下图5-8所示。

5-8.png

我们希望后台管理界面只允许管理员登录,而如果是普通用户登录的,则不允许访问。如何实现?

根据用户角色配置不同的访问权限

修改WebSecurityConfig,在authorizeHttpRequests中增加如下配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // 启用CSRF防护
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                    // 允许指定资源的请求不需要认证
                    .requestMatchers("/auth/register", "/auth/login", "/css/**", "/js/**", "/fonts/**", "/images/**", "/favicon.ico").permitAll()
                    .requestMatchers("/error/**").permitAll()
                    // 允许ADMIN角色的用户访问 /admin/** 的资源
                    .requestMatchers("/admin/**").hasRole("ADMIN")
                    // 允许ADMIN、USER角色的用户访问 /user/** 的资源
                    .requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
                    // 其他请求需求认证
                    .anyRequest().authenticated()
            )
            // ...为节约篇幅,此处省略非核心内容
    ;
    return http.build();
}

上述代码

  • /admin/**"资源允许ADMIN角色访问
  • /user/**"资源允许ADMIN或者USER角色访问

当访问地址:http://localhost:8080/admin,会跳转到登录界面执行登录,使用普通用户登录成功后会看到如下错误界面,如下图5-9所示。

5-9.png

从上述报错信息“status=403”可以获知,是没有权限。

那么如何能够更加友好地提示“没有权限访问”呢?

仿小红书403错误页面实现

下面是一个基于 Spring Security 6.5、Thymeleaf 和 Bootstrap 实现的仿小红书风格的 403 错误页面。这个页面会在用户访问受保护资源而权限不足时显示,提供友好的提示和操作按钮。

403-error.html页面放置在src/main/resources/templates目录下,内容如下:

<!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.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}" rel="stylesheet">-->
    <!-- 替换为BootCDN -->
    <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.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css"
          th:href="@{/css/font-awesome.min.css}" rel="stylesheet">-->
    <!-- 替换为BootCDN -->
    <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 {
            background-color: #fef6f6;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }

        .error-container {
            max-width: 400px;
            margin: 0 auto;
            padding: 40px 20px;
            text-align: center;
        }

        .error-icon {
            font-size: 80px;
            color: #ff2442;
            margin-bottom: 20px;
        }

        .error-title {
            font-size: 24px;
            font-weight: 700;
            color: #333;
            margin-bottom: 10px;
        }

        .error-message {
            font-size: 16px;
            color: #666;
            margin-bottom: 30px;
        }

        .btn-primary {
            background-color: #ff2442;
            border-color: #ff2442;
            border-radius: 12px;
            padding: 12px;
            font-size: 16px;
            font-weight: 600;
            transition: all 0.3s ease;
            width: 100%;
        }

        .btn-primary:hover,
        .btn-primary:focus {
            background-color: #e61e3a;
            border-color: #e61e3a;
            box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
        }

        .back-home {
            margin-top: 20px;
            font-size: 14px;
            color: #999;
        }

        .back-home a {
            color: #ff2442;
            text-decoration: none;
        }

        .back-home a:hover {
            text-decoration: underline;
        }

        .error-image {
            width: 200px;
            height: 200px;
            margin: 0 auto 30px;
            background-color: #fff;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
        }

        .error-image img {
            width: 120px;
            height: 120px;
        }
    </style>
</head>
<body class="d-flex align-items-center min-vh-100 py-4">
<div class="container">
    <div class="error-container">
        <!-- 错误图标 -->
        <div class="error-image">
            <i class="fa fa-lock fa-5x text-danger"></i>
        </div>

        <!-- 错误标题 -->
        <h2 class="error-title">访问受限</h2>

        <!-- 错误信息 -->
        <p class="error-message">
            你没有权限访问此页面。<br>
            请检查你的权限或联系管理员。
        </p>

        <!-- 返回按钮 -->
        <button class="btn btn-primary" onclick="goBack()">返回上一页</button>

        <!-- 跳转到首页 -->
        <p class="back-home">
            <a href="/" th:href="@{/}">返回RN首页</a>
        </p>
    </div>
</div>

<!-- Bootstrap JS -->
<!--<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
        th:src="@{/js/bootstrap.bundle.min.js}"></script>-->
<!-- 替换为BootCDN -->
<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>

<script>
    // 返回按钮点击事件
    function goBack() {
        window.history.back();
    }
</script>
</body>
</html>

这个 403 错误页面具有以下特点:

  1. 视觉风格

    • 采用小红书标志性的红色作为主色调
    • 圆润的边角设计和简洁的布局
    • 清晰的视觉层次和留白
  2. 功能特点

    • 明确的错误提示信息
    • 返回上一页按钮
    • 返回首页链接
    • 响应式设计,适配各种设备
  3. 交互体验

    • 按钮悬停效果
    • 返回上一页的 JavaScript 功能
    • 平滑的视觉过渡

403 错误页面集成到 Spring Security

要将此 403 错误页面集成到 Spring Security 中,需要在安全配置中添加以下内容:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // 启用CSRF防护
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                     // ...为节约篇幅,此处省略非核心内容
                    .requestMatchers("/error/**").permitAll()
                     
                    // ...为节约篇幅,此处省略非核心内容
             )

            // 异常处理
            .exceptionHandling(exception -> exception
                    // 指定403错误页面
                    .accessDeniedPage("/error/403")
            )
    ;
    return http.build();
}

错误控制器来处理 403 错误请求

同时,需要添加一个错误控制器来处理 /error/403 请求:

package com.waylau.rednote.controller;

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

/**
 * ErrorController 错误控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/17
 **/
@Controller
@RequestMapping("/error")
public class ErrorController {
    /**
     * 返回403错误页面
     */
    @GetMapping("/403")
    public String accessDenied() {
        return "403-error";
    }
}

这样,当用户访问受保护资源而权限不足时,就会显示这个精心设计的 403 错误页面,如下图5-10所示。

5-10.png

4.8 掌握Spring Security的会话管理机制

Spring Security 提供了强大而灵活的会话管理功能,包括会话超时控制、并发会话管理、会话固定攻击防护等特性。下面我将详细介绍这些功能及其实现方式。

包括

  1. 介绍 Spring Security 的会话管理机制,会话超时、并发会话控制等
  2. 在配置类中设置会话管理的相关参数
  3. 在前端页面中显示用户的登录状态,显示用户名、提供退出登录按钮
  4. 使用 Thymeleaf 的表达式获取 Spring Security 上下文中的用户信息

Spring Security 会话管理机制详解

会话创建策略

Spring Security 提供四种会话创建策略:

  • ALWAYS:始终创建会话
  • NEVER:从不主动创建会话,但会使用已存在的会话
  • IF_REQUIRED:默认策略,仅在需要时创建会话
  • STATELESS:无状态会话,不使用任何会话存储

会话超时

  • 可通过 server.servlet.session.timeout 配置全局会话超时时间
  • 也可在 Security 配置中单独设置安全会话超时

并发会话控制

  • 限制同一用户的并发登录数量
  • 当达到最大登录数时,可以选择阻止新登录或踢掉旧会话
  • 可配置会话过期后的跳转页面

会话固定攻击防护

  • 自动检测并防止会话固定攻击
  • 默认策略是在用户登录后创建新会话

会话管理配置示例

修改SecurityConfig,增加会话管理相关配置:

import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http

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

          // 会话管理
          .sessionManagement(session -> session
                    // 会话创建策略
                    .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                    // 访问无效会话时,重定向到指定URL
                    .invalidSessionUrl("/auth/login?error=" + SESSION_INVALID)
                    // 同一用户最大会话数
                    .maximumSessions(1)
                    // 访问过期会话时,重定向到指定URL
                    .expiredUrl("/auth/login?error=" + SESSION_EXPIRED)
                    // false表示允许新登录,踢掉旧会话,旧会话会过期
                    .maxSessionsPreventsLogin(false)
                    // 会话注册表
                    .sessionRegistry(sessionRegistry())
            )
    ;

    return http.build();
}

// 会话注册表 Bean
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

其中,

  • invalidSessionUrlexpiredUrl 是用于处理会话(Session)相关问题的两个不同配置项,它们分别针对不同的会话失效场景,被重定向到指定的URL。
  • SessionRegistry 是 Spring Security 中一个核心接口,用于跟踪和管理用户会话。它在并发会话控制、会话信息查询和用户状态监控等场景中发挥着重要作用。

SessionRegistry 详解

核心接口定义

public interface SessionRegistry {
    // 获取所有已登录用户的 Principal
    List<Object> getAllPrincipals();
    
    // 获取特定用户的所有活动会话
    List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions);
    
    // 根据会话 ID 获取会话信息
    SessionInformation getSessionInformation(String sessionId);
    
    // 当会话被创建时调用
    void registerNewSession(String sessionId, Object principal);
    
    // 当会话被销毁时调用
    void removeSessionInformation(String sessionId);
    
    // 刷新特定会话的最后访问时间
    void refreshLastRequest(String sessionId);
}

主要实现类

  • SessionRegistryImpl:默认的内存实现,适用于单节点应用
  • 在分布式环境中,需要自定义实现(如基于 Redis 或数据库)

SessionRegistryImpl 内部维护两个核心数据结构:

  • ConcurrentHashMap<Object, Set<String>> principals

    • 键:用户的 Principal 对象(通常是 UserDetails
    • 值:该用户的所有活动会话 ID 集合
  • ConcurrentHashMap<String, SessionInformation> sessionIds:

    • 键:会话 ID
    • 值:对应的 SessionInformation 对象,包含:
      • 会话 ID
      • 用户 Principal
      • 创建时间
      • 最后访问时间
      • 是否过期标志

工作流程

  1. 用户登录时,registerNewSession() 被调用,记录会话信息

  2. 每次请求时,refreshLastRequest() 被调用,更新最后访问时间

  3. 会话过期或用户注销时,removeSessionInformation() 被调用

  4. 通过 getAllSessions() 可以获取特定用户的所有会话,实现并发控制

invalidSessionUrlexpiredUrl 的区别

在Spring Security中,invalidSessionUrlexpiredUrl 是用于处理会话(Session)相关问题的两个不同配置项,它们分别针对不同的会话失效场景:

1. invalidSessionUrl

  • 作用:当用户尝试访问一个无效的会话时,会被重定向到指定的URL。
  • 触发场景
    • 用户手动删除了Cookie中的JSESSIONID(或其他会话标识符)。
    • 会话因某些原因被标记为无效(如服务器重启后未持久化的会话丢失)。

2. expiredUrl

  • 作用:当会话超时(即会话过期)时,用户会被重定向到指定的URL。
  • 触发场景
    • 会话配置了超时时间(如通过server.servlet.session.timeout),且用户在超时时间内未与服务器交互。

通过合理配置这两个选项,可以为用户提供更清晰的会话失效提示(如区分“会话超时”和“会话无效”)。

会话超时配置

application.properties 中设置全局会话超时时间:

# 10分钟超时
server.servlet.session.timeout=10m  

错误信息提示

会话过期或者会话失效场景,被重定向到登录界面。为了能更好的区分这两种场景,需要做错误信息提示:

@GetMapping("/login")
public String showLoginForm(Model model,
                            @RequestParam(required = false) String error,
                            @Valid @ModelAttribute("user") UserLoginDto loginDto,
                            BindingResult bindingResult) {
    // ...为节约篇幅,此处省略非核心内容

    // 处理会话失效
    if (LoginErrorType.SESSION_INVALID.equals(error)) {
        bindingResult.rejectValue("username", null, "会话失效");

        return "login-form";
    }

    // 处理会话过期
    if (LoginErrorType.SESSION_EXPIRED.equals(error)) {
        bindingResult.rejectValue("username", null, "会话过期");

        return "login-form";
    }

    return "login-form";
}

在LoginErrorType中增加这两类常量

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

    public static final String SESSION_INVALID = "sessionInvalid";
    public static final String SESSION_EXPIRED = "sessionExpired";
}

使用 Thymeleaf 获取用户信息

Thymeleaf 提供了 sec 命名空间来方便地访问 Spring Security 上下文:

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

### 获取用户名
```html
<span sec:authentication="name">用户名</span>

获取用户角色

<div sec:authorize="hasRole('ADMIN')">
    这部分内容只有管理员能看到
</div>

<div sec:authorize="hasAnyRole('ADMIN', 'USER')">
    这部分内容管理员和普通用户能看到
</div>

判断用户是否已登录

<div sec:authorize="isAuthenticated()">
    欢迎回来,<span sec:authentication="name">用户名</span>
</div>

完整示例:显示用户信息卡片

修改index.html内容如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RN - 首页</title>
</head>
<body>
<h1>这是首页</h1>

<!-- 登录用户信息 -->
<div class="card" sec:authorize="isAuthenticated()">
    <div class="card-header">
        用户信息
    </div>
    <div class="card-body">
        <dive class="row">
            <div class="col-md-9">
                <h5 class="card-title" sec:authentication="name"></h5>
                <p class="card-text">角色: <span sec:authentication="principal.authorities">[角色]</span></p>
            </div>
        </dive>
    </div>

    <div sec:authorize="hasRole('ADMIN')">
        这部分内容只有管理员能看到
    </div>

    <div sec:authorize="hasAnyRole('ADMIN', 'USER')">
        这部分内容管理员和普通用户能看到
    </div>
</div>
</body>
</html>

登录之后访问首页,效果如下图5-11所示。

5-11.png

等到会话失效之后,再次登录首页,效果如下图5-12所示,被重定向了到登录界面。

5-12.png

如下图5-13所示是会话过期的被重定向了到登录界面的效果。

5-13.png

invalidSessionUrlexpiredUrl 同时配置时,会话过期的场景也可能被会话失效所覆盖。调测expiredUrl时,可以先去除掉invalidSessionUrl 配置。

关键配置总结

  1. 会话超时配置

    • application.properties 中设置全局超时
    • 通过 invalidSessionUrl 设置会话失效跳转页面
  2. 并发会话控制

    • 使用 maximumSessions() 设置最大会话数
    • 通过 expiredUrl() 设置会话过期跳转页面
    • 配置 sessionRegistry() 跟踪会话
  3. 前端集成

    • 使用 sec:authorize 判断权限
    • 通过 sec:authentication 获取用户信息

通过这些配置,你可以实现一个安全且用户体验良好的会话管理系统,包括会话超时提醒、并发登录控制和用户状态显示等功能。

4.9 掌握退出登录的实现技巧

在Spring Security中,退出登录(Logout)功能可以通过配置HttpSecuritylogout方法来实现。以下是一个基于Spring Security 6.5的完整示例,展示如何配置退出登录功能。

配置SecurityFilterChain

修改SecurityConfig,增加退出登录相关配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http

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

          // 注销
            .logout(logout -> logout
                    // 清理会话
                    .invalidateHttpSession( true)
                    // 清理认证信息
                    .clearAuthentication(true)
                    // 用户访问此URL时,交由Spring Security处理注销逻辑
                    .logoutUrl("/logout")
                    // 注销成功后,重定向到指定URL
                    .logoutSuccessUrl("/auth/login?error=" + LOGOUT)
                    // 删除会话Cookie
                    .deleteCookies("JSESSIONID")

            )
        )

    return http.build();
}

关键配置说明:

  • invalidateHttpSession(true):清除会话。
  • clearAuthentication("/logout"):清除认证信息。
  • logoutUrl("/logout"):指定触发退出登录的URL。用户访问此URL时,Spring Security会自动处理退出逻辑。
  • logoutSuccessUrl("/auth/login?error=logout"):退出成功后重定向的URL。通常用于显示退出成功的提示信息。
  • deleteCookies("JSESSIONID"):删除客户端Cookie中的会话ID。JSESSIONID是Tomcat默认的会话Cookie名称,根据实际使用的服务器可能不同。

创建退出登录的链接

修改Index.html页面,添加一个退出登录的按钮:

<!-- 注销 -->
<form action="/logout" th:action="@{/logout}" method="post">
    <button type="submit" class="btn btn-outline-success">退出</button>
</form>

处理退出成功后的页面

退出登录之后,被重定向到登录界面,需要做错误信息提示:

@GetMapping("/login")
public String showLoginForm(Model model,
                            @RequestParam(required = false) String error,
                            @Valid @ModelAttribute("user") UserLoginDto loginDto,
                            BindingResult bindingResult) {
    // ...为节约篇幅,此处省略非核心内容

    // 检查用户是否已注销
    if (LoginErrorType.LOGOUT.equals(error)) {
        bindingResult.rejectValue("username", null, "已注销");
        return "login-form";
    }

    return "login-form";
}

在LoginErrorType中增加LOGOUT常量:

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

    public static final String LOGOUT = "logout";
}

完整流程

  1. 用户访问受保护的资源(如/),需要登录。
  2. 用户登录成功后,访问主页或其他受保护页面。
  3. 用户点击退出登录链接(/logout)。
  4. Spring Security处理退出逻辑:
    • 使会话无效。
    • 删除会话Cookie。
    • 重定向到/auth/login?error=logout页面。

登录之后访问首页,效果如下图5-14所示。

5-14.png

如下图5-15所示是退出登录后被重定向了到登录界面的效果。

5-15.png

4.10 实现“记住我”浏览器重启后无需再次登录

在Spring Security中,“记住我”(Remember-Me)功能允许用户在关闭浏览器后重新访问网站时自动登录,而无需重新输入用户名和密码。这是通过在客户端存储一个持久化的令牌(通常是一个Cookie)来实现的,如下图。

5-16.png

以下是一个基于Spring Security 6.5的“记住我”功能示例:

配置SecurityFilterChain

首先,配置HttpSecurity以启用“记住我”功能。修改SecurityConfig,增加退出登录相关配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http

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

          // 记住我
        .rememberMe(rememberMe -> rememberMe
                // 设置记住我令牌的有效期(秒),默认是2周。以下设置1周
                .tokenValiditySeconds(60 * 60 * 24 * 7)
                // 设置用于签名令牌的密钥
                .key("rnRememberMeKey")
        )

    ;

    return http.build();
}

关键配置说明

  • tokenValiditySeconds(86400):设置“记住我”令牌的有效期(以秒为单位)。在此示例中设置为24小时(86400秒)。
  • key("rnRememberMeKey"):用于签名令牌的密钥。应确保此密钥是唯一的且保密的。

确保登录表单有“记住我”复选框

在登录页面中,需要有一个“记住我”复选框:

<!-- 记住我 -->
<div class="form-check mb-3">
    <input type="checkbox" class="form-check-input" id="rememberMe" name="remember-me">
    <label class="form-check-label" for="rememberMe">记住我</label>
</div>

注意,复选框是name必须是“remember-me”。

注意事项

  • 安全性:确保“记住我”令牌的密钥(key)是唯一的且保密的。

4.11 安全经验总结及性能优化技巧

经验总结

  1. 登录处理

    • 示例中使用简单的用户名密码比对
    • 实际项目中应使用 Spring Security 进行安全认证
    • 密码应使用 BCrypt 等强哈希算法加密存储
  2. 错误处理

    • 登录失败时通过重定向传递错误参数
    • 登录页面根据错误参数显示相应错误信息
  3. 安全处理

    • 永远不要明文存储密码
    • 实现密码加密和盐值处理
    • 添加 CSRF 保护(Spring Security 默认启用)
  4. 会话超时配置

    • application.properties 中设置全局超时
  5. 合理配置最大会话数

    • 根据业务需求设置 maximumSessions
    • 考虑是否允许新登录踢掉旧会话(maxSessionsPreventsLogin

优化建议

  1. 安全建议

    • 使用 HTTPS 保护登录过程
  2. 监控会话使用情况

    • 通过 SessionRegistry 实现会话监控仪表盘
    • 定期清理过期会话数据
  3. 分布式环境处理

    • 使用 Redis 或其他分布式存储实现 SessionRegistry
    • 考虑会话序列化和反序列化问题
  4. 性能优化

    • 在高并发场景下,注意 SessionRegistry 实现的线程安全性
    • 避免频繁查询 SessionRegistry,考虑缓存策略