从零搭建微服务项目Pro(第6-1章——Spring Security+JWT实现用户鉴权访问与token刷新)
前言:
在现代的微服务架构中,用户鉴权和访问控制是非常重要的一部分。Spring Security 是 Spring 生态中用于处理安全性的强大框架,而 JWT(JSON Web Token)则是一种轻量级的、自包含的令牌机制,广泛用于分布式系统中的用户身份验证和信息交换。
本章实现了一个门槛极低的Spring Security+JWT实现用户鉴权访问与token刷新demo项目。具体效果可看测试部分内容。
只需要创建一个spring-boot项目,导入下文pom依赖以及项目结构如下,将各类的内容粘贴即可。(不需要nacos、数据库等配置,也不需要动yml配置文件。且用ai生成了html网页,减去了用postman测试接口的麻烦)。
也可直接选择下载项目源码,链接如下:
wlf728050719/SpringCloudPro6-1https://github.com/wlf728050719/SpringCloudPro6-1
以及本专栏会持续更新微服务项目,每一章的项目都会基于前一章项目进行功能的完善,欢迎小伙伴们关注!同时如果只是对单章感兴趣也不用从头看,只需下载前一章项目即可,每一章都会有前置项目准备部分,跟着操作就能实现上一章的最终效果,当然如果是一直跟着做可以直接跳过这一部分。专栏目录链接如下,其中Base篇为基础微服务搭建,Pro篇为复杂模块实现。
从零搭建微服务项目(全)-CSDN博客https://blog.csdn.net/wlf2030/article/details/145799620
依赖:
<?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.4.3</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>cn.bit</groupId><artifactId>Pro6_1</artifactId><version>0.0.1-SNAPSHOT</version><name>Pro6_1</name><description>Pro6_1</description><properties><java.version>17</java.version></properties><dependencies><!-- Web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Security --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!-- OAuth2 Authorization Server (Spring Boot 3.x 推荐) --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-authorization-server</artifactId></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><scope>provided</scope></dependency><!-- JWT --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.1</version></dependency><dependency><groupId>org.glassfish.jaxb</groupId><artifactId>jaxb-runtime</artifactId><version>2.3.1</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
核心:
工具类:
SaltUtil,用于生成随机盐。(不过由于本章没有将用户账号密码等信息存放在数据库,在代码中写死用户信息,所以这个工具类实际没有作用)。
package cn.bit.pro6_1.core.util;import java.security.SecureRandom;
import java.util.Base64;/*** 盐值工具类* @author muze*/
public class SaltUtil {/*** 生成盐值* @return 盐值*/public static String generateSalt() {// 声明并初始化长度为16的字节数组,用于存储随机生成的盐值byte[] saltBytes = new byte[16];// 创建SecureRandom实例,用于生成强随机数SecureRandom secureRandom = new SecureRandom();// 将随机生成的盐值填充到字节数组secureRandom.nextBytes(saltBytes);// 将字节数组编码为Base64格式的字符串后返回return Base64.getEncoder().encodeToString(saltBytes);}
}
JwtUtil,用于生成和验证token。(密钥为了不写配置文件就直接写代码里了,以及设置access token和refresh token失效时间为10s和20s方便测试)
package cn.bit.pro6_1.core.util;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;@Component
public class JwtUtil {private String secret = "wlf18086270070";private final Long accessTokenExpiration = 10L; // 1 小时private final Long refreshTokenExpiration = 20L; // 7 天public String generateAccessToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();return createToken(claims, userDetails.getUsername(), accessTokenExpiration);}public String generateRefreshToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();return createToken(claims, userDetails.getUsername(), refreshTokenExpiration);}private String createToken(Map<String, Object> claims, String subject, Long expiration) {return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())).setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)).signWith(SignatureAlgorithm.HS256, secret).compact();}public Boolean validateToken(String token, UserDetails userDetails) {final String username = extractUsername(token);return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));}public String extractUsername(String token) {return extractClaim(token, Claims::getSubject);}public Date extractExpiration(String token) {return extractClaim(token, Claims::getExpiration);}private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {final Claims claims = extractAllClaims(token);return claimsResolver.apply(claims);}private Claims extractAllClaims(String token) {return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}private Boolean isTokenExpired(String token) {return extractExpiration(token).before(new Date());}public Date getAccessTokenExpiration() {return new Date(System.currentTimeMillis() + accessTokenExpiration * 1000);}public Date getRefreshTokenExpiration() {return new Date(System.currentTimeMillis() + refreshTokenExpiration * 1000);}
}
SecurityUtils,方便全局接口获取请求的用户信息。
package cn.bit.pro6_1.core.util;import lombok.experimental.UtilityClass;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;/*** 安全工具类** @author L.cm*/
@UtilityClass
public class SecurityUtils {/*** 获取Authentication*/public Authentication getAuthentication() {return SecurityContextHolder.getContext().getAuthentication();}/*** 获取用户* @param authentication* @return HnqzUser* <p>*/public User getUser(Authentication authentication) {if (authentication == null || authentication.getPrincipal() == null) {return null;}Object principal = authentication.getPrincipal();if (principal instanceof User) {return (User) principal;}return null;}/*** 获取用户*/public User getUser() {Authentication authentication = getAuthentication();return getUser(authentication);}
}
用户加载:
UserService,模拟数据库中有admin和buyer两个用户密码分别为123456和654321
package cn.bit.pro6_1.core.service;import cn.bit.pro6_1.core.util.SaltUtil;
import cn.bit.pro6_1.pojo.UserPO;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;import java.util.ArrayList;
import java.util.List;@Service
@AllArgsConstructor
public class UserService implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//模拟通过username通过feign拿取到了对应用户UserPO user;BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();if (username.equals("admin")) {user = new UserPO();user.setUsername(username);user.setPassword(encoder.encode("123456"));user.setRoles("ROLE_ADMIN");user.setSalt(SaltUtil.generateSalt());}else if(username.equals("buyer")){user = new UserPO();user.setUsername(username);user.setPassword(encoder.encode("654321"));user.setRoles("ROLE_BUYER");user.setSalt(SaltUtil.generateSalt());}elsethrow new UsernameNotFoundException("not found");//模拟通过role从数据库字典项中获取对应角色权限,暂不考虑多角色用户List<GrantedAuthority> authorities = new ArrayList<>();authorities.add(new SimpleGrantedAuthority(user.getRoles()));//先加入用户角色//加入用户对应角色权限if(user.getRoles().contains("ROLE_ADMIN")){authorities.add(new SimpleGrantedAuthority("READ"));authorities.add(new SimpleGrantedAuthority("WRITE"));}else if(user.getRoles().contains("ROLE_BUYER")){authorities.add(new SimpleGrantedAuthority("READ"));}return new User(user.getUsername(), user.getPassword(),authorities);}
}
过滤器:
JwtRequestFilter,用户鉴权并将鉴权信息放secruity全局上下文
package cn.bit.pro6_1.core.filter;import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;@Component
@AllArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {private JwtUtil jwtUtil;private UserDetailsService userDetailsService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {final String authorizationHeader = request.getHeader("Authorization");String username = null;String jwt = null;if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {jwt = authorizationHeader.substring(7);username = jwtUtil.extractUsername(jwt);}if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);if (jwtUtil.validateToken(jwt, userDetails)) {UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);}}chain.doFilter(request, response);}
}
配置类:
CorsConfig,跨域请求配置。(需要设置为自己前端运行的端口号)
package cn.bit.pro6_1.core.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import java.util.List;@Configuration
public class CorsConfig {@Beanpublic CorsConfigurationSource corsConfigurationSource() {CorsConfiguration configuration = new CorsConfiguration();configuration.setAllowedOrigins(List.of("http://localhost:63342")); // 明确列出允许的域名configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); // 允许的请求方法configuration.setAllowedHeaders(List.of("*")); // 允许的请求头configuration.setAllowCredentials(true); // 允许携带凭证(如 Cookie)UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**", configuration); // 对所有路径生效return source;}
}
ResourceServerConfig,资源服务器配置。配置鉴权过滤器链,以及退出登录处理逻辑。在登录认证和刷新token时不进行access token校验,其余接口均进行token校验。这里需要将jwt的过滤器放在logout的过滤器前,否则logout无法获取secruity上下文中的用户信息,报空指针错误,从而无法做后续比如清除redis中token,日志记录等操作。
package cn.bit.pro6_1.core.config;import cn.bit.pro6_1.core.filter.JwtRequestFilter;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.cors.CorsConfigurationSource;import jakarta.servlet.http.HttpServletResponse;@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class ResourceServerConfig {private final JwtRequestFilter jwtRequestFilter;private final CorsConfigurationSource corsConfigurationSource;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.cors(cors -> cors.configurationSource(corsConfigurationSource)).csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF.authorizeHttpRequests(auth -> auth.requestMatchers("/authenticate", "/refresh-token").permitAll() // 允许匿名访问.requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN 角色可访问.requestMatchers("/buyer/**").hasRole("BUYER") // BUYER 角色可访问.anyRequest().authenticated() // 其他请求需要认证).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话).logout(logout -> logout.logoutUrl("/auth/logout") // 退出登录的 URL.addLogoutHandler(logoutHandler()) // 自定义退出登录处理逻辑.logoutSuccessHandler(logoutSuccessHandler()) // 退出登录成功后的处理逻辑.invalidateHttpSession(true) // 使 HTTP Session 失效.deleteCookies("JSESSIONID") // 删除指定的 Cookie).addFilterBefore(jwtRequestFilter, LogoutFilter.class); // 添加 JWT 过滤器return http.build();}@Beanpublic LogoutHandler logoutHandler() {return (request, response, authentication) -> {if (authentication != null) {// 用户已认证,执行正常的登出逻辑System.out.println("User logged out: " + authentication.getName());// 这里可以添加其他逻辑,例如记录日志、清理资源等} else {// 用户未认证,处理未登录的情况System.out.println("Logout attempt without authentication");// 可以选择记录日志或执行其他操作}};}@Beanpublic LogoutSuccessHandler logoutSuccessHandler() {return (request, response, authentication) -> {// 退出登录成功后的逻辑,例如返回 JSON 响应response.setStatus(HttpServletResponse.SC_OK);response.getWriter().write("Logout successful");};}
}
Pojo:
封装登录请求和响应,以及用户实体类
package cn.bit.pro6_1.pojo;import lombok.Data;@Data
public class LoginRequest {private String username;private String password;
}
package cn.bit.pro6_1.pojo;import lombok.Data;import java.util.Date;@Data
public class LoginResponse {private String accessToken;private String refreshToken;private Date accessTokenExpires;private Date refreshTokenExpires;
}
package cn.bit.pro6_1.pojo;import lombok.Data;@Data
public class UserPO {private Integer id;private String username;private String password;private String roles;private String salt;
}
接口:
全局异常抓取
package cn.bit.pro6_1.controller;import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import io.jsonwebtoken.ExpiredJwtException;
import java.nio.file.AccessDeniedException;@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {/*** 全局异常.* @param e the e* @return R*/@ExceptionHandler(Exception.class)@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)public String handleGlobalException(Exception e) {log.error("全局异常信息 ex={}", e.getMessage(), e);return e.getLocalizedMessage();}/*** AccessDeniedException* @param e the e* @return R*/@ExceptionHandler(AccessDeniedException.class)@ResponseStatus(HttpStatus.FORBIDDEN)public String handleAccessDeniedException(AccessDeniedException e) {log.error("拒绝授权异常信息 ex={}", e.getLocalizedMessage(),e);return e.getLocalizedMessage();}/**** @param e the e* @return R*/@ExceptionHandler(ExpiredJwtException.class)@ResponseStatus(HttpStatus.FORBIDDEN)public String handleExpiredJwtException(ExpiredJwtException e) {log.error("Token过期 ex={}", e.getLocalizedMessage(),e);return e.getLocalizedMessage();}
}
登录接口
package cn.bit.pro6_1.controller;import cn.bit.pro6_1.pojo.LoginRequest;
import cn.bit.pro6_1.pojo.LoginResponse;
import cn.bit.pro6_1.core.service.UserService;
import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.Date;@RestController
@RequestMapping("/authenticate")
@AllArgsConstructor
public class AuthenticationController {private final JwtUtil jwtUtil;private final UserService userService;private final PasswordEncoder passwordEncoder;@PostMappingpublic ResponseEntity<LoginResponse> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {// 生成 Access Token 和 Refresh TokenUserDetails userDetails = userService.loadUserByUsername(loginRequest.getUsername());if(!passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) {throw new RuntimeException("密码错误");}String accessToken = jwtUtil.generateAccessToken(userDetails);String refreshToken = jwtUtil.generateRefreshToken(userDetails);// 获取 Token 过期时间Date accessTokenExpires = jwtUtil.getAccessTokenExpiration();Date refreshTokenExpires = jwtUtil.getRefreshTokenExpiration();// 返回 Token 和过期时间LoginResponse loginResponse = new LoginResponse();loginResponse.setAccessToken(accessToken);loginResponse.setRefreshToken(refreshToken);loginResponse.setAccessTokenExpires(accessTokenExpires);loginResponse.setRefreshTokenExpires(refreshTokenExpires);return ResponseEntity.ok(loginResponse);}
}
access token刷新接口
package cn.bit.pro6_1.controller;import cn.bit.pro6_1.pojo.LoginRequest;
import cn.bit.pro6_1.pojo.LoginResponse;
import cn.bit.pro6_1.core.service.UserService;
import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.Date;@RestController
@RequestMapping("/authenticate")
@AllArgsConstructor
public class AuthenticationController {private final JwtUtil jwtUtil;private final UserService userService;private final PasswordEncoder passwordEncoder;@PostMappingpublic ResponseEntity<LoginResponse> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {// 生成 Access Token 和 Refresh TokenUserDetails userDetails = userService.loadUserByUsername(loginRequest.getUsername());if(!passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) {throw new RuntimeException("密码错误");}String accessToken = jwtUtil.generateAccessToken(userDetails);String refreshToken = jwtUtil.generateRefreshToken(userDetails);// 获取 Token 过期时间Date accessTokenExpires = jwtUtil.getAccessTokenExpiration();Date refreshTokenExpires = jwtUtil.getRefreshTokenExpiration();// 返回 Token 和过期时间LoginResponse loginResponse = new LoginResponse();loginResponse.setAccessToken(accessToken);loginResponse.setRefreshToken(refreshToken);loginResponse.setAccessTokenExpires(accessTokenExpires);loginResponse.setRefreshTokenExpires(refreshTokenExpires);return ResponseEntity.ok(loginResponse);}
}
admin
package cn.bit.pro6_1.controller;import cn.bit.pro6_1.core.util.SecurityUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/admin")
public class AdminController {@GetMapping("/info")@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色可以访问public String adminInfo() {User user = SecurityUtils.getUser();System.out.println(user.getUsername());return "This is admin info. Only ADMIN can access this.";}@GetMapping("/manage")@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色可以访问public String adminManage() {return "This is admin management. Only ADMIN can access this.";}
}
buyer
package cn.bit.pro6_1.controller;import cn.bit.pro6_1.core.util.SecurityUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/buyer")
public class BuyerController {@GetMapping("/info")@PreAuthorize("hasRole('BUYER')") // 只有 BUYER 角色可以访问public String buyerInfo() {User user = SecurityUtils.getUser();System.out.println(user.getUsername());return "This is buyer info. Only BUYER can access this.";}@GetMapping("/order")@PreAuthorize("hasRole('BUYER')") // 只有 BUYER 角色可以访问public String buyerOrder() {return "This is buyer order. Only BUYER can access this.";}
}
前端:
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>权限控制测试</title><style>body {font-family: Arial, sans-serif;background-color: #f4f4f4;margin: 0;padding: 20px;}.container {max-width: 600px;margin: auto;background: #fff;padding: 20px;border-radius: 8px;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);}h1, h2 {color: #333;}label {display: block;margin: 10px 0 5px;}input[type="text"],input[type="password"] {width: 100%;padding: 10px;margin-bottom: 20px;border: 1px solid #ccc;border-radius: 4px;}button {background-color: #28a745;color: white;padding: 10px;border: none;border-radius: 4px;cursor: pointer;width: 100%;}button:hover {background-color: #218838;}.result {margin-top: 20px;}.error {color: red;}.logout-button {background-color: #dc3545; /* 红色按钮 */margin-top: 10px;}.logout-button:hover {background-color: #c82333;}</style>
</head>
<body>
<div class="container"><h1>登录</h1><form id="loginForm"><label for="username">用户名:</label><input type="text" id="username" name="username" required><label for="password">密码:</label><input type="password" id="password" name="password" required><button type="submit">登录</button></form><div class="result" id="loginResult"></div><h2>Token 失效倒计时</h2><div id="accessTokenCountdown"></div><div id="refreshTokenCountdown"></div><h2>测试接口</h2><button onclick="testAdminInfo()">测试 /admin/info</button><button onclick="testBuyerInfo()">测试 /buyer/info</button><!-- 退出按钮 --><button class="logout-button" onclick="logout()">退出登录</button><div class="result" id="apiResult"></div>
</div><script>let accessToken = '';let refreshToken = '';let accessTokenExpires;let refreshTokenExpires;let accessTokenCountdownInterval;let refreshTokenCountdownInterval;document.getElementById('loginForm').addEventListener('submit', async (event) => {event.preventDefault();const username = document.getElementById('username').value;const password = document.getElementById('password').value;const response = await fetch('http://localhost:8080/authenticate', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ username, password })});if (response.ok) {const data = await response.json();accessToken = data.accessToken;refreshToken = data.refreshToken;accessTokenExpires = new Date(data.accessTokenExpires).getTime();refreshTokenExpires = new Date(data.refreshTokenExpires).getTime();document.getElementById('loginResult').innerHTML = `<p>登录成功!Access Token: ${accessToken}</p>`;startCountdown('accessTokenCountdown', accessTokenExpires, 'Access Token 将在 ');startCountdown('refreshTokenCountdown', refreshTokenExpires, 'Refresh Token 将在 ');} else {document.getElementById('loginResult').innerHTML = `<p class="error">登录失败,状态码: ${response.status}</p>`;}});function startCountdown(elementId, expirationTime, prefix) {const countdownElement = document.getElementById(elementId);const interval = setInterval(() => {const now = new Date().getTime();const distance = expirationTime - now;if (distance <= 0) {clearInterval(interval);countdownElement.innerHTML = `${prefix}已过期`;} else {const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));const seconds = Math.floor((distance % (1000 * 60)) / 1000);countdownElement.innerHTML = `${prefix}${hours} 小时 ${minutes} 分钟 ${seconds} 秒后过期`;}}, 1000);// 根据元素 ID 记录对应的计时器if (elementId === 'accessTokenCountdown') {accessTokenCountdownInterval = interval;} else if (elementId === 'refreshTokenCountdown') {refreshTokenCountdownInterval = interval;}}async function testAdminInfo() {if (!accessToken) {alert('请先登录!');return;}const response = await fetch('http://localhost:8080/admin/info', {method: 'GET',headers: {'Authorization': `Bearer ${accessToken}`}});if (response.ok) {const data = await response.text();document.getElementById('apiResult').innerHTML = `<p>响应: ${data}</p>`;} else if (response.status === 403) {await refreshAccessToken();await testAdminInfo(); // 重新尝试} else {document.getElementById('apiResult').innerHTML = `<p class="error">访问失败,状态码: ${response.status}</p>`;}}async function testBuyerInfo() {if (!accessToken) {alert('请先登录!');return;}const response = await fetch('http://localhost:8080/buyer/info', {method: 'GET',headers: {'Authorization': `Bearer ${accessToken}`}});if (response.ok) {const data = await response.text();document.getElementById('apiResult').innerHTML = `<p>响应: ${data}</p>`;} else if (response.status === 403) {await refreshAccessToken();await testBuyerInfo(); // 重新尝试} else {document.getElementById('apiResult').innerHTML = `<p class="error">访问失败,状态码: ${response.status}</p>`;}}async function refreshAccessToken() {const response = await fetch('http://localhost:8080/refresh-token', {method: 'POST',headers: {'Authorization': refreshToken}});if (response.ok) {const data = await response.json();accessToken = data.accessToken; // 更新 access tokenaccessTokenExpires = new Date(data.accessTokenExpires).getTime(); // 更新过期时间document.getElementById('loginResult').innerHTML = `<p>Access Token 刷新成功!新的 Access Token: ${accessToken}</p>`;// 更新 accessToken 的倒计时startCountdown('accessTokenCountdown', accessTokenExpires, 'Access Token 将在 ');} else if (response.status === 403) {// 清除 tokens 并提示用户重新登录accessToken = '';refreshToken = '';document.getElementById('loginResult').innerHTML = `<p class="error">刷新 Token 失败,请重新登录。</p>`;alert('请重新登录!');} else {document.getElementById('loginResult').innerHTML = `<p class="error">刷新 Token 失败,状态码: ${response.status}</p>`;}}// 退出登录逻辑async function logout() {// 调用退出登录接口const response = await fetch('http://localhost:8080/auth/logout', {method: 'POST',headers: {'Authorization': `Bearer ${accessToken}`}});if (response.ok) {// 清除本地存储的 tokensaccessToken = '';refreshToken = '';// 停止倒计时clearInterval(accessTokenCountdownInterval);clearInterval(refreshTokenCountdownInterval);// 更新页面显示document.getElementById('loginResult').innerHTML = `<p>退出登录成功!</p>`;document.getElementById('accessTokenCountdown').innerHTML = '';document.getElementById('refreshTokenCountdown').innerHTML = '';document.getElementById('apiResult').innerHTML = '';} else {document.getElementById('loginResult').innerHTML = `<p class="error">退出登录失败,状态码: ${response.status}</p>`;}}
</script>
</body>
</html>
测试:
启动服务,打开前端:
1.输入错误的账号
后端抛出用户名未找到的异常
2.输入错误密码
后端抛出密码错误异常
3.正确登录
显示两个token有效期倒计时以及access-token的值
4.访问admin接口
5.访问buyer接口
会看到access-token会不断刷新,但不会显示"This is buyer info. Only BUYER can access this."字体,看上去有点鬼畜,原因是前端写的是在收到403状态码后会以为是access-token过期而会访问fresh接口并再次执行一次接口。但实际上这个403是因为没有对应权限所导致的,这个问题无论改前端还是后端都能解决,但前端是ai生成的且我自己也不是很了解,后端也可限定不同异常的错误响应码,但正如开篇所说本章只是各基础demo所以就懒的改了。反正请求确实是拦截到了。
6.测试token刷新
在access-token过期但refresh-token未过期时测试admin,能够看到刷新成功且重新访问接口成功
fresh-token过期后则显示重新登录
最后:
auth模块在微服务项目中的重要性都不言而喻,目前只是实现了一个简单的框架,在后面几章会添加feign调用的鉴权,以及redis存放token从而同时获取有状态和无状态校验的优点,以及mysql交互获取数据库中信息等。还敬请关注!
相关文章:
从零搭建微服务项目Pro(第6-1章——Spring Security+JWT实现用户鉴权访问与token刷新)
前言: 在现代的微服务架构中,用户鉴权和访问控制是非常重要的一部分。Spring Security 是 Spring 生态中用于处理安全性的强大框架,而 JWT(JSON Web Token)则是一种轻量级的、自包含的令牌机制,广泛用于分…...
LeetCode 124.二叉树中的最大路径和
题目: 二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。 路径和 是路径中各节点值的总和。 给你一个二叉树的根节点…...
结构型模式之适配器模式:让不兼容的接口兼容
在软件开发中,经常会遇到这样一种情况:系统的不同部分需要进行交互,但由于接口不兼容,导致无法直接使用。这时,适配器模式(Adapter Pattern)就能派上用场。适配器模式是设计模式中的结构型模式&…...
Python 中用T = TypeVar(“T“)这个语法定义一个“类型变量”,属于类型提示系统的一部分
T TypeVar("T") 这一语法规则定义了一个泛型类型变量 T,用于标记“某种类型”,让你可以写出既通用又类型安全的代码。 TypeVar(“T”) 会创建一个名为 T 的类型占位符,这个占位符可以在后续的函数、类或方法中用作泛型参数。泛型…...
uniapp移动端图片比较器组件,仿英伟达官网rtx光追图片比较器功能
组件下载地址:https://ext.dcloud.net.cn/plugin?id22609 已测试h5和微信小程序,理论支持全平台 亮点: 简单易用 使用js计算而不是resize属性,定制化程度更高 组件挂在后可播放指示线动画,提示用户可以拖拽比较图片…...
理解我们单片机拥有的资源
目录 为什么要查询单片机拥有的资源 所以,去哪些地方可以找数据手册 一个例子:STM32F103C8T6 前言 本文章隶属于项目: Charliechen114514/BetterATK: This is a repo that helps rewrite STM32 Common Repositorieshttps://github.com/C…...
接上一篇,C++中,如何设计等价于Qt的信号与槽机制。
看下面例子: class FileManager : public QObject {Q_OBJECTpublic:FileManager(QObject* parent nullptr) : QObject(parent) {}void changeFileName(const QString& newName) {fileName newName;emit fileNameChanged(fileName);}signals:void fileNameChan…...
redis分片集群如何解决高并发写问题的?
不使用分片集群,仅使用主从复制和哨兵模式下,可以有多个主从集群,但每个主从集群一般只有一个活跃的主节点并执行写操作,每个主从集群的数据也可能(应该)是不同的,同时每个主从集群存储的数据没…...
2025 linux系统资源使用率统计docker容器使用率统计docker监控软件Weave Scope安装weavescope
1.Weave Scope介绍 Weave Scope 是一款用于监控和可视化 Docker 容器、Kubernetes 集群以及分布式应用的强大工具。它的设计目标是帮助开发者和运维人员更好地理解和管理复杂的微服务架构。以下是 Weave Scope 的主要优点: 1. 实时可视化 Weave Scope 提供了一个直…...
Spring Boot 核心知识点深度详解:自动化配置 (Auto-configuration) - 解锁 Spring Boot 的 “魔法”
Spring Boot 核心知识点深度详解:自动化配置 (Auto-configuration) - 解锁 Spring Boot 的 “魔法” ✨ 自动化配置 (Auto-configuration) 是 Spring Boot 最核心的特性之一,也是它能够大幅简化 Spring 应用开发的关键所在。 它让 Spring Boot 应用能够…...
嵌入式Linux | 什么是 BootLoader、Linux 内核(kernel)、和文件系统?
01 什么是 BootLoader 呢? 它是个引导程序,也就是硬件复位以后第一个要执行的程序,它主要工作就是初始化操作系统运行的环境,比如说内存、定时器、缓冲器等,当这个工作做完以后,再把操作系统的代码加载…...
IP关联是什么?怎么避免?
在跨境电商的道路上,大家好!今天想和大家聊一聊一个非常重要的话题,那就是IP关联的问题。在商业活动中,了解如何避免IP关联对保护我们宝贵的商铺至关重要。接下来,我们将深入探讨IP关联的概念、影响及如何有效防止这一…...
【Agent】OpenManus-Prompt组件详细分析
1. 提示词架构概述 OpenManus 的提示词组件采用了模块化设计,为不同类型的智能体提供专门的提示词模板。每个提示词模块通常包含两种核心提示词:系统提示词(System Prompt)和下一步提示词(Next Step Prompt࿰…...
算数操作符、赋值操作符、单目操作符、强制类型转换
一、算术操作符(、 -、 *、 /、 %) • - * / %操作符都是双⽬操作符,有**两个操作数**的符号就叫做双目操作符 10 4| || | 操作数1 操作数2// - % / * 以此类推•操作符也被叫做:运算符 1. 符号、符号 - 和 符号* •…...
华为OD机试 - 九宫格按键输入 - 逻辑分析(Java 2023 B卷 200分)
题目描述 九宫格按键输入,输出显示内容。有英文和数字两个模式,默认是数字模式。数字模式直接输出数字,英文模式连续按同一个按键会依次出现这个按键上的字母。如果输入“/”或其他字符,则循环中断。 输入描述 输入范围为数字0…...
DeepSeek大模型在政务服务领域的应用
DeepSeek大模型作为国产人工智能技术的代表,近年来在政务服务领域的应用呈现多点开花的态势。通过多地实践,该技术不仅显著提升了政务服务的效率与智能化水平,还推动了政府治理模式的创新。以下从技术应用场景、典型案例及发展趋势三个维度进…...
卷积神经网络 - 一维卷积、二维卷积
卷积(Convolution),也叫褶积,是分析数学中一种重要的运算。在信号处理或图像处理中,经常使用一维或二维卷积,本博文我们来学习一维卷积和二维卷积。 理解一维卷积和二维卷积的核心在于把握维度对特征提取方式的影响。我们从数学定…...
【NLP 33、实践 ⑦ 基于Triple Loss作表示型文本匹配】
目录 一、配置文件 config.py 二、 数据加载文件 loader.py 1.加载数据 Ⅰ、加载字表或词表 Ⅱ、加载标签映射表 Ⅲ、封装数据 2.处理数据 Ⅰ、补齐或截断 Ⅱ、定义类的特殊方法 ① 返回数据集大小 ② 生成随机训练样本 ③ 根据索引返回样本 Ⅲ、加载和处理训练样本和测试样本 …...
基于CNN的多种类蝴蝶图像分类
基于CNN的多种类蝴蝶图像分类🦋 基于卷积神经网络对64992786张图像,75种不同类别的蝴蝶进行可视化分析、模型训练及分类展示 导入库 import pandas as pd import os import matplotlib.pyplot as plt import seaborn as sns import numpy as np from …...
Linkreate wordpressAI智能插件-自动生成原创图文、生成关键词、获取百度搜索下拉关键词等
Linkreate wordpressAI插件核心功能亮点 文章生成与优化 自动化文章生成:利用 AI 技术,根据关键词生成高质量文章。 支持指定长度和要求,异步生成不阻塞操作。 且 AI 可自动生成精准的 tag 标签,利于 SEO 优化。 批量生成文章…...
uniapp-x web 开发警告提示以及解决方法
defineModel props...
详细介绍 SetWindowPos() 函数
书籍:《Visual C 2017从入门到精通》的2.3.8 Win32控件编程 环境:visual studio 2022 内容:【例2.29】模态对话框 说明:以下内容大部分来自腾讯元宝。 1. 函数功能与用途 SetWindowPos() 是 Windows API 中用于动态调整窗口…...
SpringBoot桂林旅游景点导游平台开发与设计
一个基于SpringBoot开发的桂林旅游景点导游平台项目。该项目不仅功能全面,而且易于部署,适合初学者学习和进阶开发者参考。 项目概述 该项目旨在为用户提供一个便捷的桂林旅游景点信息查询与线路推荐平台。系统分为管理员模块和用户模块,分…...
深入理解传输层协议
各类资料学习下载合集 https://pan.quark.cn/s/8c91ccb5a474 传输层是计算机网络中的一个重要层次,其主要任务是为应用层提供可靠的数据传输服务。传输层的协议主要包括 TCP(传输控制协议)和 UDP(用户数据报协议)。本文将详细解析这两种协议的特点、使用…...
基于金融产品深度学习推荐算法详解【附源码】
深度学习算法说明 1、简介 神经网络协同过滤模型(NCF) 为了解决启发式推荐算法的问题,基于神经网络的协同过滤算法诞生了,神经网络的协同过滤算法可以 通过将用户和物品的特征向量作为输入,来预测用户对新物品的评分,从而解决…...
java 中散列表(Hash Table)和散列集(Hash Set)是基于哈希算法实现的两种不同的数据结构
在 Java 中,散列表(Hash Table)和散列集(Hash Set)是两种不同的数据结构,但它们都基于哈希表的原理来实现。下面是它们的联系与区别、实现类以及各自的优缺点,并用表格进行对比整理。 联系与区…...
Tauri + Vite + SvelteKit + TailwindCSS + DaisyUI 跨平台开发详细配置指南(Windows)
Tauri Vite SvelteKit TailwindCSS DaisyUI 跨平台开发详细配置指南(Windows) 本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。转载请注明出处及本声明 原文链接:[你的文章链接] 🛠️ 环境准备 1. 安装核心工具…...
centos 7误删/bash 拯救方法
进入救援模式 1. 插入CentOS 7安装光盘,重启系统。在开机时按BIOS设置对应的按键(通常是F2等),将启动顺序调整为CD - ROM优先。 2. 系统从光盘启动后,选择“Troubleshooting”,然后选择“Rescue a Cent…...
协程池是调用端并发请求的缓释胶囊
hello, 我是马甲哥,这是我的第183篇原创文章,阅读时间3min,有用指数4颗星。 昨天"朝花夕拾"栏目倒腾了一款具有请求排队功能的并发受限服务器。 演示了互联网高并发请求,服务端遇到的现实情况(服务器高负载、…...
【渗透测试】webpack对于渗透测试的意义
作者 :Yuppie001 作者主页 : 传送 本文专栏 :Web漏洞篇 🌟🌟🌟🌟🌟🌟🌟🌟 webpack: 一.webpack是什么二.对于渗透测试的意义&#…...
举例说明 牛顿法 Hessian 矩阵
矩阵求逆的方法及示例 目录 矩阵求逆的方法及示例1. 伴随矩阵法2. 初等行变换法矩阵逆的实际意义1. 求解线性方程组2. 线性变换的逆操作3. 数据分析和机器学习4. 优化问题牛顿法原理解释举例说明 牛顿法 Hessian 矩阵1. 伴随矩阵法 原理:对于一个 n n n 阶方阵 A A...
VSTO(C#)Excel开发12:多线程的诡异
初级代码游戏的专栏介绍与文章目录-CSDN博客 我的github:codetoys,所有代码都将会位于ctfc库中。已经放入库中我会指出在库中的位置。 这些代码大部分以Linux为目标但部分代码是纯C的,可以在任何平台上使用。 源码指引:github源…...
MambaVision:一种Mamba-Transformer混合视觉骨干网络
摘要 我们提出了一种新型混合Mamba-Transformer主干网络,称为MambaVision,该网络专为视觉应用而设计。我们的核心贡献包括重新设计Mamba公式,以增强其对视觉特征的高效建模能力。此外,我们还对将视觉Transformer(ViT&…...
目标跟踪之DeepSort算法(4)
目标跟踪之DeepSort 1 安装1.1 代码下载与安装1. 2 DeepSort检测流程1.3 模型初始化流程 2. 模型推理2.1 模型推理代码解析2.2 对预测结果跟踪代码解析2.3 轨迹预测2.4 轨迹跟踪2.5 轨迹与特征匹配2.6 计算轨迹与检测的特征余弦距离2.7 用轨迹与检测的马氏距离跟新cost_matrix矩…...
速通大厂测开
最近26届暑期实习招聘和25届春招已经开始,测开学习圈也有同学拿到offer了 今天分享一位25届秋招圈友快速拿到大厂测开offer的经历,希望对大家有所帮助 我是某211本科生,在去年暑假准备考研的间隙意外收获了某大厂测开实习offer,…...
LightRAG简要概述
文章目录 索引流程问答流程naivelocalglobalhybridmix 中文prompt 官方仓库:LightRAG 没有废话,直接进入主题。 索引流程 1、提取实体与关系 2、LLM判断是否有漏掉的实体与关系,如有则接着提取 3、合并实体,根据多个实体描述&a…...
【Mac】安装 Parallels Desktop、Windows、Rocky Linux
一、安装PD 理论上,PD只支持试用15天!当然,你懂的。 第一步,在 Parallels Desktop for Mac 官网 下载 Install Parallels Desktop.dmg第二步,双击 Install Parallels Desktop.dmg 第三步,双击安装Paralle…...
Unity利用噪声生成动态地形
引言 在游戏开发中,地形是构建游戏世界的基础元素之一。传统的地形创建方法通常依赖于手动建模或预设资源,这种方式虽然精确但缺乏灵活性,且工作量巨大。而使用噪声算法生成地形则提供了一种程序化、动态且高效的解决方案。本文将详细介绍如…...
【Linux】Ext系列文件系统(上)
目录 一、 理解硬件 1-1 磁盘 1-2 磁盘物理结构 1-3 磁盘的存储结构 1-4 如何定位一个扇区 1-4 磁盘的逻辑结构 1-4-1 理解过程 1-4-2 真实过程 1-5 CHS && LBA地址 二、文件系统 2-1 "块"概念 2-2 "分区"概念 2-3 "inode"…...
解决diffusers加载stablediffusion模型,输入prompt总是报错token数超出clip最大长度限制
1. StableDiffusion1.5 在加载huggingface中的扩散模型时,输入prompt总是会被报错超过clip的最大长度限制。 解决方案:使用compel库 from diffusers import AutoPipelineForText2Image import torch import pdb from compel import Compeldevice torc…...
metersphere接口测试(1)使用MeterSphere进行接口测试
文章目录 前言接口文档单接口测试环境配置梳理接口测试场景测试接口 接口自动化怎么写复用性高的自动化测试用例 总结 前言 大汉堡工作第203天,本篇记录我第一次接触接口测试任务,最近有些懈怠啊~ 接口文档 首先就是接口地址,接口测试时用…...
Android第三次面试总结(网络篇)
在计算机网络领域,网络模型是理解通信原理的基础框架。本文将详细解析 OSI 参考模型和 TCP/IP 模型的分层结构、核心功能及实际应用,并通过对比帮助读者建立完整的知识体系。 一、OSI 参考模型:七层架构的理论基石 OSI(开放系统…...
AirtestIDE用法
包括airtest和poco 1. airtest 安装python3.7.9 64 python3 -m pip install -U airtest 或者: git clone https://github.com/AirtestProject/Airtest.git pip install -e airtest 无界面的airtest用法: 打开手机中的 开发者选项 , 以及 允许USB调…...
【面试手撕】非常规算法,多线程常见手撕题目
【面试手撕】非常规算法,多线程常见手撕题目 生产者消费者ReentrantLock实现的生产苹果/消费苹果synchronized实现的生产消费LinkedBlockingQueue阻塞队列方法实现多条件资源分配分布式任务调度模拟 交替打印两个线程交替打印1-100之间的数ReentrantLock 实现synchr…...
MySQL复合查询
目录 多表查询 自连接 子查询 单行子查询 多行子查询 in关键字 all关键字 any关键字 多列子查询 from中使用子查询 合并查询 union 操作符 union all 操作符 内外连接 内连接 外连接 左外连接 右外连接 前几期我们已经学习了MySQL的基本查询&…...
登录Xshell主机及Linux基本指令
✅博客主页:爆打维c-CSDN博客 🐾 🔹分享c、c知识及代码 🐾 🔹Gitee代码仓库 五彩斑斓黑1 (colorful-black-1) - Gitee.com 一、操作系统简介 Linux其实跟我们熟知的Window一样,它们都是操作系统。 &#x…...
[LevelDB]关于LevelDB存储架构到底怎么设计的?
本文内容组织形式 LevelDB 存储架构重要特点总体概括LevelDB中内存模型MemTableMemTable的数据结构背景:SkipListSkiplist的数据结构 Skiplist的数据访问细节 SkipList的核心方法Node细节源代码 MemTable的数据加速方式Iterator 的核心方法 MemTable 的读取&写入…...
深入解析 React Diff 算法:原理、优化与实践
深入解析 React Diff 算法:原理、优化与实践 1. 引言 React 作为前端领域的标杆框架,采用 虚拟 DOM(Virtual DOM) 来提升 UI 更新性能。React 的 Diff 算法(Reconciliation) 是虚拟 DOM 运行机制的核心&a…...
【从零开始】Air780EPM的LuatOS二次开发——OneWire协议调试注意事项!
当涉及到与传感器、执行器等外部设备交互时,OneWire协议的高效调试成为决定项目成败的关键环节。OneWire协议(单总线协议)以其仅需一根数据线即可实现设备通信的极简特性,被广泛应用于温度传感器、身份识别模块等场景。 一、LuatO…...
响应(Response)
在 Flask 中,视图函数可以返回多种类型的响应,例如字符串、HTML、JSON、文件等。Flask 提供了 make_response 函数,用于生成和自定义 HTTP 响应。 2.1 默认响应 默认情况下,视图函数返回的字符串会被 Flask 包装成一个 HTTP 响应…...