SpringSecurity的使用

文章目录

  • 1.认证的两种方式的介绍
    • [1.1 基于Session的认证方式](#1.1 基于Session的认证方式)
    • [1.2 基于Token的认证方式](#1.2 基于Token的认证方式)
  • 2.简介
    • [2.1 什么是SpringSecurity](#2.1 什么是SpringSecurity)
    • [2.2 引入SpringSecurity](#2.2 引入SpringSecurity)
    • [2.3 Spring Security基本原理分析](#2.3 Spring Security基本原理分析)
      • [2.3.1 filters讲解](#2.3.1 filters讲解)
  • 3.SpringSecurity框架登录认证
    • [3.1 Spring Security基于数据库查询登录](#3.1 Spring Security基于数据库查询登录)
      • [3.1.1 Spring Security数据库登录流程分析](#3.1.1 Spring Security数据库登录流程分析)
    • [3.2 Spring Security自定义登录页](#3.2 Spring Security自定义登录页)
    • [3.3 验证码登录](#3.3 验证码登录)
    • [3.4 获取当前登录用户信息](#3.4 获取当前登录用户信息)
      • [3.4.1 在控制器中获取用户信息](#3.4.1 在控制器中获取用户信息)
      • [3.4.2 自定义 UserDetails 获取更多信息](#3.4.2 自定义 UserDetails 获取更多信息)
  • 4.SpringSecurity框架权限管理
    • [4.1 基于角色的权限管理](#4.1 基于角色的权限管理)
      • [4.1.1 权限拦截注解](#4.1.1 权限拦截注解)
      • [4.1.2 自定义无权限页面](#4.1.2 自定义无权限页面)
  • 5.前后端分离
    • [5.1 前后端分离后遇到的问题](#5.1 前后端分离后遇到的问题)
  • [6 JWT(JSON Web Token)](#6 JWT(JSON Web Token))
    • [6.1 JWT的数据结构](#6.1 JWT的数据结构)
      • [6.1.1 Header](#6.1.1 Header)
      • [6.1.2 Payload](#6.1.2 Payload)
      • [6.1.3 签名](#6.1.3 签名)
    • [6.2 JWT的使用](#6.2 JWT的使用)
      • [6.2.1 使用java-jwt](#6.2.1 使用java-jwt)
      • [6.2.2 使用hutool-jwt](#6.2.2 使用hutool-jwt)
  • 7.前端浏览器存储Token

1.认证的两种方式的介绍

1.1 基于Session的认证方式

(1)在之前的单体架构时代,我们认证成功之后都会将信息存入到Session中,然后响应给客户端的是对应的Session中数据的key,客户端会将这个key存储在cookie中,之后请求都会携带这个cookie中的信息,结构图如下:

(2)但是随着技术的更新迭代,我们在项目架构的时候更多的情况下会选择前后端分离或者分布式架构,那么在这种情况下基于session的认证方式就显露了很多的不足,列举几个明显的特点:

①cookie存储的内容有限制4k

②cookie的有效范围是当前域名下,所以在分布式环境下或者前后端分离的项目中都不适用,即使要用也会很麻烦

③服务端存储了所有认证过的用户信息,会占据服务器资源

1.2 基于Token的认证方式

(1)相较于Session对需求的兼容,基于Token的方式便是我们在当下项目中处理认证和授权的实现方式的首选了,Token的方式其实就是在用户认证成功后便把用户信息通过加密封装到了Token中,在响应客户端的时候会将Token信息传回给客户端,当下一次请求到来的时候在请求的Http请求的head的Authentication中会携带token

2.简介

2.1 什么是SpringSecurity

SpringSecurity是Spring家族中的一个安全管理框架,web应用需要进行认证和授权,认证和授权是SpringSecurity作为安全框架的核心功能

(1)认证:验证当前访问系统的是不是本系统的用户,并且确认具体是哪个用户

(2)授权:经过认证后判断当前用户是否有权限进行某个操作

2.2 引入SpringSecurity

(1)新建SpringBoot项目,勾选Spring Web(访问controller用到)和Spring Security即可创建一个简易的SpringSecurity项目,通过

(2)默认情况下,只有/login请求地址可以正常访问,其他所有controller请求地址,未登录时,会自动跳转到/login登录页面。①SpringSecurity框架启动时,会在控制台日志中输出一个UUID字符串,此字符串是SpringSecurity框架默认生成的登录密码,用户名默认是user。

②访问controller接口后,会自动跳转到登陆页面/login,输入账号密码后才可以正常访问接口

③登录成功以后,在前端的cookie中会产生sessionid,用于关联前端和后端,有了sessionid以后就不用再登录了。

④我们写的controller地址是会被拦截的,但是Spring Security框架提供的处理接口(/login, /logout)是不会被拦截的。例如:

2.3 Spring Security基本原理分析

(1)当我们第一次访问接口http://localhost:8080/hello后 -->跳转到了登录页面:http://localhost:8080/login 使用的是重定向

(2)Spring Security采用15个Filter进行过滤拦截;(基于session)

①入口在FilterChainProxy类中,打个断点即可看到filters

②代码:List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);

2.3.1 filters讲解

一、DefaultLoginPageGeneratingFilter 生成登录的页面;

(1)登录跳转地址是: /login (这是Spring Security框架提供的,不是我们写的)

(2)默认情况下,用户名是user,密码是临时生成的uuid;(来自SecurityProperties类)

①String name = "user";

②String password = UUID.randomUUID().toString();

③可以修改默认的用户名和密码,在配置文件application.properties中配置:

二、DefaultLogoutPageGeneratingFilter 生成退出的页面;

退出跳转地址是: /logout (这是Spring Security框架提供的,不是我们写的)

3.SpringSecurity框架登录认证

3.1 Spring Security基于数据库查询登录

(1)核心代码是实现UserDetailsService 接口的一个方法,实现从数据库查询用户登录;

①service去继承UserDetailsService

java 复制代码
public interface UserService extends UserDetailsService {
}

②写实现类再去implements上面的service

java 复制代码
@Service
public class UserServiceImpl implements UserService {
    @Resource
    private TUserMapper tUserMapper;
    /**
     * 该方法在spring security框架登录的时候被调用
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询数据库,查询页面中传过来的这个用户名是否在数据库中存在
        TUser tUser = tUserMapper.selectByLoginAct(username);
        if (tUser == null){
            throw new UsernameNotFoundException("登录账号不存在");
        }
        //构建一个Spring Security框架里面的User对象来返回(User是UserDetails的实现类)
        UserDetails userDetails = User.builder()
                .username(tUser.getLoginAct())
                .password(tUser.getLoginPwd())
                .authorities(AuthorityUtils.NO_AUTHORITIES).build();
        return userDetails;
    }
}

(2)去运行;

①老版本报错:java.lang.IllegalArgumentException:You have entered a password with no PasswordEncoder.

②新版本报错:You have entered a password with no PasswordEncoder. If that is your intent, it should be prefixed with {noop}.

③这是因为没有加入密码加密器导致的;创建配置类,配置加密器

(3)这时,登录之后就会走loadUserByUsername方法,经过数据库去查询了

(4)登录成功之前的sessionid和成功之后的sessionid是不一样的

3.1.1 Spring Security数据库登录流程分析

(1)访问http://localhost:8080/

(2)被spring security的filter过滤器拦截(里面有15个Filter);

(3)由于没有登录过,所以spring security就跳转到登录页(登录页是框架生成的)

(4)我们在登录页输入账号和密码去登录提交;(账号和密码是数据库的账号密码)

(5)spring security里面的UsernamePasswordAuthenticationFilter接收前端输入的账号和密码;

(6)第5步的这个filter会调用loadUserByUsername(String username)方法去数据库查询用户;

(7)从数据库查询到用户后,把用户组装成UserDetail对象,然后返回给SpringSecurity框架;

(8)第7步返回后,再回到框架的filter里面进行用户状态的判断,用户对象中默认有4个状态字段,如果这4个状态字段的值都是true,该用户才能登录,否则就是提示用户状态不正常,不能登录的(框架中实际上只判断3个状态值,那个密码是否过期没有做判断);

(9)第7步返回后,再回到框架的filter里面进行密码的匹配,如果密码匹配上了,就登录成功,否则失败;

(10)比较密码代码:

java 复制代码
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);

3.2 Spring Security自定义登录页

(1)html

(2)controller:

(3)配置

java 复制代码
 @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
        return httpSecurity.formLogin((formLogin)->{
            //框架默认接收登录的提交请求地址是/login,但是我们自定义配置后会弄丢,需要重新加回来
            formLogin.loginProcessingUrl("/login");//登录的账号密码往哪个地址提交
            //定制登录页
            formLogin.loginPage("/toLogin");
            //把默认检查所有接口的默认行为,再加回来
            }).authorizeHttpRequests((authorizeHttpRequests)->{
                authorizeHttpRequests
                // 特殊情况的设置,permitAll允许不登录就可以访问
                        .requestMatchers("/toLogin").permitAll()
                // 除了上面的,任何对后端接口的请求都需要认证(登录)后才能访问
                        .anyRequest().authenticated();
                })
                .build();
    }

3.3 验证码登录

验证码生成:https://www.hutool.cn/ (糊涂) 这个jar包提供了很多工具类;

(1)html页面添加验证码链接

(2)写对应的验证码controller接口

java 复制代码
@Controller
public class CaptchaController {
    @RequestMapping("/common/captcha")
    public void generateCaptcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //告诉浏览器,我的响应内容类型是图片
        response.setContentType("image/jpeg");
//        生成的是一个验证码的图片,我们不需要跳转页面,就是把这个生成的图片写出到
//        浏览器中就可以了,以IO流的方式写出去
//        1.生成这个验证码图片
        CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(150, 30, 4, 10, 1);
//        2.把这个图片里面的验证码字符串在后端中保存起来,因为后续前端提交过来,要在后端验证提交的验证码对不对
        request.getSession().setAttribute("captcha",captcha.getCode());
//        3.把生成的验证码图片以io流的方式写出去(写到浏览器)
        captcha.write(response.getOutputStream());
    }
}

3.4 获取当前登录用户信息

在 Spring Security 中,只有用户通过认证后才能获取到登录用户信息

3.4.1 在控制器中获取用户信息

(1)使用 Principal 参数

java 复制代码
@GetMapping("/user")
public String getUserInfo(Principal principal) {
    String username = principal.getName();
    return "当前用户: " + username;
}

(2)使用 Authentication 参数

java 复制代码
@GetMapping("/user")
public String getUserInfo(Authentication authentication) {
    String username = authentication.getName();
    Object principal = authentication.getPrincipal();
    Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
    
    return "用户: " + username + ", 权限: " + authorities;
}

(3)使用 SecurityContextHolder(最常用)

java 复制代码
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;

@GetMapping("/user")
public String getCurrentUser() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    
    if (authentication == null || !authentication.isAuthenticated()) {
        return "用户未登录";
    }
    
    String username = authentication.getName();
    
    // 获取 UserDetails(如果有)
    Object principal = authentication.getPrincipal();
    if (principal instanceof UserDetails) {
        UserDetails userDetails = (UserDetails) principal;
        String password = userDetails.getPassword(); // 通常是加密的
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
        
        return "用户: " + userDetails.getUsername() + 
               ", 权限: " + authorities;
    } else if (principal instanceof String) {
        // 或者直接是用户名(字符串形式)
        return "用户: " + principal;
    }
    
    return "用户名: " + username;
}

3.4.2 自定义 UserDetails 获取更多信息

如果你的用户信息包含更多字段,可以自定义 UserDetails

java 复制代码
@Data
public class TUser implements Serializable, UserDetails {
    /**
     * 主键,自动增长,用户ID
     */
    private Integer id;

    /**
     * 登录账号
     */
    private String loginAct;

    /**
     * 登录密码
     */
    private String loginPwd;

    /**
     * 用户姓名
     */
    private String name;

    /**
     * 用户手机
     */
    private String phone;

    /**
     * 用户邮箱
     */
    private String email;

    /**
     * 账户是否没有过期,0已过期 1正常
     */
    private Integer accountNoExpired;

    /**
     * 密码是否没有过期,0已过期 1正常
     */
    private Integer credentialsNoExpired;

    /**
     * 账号是否没有锁定,0已锁定 1正常
     */
    private Integer accountNoLocked;

    /**
     * 账号是否启用,0禁用 1启用
     */
    private Integer accountEnabled;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 创建人
     */
    private Integer createBy;

    /**
     * 编辑时间
     */
    private Date editTime;

    /**
     * 编辑人
     */
    private Integer editBy;

    /**
     * 最近登录时间
     */
    private Date lastLoginTime;

    private static final long serialVersionUID = 1L;
//实现UserDetails接口中的7个方法
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return this.loginPwd;
    }

    @Override
    public String getUsername() {
        return this.loginAct;
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNoExpired == 1;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNoLocked == 1;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNoExpired == 1;
    }

    @Override
    public boolean isEnabled() {
        return this.accountEnabled == 1;
    }
}
java 复制代码
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询数据库,查询页面中传过来的这个用户名是否在数据库中存在
        TUser tUser = tUserMapper.selectByLoginAct(username);
        if (tUser == null){
            throw new UsernameNotFoundException("登录账号不存在");
        }
        return tUser;
    }

4.SpringSecurity框架权限管理

(1)基于角色的权限控制(Role-Based Access Control, RBAC)是 Spring Security 中最常用的权限管理方式。

RBAC基本模型为:用户(User) → 角色(Role) → 权限(Permission/Authority)

4.1 基于角色的权限管理

4.1.1 权限拦截注解

(1)@PreAuthorize 在方法调用前进行权限检查;(常用)、@PostAuthorize 在方法调用后进行权限检查;(很少用)

(2)上面的两个注解如果要使用的话必须加上@EnableMethodSecurity(prePostEnabled = true);一般在spring security的配置类上加上即可。

(3)hasRole、hasAnyRole、hasAuthority和hasAnyAuthority的使用

java 复制代码
@RestController
@RequestMapping("/api")
@EnableMethodSecurity(prePostEnabled = true) // 启用方法安全
public class UserController {
    
    // 1. 使用 hasRole()
    @GetMapping("/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // 检查是否有 ROLE_ADMIN
    public List<User> getAllUsers() {
        return userService.findAll();
    }
    
    // 2. 使用 hasAnyRole() - 多个角色
    @GetMapping("/users")
    @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
    public List<User> getUsers() {
        return userService.findActiveUsers();
    }
    
    // 3. 使用 hasAuthority()
    @PostMapping("/users")
    @PreAuthorize("hasAuthority('USER_CREATE')")  // 检查具体权限
    public User createUser(@RequestBody User user) {
        return userService.create(user);
    }
    
    // 4. 使用 hasAnyAuthority()
    @PutMapping("/users/{id}")
    @PreAuthorize("hasAnyAuthority('USER_UPDATE', 'USER_MANAGE')")
    public User updateUser(@PathVariable Long id, @RequestBody User user) {
        return userService.update(id, user);
    }
    
    // 5. 混合使用
    @DeleteMapping("/users/{id}")
    @PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_DELETE')")
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }
    
    // 6. 复杂表达式
    @GetMapping("/users/{id}")
    @PreAuthorize("hasAuthority('USER_READ') and " +
                 "(hasRole('ADMIN') or @securityService.isOwner(#id, authentication))")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

4.1.2 自定义无权限页面

没有权限的话,页面显示403页面的不够美观。我们在resources/static/error/建立403.html文件,框架即可自动使用。

5.前后端分离

5.1 前后端分离后遇到的问题

(1)前后端分离之前,我们需要验证token的。前后端分离之后,像前面这样写的是拿不到token的。

(2)拿不到后,我们要禁用csrf跨站请求伪造。不过禁用之后,肯定就不安全了,有csrf网络攻击的风险。但是我们可以加入jwt进行防御来解决

(3)前后端分离以后还有遇到跨域问题

(协议不同会跨域 https://localhost:8080http://localhost:8080

端口不同会跨域:http://localhost:10492http://localhost:8080

域名不同会跨域:http://bjpowernode.comhttp://baidu.com

下面是解决跨域的方法

(4)前端再用Ajax发送数据时,Spring Security期望 /login是表单格式的数据

(5)前后端分离后就不能直接写登录成功返回的页面了,配置类中要添加登录成功后的handler来返回给前端

6 JWT(JSON Web Token)

(1)JWT(JSON Web Token)是一种开放的行业标准(RFC 7519),用于安全地双方之间传输信息,常用于各方之间传输信息,特别是在身份认证领域使用非常广泛;

(2)官网:https://jwt.io/

JWT 在 Spring Security 中扮演着 无状态认证机制 的核心角色,解决了传统 Session 机制在多服务、分布式环境中的问题。

(3)主要作用

1.认证(Authentication)

①身份验证:验证用户身份,确认"你是谁"

②Token 生成:登录成功后颁发包含用户信息的 JWT

③无状态认证:服务器不需要存储 Session,每次请求都携带完整的认证信息

2.授权(Authorization)

①权限信息携带:在 JWT 载荷中包含用户角色和权限信息

②访问控制:Spring Security 可以根据 JWT 中的角色/权限进行权限检查

③资源访问验证:确保用户只能访问自己有权限的资源

3.信息传递

①携带用户上下文:在各个微服务间传递用户身份信息

②减少数据库查询:用户基本信息可直接从 Token 解码获取,无需频繁查询数据库

6.1 JWT的数据结构

(1)JWT的数据结构由三部分组成,用点号 . 分隔,

(2)具体格式:头部分.载荷部分.签名部分

(3)注意,JWT内部是没有换行的,这里只是为了便于展示,将它写成了几行;JWT的三个部分依次是:

①Header(头部)

②Payload(负载) 这里面可以携带业务数据(比如一些参数)

③Signature(签名)

6.1.1 Header

(1)声明算法和令牌类型,Header部分原文是一个JSON对象,描述JWT的元数据,通常如下:

javascript 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

(2)其中alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);

(3)typ属性表示这个令牌(token)的类型(type),JWT令牌统一写为JWT;

(4)最后,将上面的JSON对象使用Base64URL算法转成字符串,就得到Header部分;

6.1.2 Payload

(1)Payload 部分原文也是一个JSON对象,用来存放实际需要传递的数据,JWT定义了7个官方字段供选用:

iss (issuer):签发人

exp (expiration time):过期时间

sub (subject):主题

aud (audience):受众

nbf (Not Before):生效时间

iat (Issued At):签发时间

jti (JWT ID):编号

(2)但是我们可以不使用官方的字段,我们可以使用任何字段来传递数据,比如:

javascript 复制代码
{
  "number": "1234567890",
  "name": "cat",
   "phone": "13700000000"
}

(3)这个 JSON 对象也要使用 Base64URL 算法转成字符串;注意,Base64URL 算法不是加密算法,它是编码算法,是可以解码出原文的,也就是JWT负载中的数据任何人都可以解码得到原文(不安全),所以不要把私密信息(密码,验证码等)放在这个部分;(虽然可以解码出来,但是我们把密码比如加密之后再放在负载里面,也是没有问题,是安全的)

6.1.3 签名

(1)验证令牌完整性和真实性。

(2)首先,需要指定一个密钥(secret),这个密钥只有服务器才知道,不能泄露给用户,然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名:

javascript 复制代码
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

6.2 JWT的使用

6.2.1 使用java-jwt

(1)添加jwt的依赖,使用

handlebars 复制代码
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>
java 复制代码
package com.bjpowernode.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.HashMap;
import java.util.Map;

public class JWTUtil {
    //密钥不能让别人知道,这个密钥一般放在服务器上
    public static final String secret = "e1r23r23r2;3r32r";
    //1.生成jwt字符串
    public static String createToken(String userJson) {
        //组装头数据
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256");
        header.put("typ", "JWT");
        return JWT.create()
                //头
                .withHeader(header)
                //负载(自定义数据)
                .withClaim("user", userJson)
                .withClaim("phone",123)
                //签名算法
                .sign(Algorithm.HMAC256(secret));
    }
    /**
     * 2.验证JWT有没有被篡改
     */
    public static Boolean verifyToken(String token) {
        try {
            // 使用秘钥创建一个jwt验证对象
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
            //使用验证器对象验证JWT,如果验证没有抛出异常说明验证通过,反之就是没有通过
            jwtVerifier.verify(token);
            //如果验证没有抛出异常,返回true表示验证通过
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
    /**
     * 3.解析出JWT里面的负载数据
     */
    public static void parseToken(String token) {
        try {
            // 使用秘钥创建一个jwt验证器对象
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
            //验证JWT,得到一个解码后的jwt对象
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            //通过解码后的jwt对象获取负载的数据
            Claim user = decodedJWT.getClaim("user");
            System.out.println("user:"+user);
            Claim phone = decodedJWT.getClaim("phone");
            System.out.println("phone:"+phone);
        } catch (TokenExpiredException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

6.2.2 使用hutool-jwt

java 复制代码
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-jwt</artifactId>
    <version>5.8.32</version>
</dependency>

7.前端浏览器存储Token

localStorage 和 sessionStorage 都是浏览器的 Web Storage API,用于在客户端存储数据,但它们有几个关键区别:

相关推荐
仙俊红2 小时前
Java Map 家族核心解析
java·开发语言
一嘴一个橘子2 小时前
springMvc 接收参数、cookie、header
java
code_li3 小时前
聊聊支付宝架构
java·开发语言·架构
CC.GG4 小时前
【Linux】进程概念(五)(虚拟地址空间----建立宏观认知)
java·linux·运维
以太浮标4 小时前
华为eNSP模拟器综合实验之- AC+AP无线网络调优与高密场景
java·服务器·华为
Mr__Miss5 小时前
JAVA面试-框架篇
java·spring·面试
小马爱打代码5 小时前
SpringBoot:封装 starter
java·spring boot·后端
STARSpace88885 小时前
SpringBoot 整合个推推送
java·spring boot·后端·消息推送·个推
码农幻想梦5 小时前
实验八 获取请求参数及域对象共享数据
java·开发语言·servlet