1.springsecurity+jwt的安全身份校验
- 过滤器 (
JwtRequestFilter实现OncePerRequestFilter
):从请求头中提取 JWT,并验证其有效性。如果有效,将用户信息存储在SecurityContext
中。 - 工具类 (
JwtUtil
):用于生成(uuid)、验证、解析和失效 JWT(存在redis中)。 - 安全配置 (
SecurityConfig
):配置 Spring Security 使用 JWT 进行认证和授权。 - 控制器 (
Controller
):提供认证接口和受保护资源接口。 - 用户详细信息服务 (
UserDetailsService
):从数据库加载用户信息,id,name,password,roles - 退出登录:安全隐患,加入黑名单(退出时把token放入redis,下次登录有id则不许)
import io.jsonwebtoken.JwtException;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 从请求头中获取Authorization头信息
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// 检查Authorization头信息是否存在且以"Bearer "开头
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
// 提取JWT令牌
jwt = authorizationHeader.substring(7);
try {
// 从JWT令牌中提取用户名
username = jwtUtil.extractUsername(jwt);
} catch (JwtException e) {
// Token 验证失败,设置响应状态为401未授权
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid JWT Token");
return;
}
}
// 检查用户名是否存在且当前上下文没有经过认证
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 加载用户详细信息
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 验证JWT令牌的有效性
if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
// 创建UsernamePasswordAuthenticationToken对象并设置到安全上下文中
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
// 继续过滤链
chain.doFilter(request, response);
}
}
2.跨域
跨域问题是指在浏览器端发起的请求受到同源策略(Same-Origin Policy)的限制,因此无法直接与不同域名、协议或端口的资源进行交互。在前后端开发中,特别是在前后端分离的架构中,由于前端和后端可能运行在不同的域名或端口下,跨域问题就会经常出现。这可能会导致一些请求被浏览器拦截或失败,从而影响应用的正常运行。如前端应用在 http://localhost:3000
运行,后端API在 http://localhost:8080
提供服务。由于不同端口,浏览器默认会阻止前端直接访问后端API。解决跨域问题。这个过滤器会拦截所有的请求,并为每个请求添加相应的 CORS 头信息。
- 前端页面无法通过AJAX请求直接访问后端API,因为浏览器会拒绝跨域请求。
- 跨域请求可能会收到浏览器的预检请求(Preflight Request)失败的错误。
- 由于浏览器的安全策略,某些资源(如Cookies和LocalStorage)在跨域情况下可能无法正常工作。
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebFilter("/*")
public class CorsFilter extends HttpFilter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 过滤器初始化方法,可以在此进行一些启动时的配置或初始化操作
}
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 允许所有来源
response.setHeader("Access-Control-Allow-Origin", "*");
// 允许的 HTTP 方法
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
// 允许的 HTTP 头信息
response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
// 允许的实际请求携带的 HTTP 头信息
response.setHeader("Access-Control-Expose-Headers", "Authorization, Content-Disposition");
// 预检请求的缓存时间,单位为秒
response.setHeader("Access-Control-Max-Age", "3600");
// 如果是 OPTIONS 请求,则返回 200 状态码,表示预检请求成功
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
// 继续过滤器链
chain.doFilter(request, response);
}
@Override
public void destroy() {
// 过滤器销毁方法,可以在此进行一些清理操作
}
}
在 Spring Boot 中,通常不需要手动注册使用 @WebFilter
注解的过滤器。手动注册可以使用如下配置:
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<CorsFilter> corsFilter() {
FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new CorsFilter());
registrationBean.addUrlPatterns("/*"); // 过滤所有 URL
return registrationBean;
}
}
设置 CORS 响应头:
Access-Control-Allow-Origin
:允许的来源,*
表示允许所有来源。可以根据需求限制为特定的域名。Access-Control-Allow-Methods
:允许的 HTTP 方法,例如 GET、POST、PUT、DELETE、OPTIONS。Access-Control-Allow-Headers
:允许客户端发送的 HTTP 头信息,例如 Origin、X-Requested-With、Content-Type、Accept、Authorization。Access-Control-Expose-Headers
:允许客户端访问的 HTTP 头信息,例如 Authorization、Content-Disposition。Access-Control-Max-Age
:预检请求的缓存时间,单位为秒。在此期间,不需要再次发送预检请求。
3.邮件发送
- 消息队列
- 监听消费
-
生产者发送邮件消息到RabbitMQ队列: 在Java代码中,创建一个生产者,负责将待发送的邮件消息发布到RabbitMQ队列中。
-
消费者监听邮件队列并发送邮件: 创建一个消费者,监听RabbitMQ中的邮件队列。当有新的邮件消息到达队列时,消费者负责从队列中获取邮件消息并发送邮件。
-
控制发送频率: 在消费者中实现逻辑,限制发送邮件的频率。可以使用一些机制,如等待固定时间再发送下一封邮件,或者设置一个计数器,每隔一段时间重置计数器来控制发送频率。
-
邮件发送失败处理: 如果邮件发送失败,可能需要将邮件消息重新放回队列,或者记录发送失败的信息供后续处理。
配置mq时候创建一个队列叫mail
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
public static final String MAIL_QUEUE = "mail";
@Bean
public Queue mailQueue() {
return new Queue(MAIL_QUEUE);
}
}
把数据通过ampqtemplate发送到队列mail中
ampqtemplate.convertAndSend("mail",data);
设计3分钟有效(放入redis,设置3min有效),60s内不能发送(限流工具:没有被封禁,丢入封禁 的redis(60S) ;如果被封禁(存在),返回false)。200个请求同时来会出问题,加锁(synchronized)。
//限流
@Component
public class FlowUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 尝试进行一次限流操作
*
* @param key 限流的key
* @param limit 在时间窗口内允许的最大请求数
* @param timeWindow 时间窗口,单位秒
* @return true 表示操作成功,false 表示超过限流
*/
public boolean tryAcquire(String key, int limit, long timeWindow) {
// 递增操作
Long count = redisTemplate.opsForValue().increment(key);
// 如果是第一次访问,则设置过期时间
if (count != null && count == 1) {
redisTemplate.expire(key, timeWindow, TimeUnit.SECONDS);
}
// 判断是否超过限流
return count != null && count <= limit;
}
}
监听器:
@rabbitmqListener(queues="mail")
@RabbitHandler
public void sendMailMessage(Map<String, Object> data){
String email=(string)data.get("email");
Integer code=(Integer)data.get("code");
String type=(String)data.get("type");
SimpleMailMessage message=switch(type){
case "register"->createMessage( title:"欢迎注册我们的网站”,content:“您的邮件注册验证码为:"+code+",有效时间3分钟,为了保障您的安全,请勿向他人泄露验证码信息。",email);
case"reset"-> createMessage( titie:"你的密码重置邮件",content;“你好,您正在进行重置密码操作,验证码:"+code+",有效时间3分钟,如非本人操作,请无视。",email);
default -> null;
};
if(message == null)return;
sender.send(message);
}
private SimpleMailMessage createMessage(String title,String content,String email
{
SimpleMailMessage message =new SimpleMailMessage();
message.setSubject(title);
message.setText(content);
message.setTo(email);
message.setFrom(username);
return message;
}
为了确保消息的可靠性,可能需要考虑消息的持久化、确认机制等。
4.对接天气api
restTemplate
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
//天气那边的是Gzip的压缩,需要解压
private JS0NObject decompressStingToJson(byte!data){
0个用法
ByteArrayOutputStream stream = new ByteArrayOutputStream();
try {
GZIPInputStream gzip=new GZIPInputStream(new ByteArrayInputStream(data));
byte[] buffer =new byte[1024];
int read;
while((read=gzip.read(buffer))!=-1)
stream.write(buffer,off:0,read);
gzip.close();
stream.close();
return JSONObject.parse0bject(stream.tostring());
}catch(I0Exception e)
{return null;
}
}
//通过向api地址发送请求,查询到对应的信息并返回,放到redis缓存里
时间为一小时,key是"weather:"+id,value是查询到的天气信息
private WeatherVo fetchFromCache(double longitude, double latitude){
JSONObject geo =this.decompressstingToJson(rest.getForObject(url: "https://geoapi.gweather.com/v2/city/lookup?location="+longitude+","+latitude+"skey="+key, byte[].class));
if(geo == null)return null;
JSONObject location=geo.getISON0bject( key:"location");
int id =location.getInteger( key: "id");
String key = "weather:"+id;
String cache =template.opsForValue().get(key);
if(cache != null)
return JSONObject.parse0bject(cache).to(WeatherVo.class):
WeatherVO vo=this.fetchFromAPI(id,location);
if(vo == null)return null;
template.opsForValue().set(key,Js0Nobject.from(vo).toJSONstring(), timeout: 1, TimeUnit.HOURS);
return vo;
}
private WeatherVo fetchFromAPI(int id,JsONObject location){
WeatherVO vo =new WeatherV():
vo.setLocation(location);
JsONObject now=this.decompressstingToJson(rest.getForObject(
url: "https://devapi.qweather.com/v7/weather/now?location="+id+"&key="+key, byte[].class));
if(now == null)return null;
vo.setNow(now.getJSONObject( key:"now" ));
JSONObject hourly = this.decompressStingToJson(rest.getForObject(url: "https://devapi.qweather.com/v7/weather/24h?location="+id+"&key="+key, byte[].class));
if(hourly == null)return null;
vo.setHourly(new Js0NArray(hourly.getJsONArray( key: "hourly").stream().limit( maxsize: 5).toList()));
return vo;
}
5.帖子发布
1.用到一个联表查询(即帖子信息和用户信息)
select * from db topic left join db account on uid = db account.id
order by time desc limit ${start}, 10
2.讲师特别喜欢写工具类复用代码
@Component
public class CacheUtils{
@Resource 5个用法
StringRedisTemplate template;
public <T> T takeFromCache(String key,Class<T>dataType){
String s=template.opsForValue().get(key);
if(s == null)return null;
return JsONObject.parse0bject(s).to(dataType);
}
public<T> List<T> takeListFromCache(String key,Class<T>itemType){
String s=template.opsForValue().get(key);
if(s == null)return null;
return JSONArray.parseArray(s).toList(itemType);
}
public<T> void saveToCache(String key,T data,long expire){ template.opsForValue().set(key,JsoNobject.from(data).toJsONString(),expire, TimeUnit.SECONDS);
}
public <T> void saveListToCache(String key,List<T>list,long expire){ template.opsForValue().set(key,Js0NArray.from(list).to]soNstring(),expire, TimeUnit.SECONDS);
}
public void deleteCache(String key){
template.delete(key);
}
}
3.做一个缓存,key是page+type,value是List<topic>,先从缓存中查询,再查数据库
@Override
public List<TopicPreviewV0> listTopicByPage(int page, int type){
String key=Const.FORUM TOPIC PREVIEW CACHE + page + ":"+ type;
List<TopicPreviewV0> list = cacheUtils.takelistFromCache(key,TopicPreviewvo.class);
if(list != null)return list;
List<Topic> topics;
if(type ==0)topics=baseMapper.topicList(page *10);
else
topics =baseMapper.topicListByType(page * 10,type);
if(topics.isEmpty())return null;
list =topics.stream().map(this::resolveToPreview).tolist();
//缓存60s
cacheUtils.saveListToCache(key,list,expire:60);
return list;
}
4.(拓展)可以将帖子 ID 和发布时间添加到 Redis,启用定时任务,确保定期检查并发布帖子。
- 工具类编写 一个方法,包含调度帖子发布和检查发布的方法。
- 使用 Redis 存储帖子 ID 和发布时间。
- 启动定时任务,确保定期检查并发布帖子。
- 使用 Redis 存储帖子 ID 和发布时间。
6.点赞收藏
频繁交互的数据加大数据库的压力。由于论坛交互数据(如点赞、收藏等)更新可能会非常频繁,更新信息实时到MySQL不太现实,所以需要用Redis做缓冲并在合适的时机一次性入库一段时间内的全部数据当数据更新到来时,会创建一个新的定时任务,此任务会在一段时间之后执行各全部Redis暂时缓存的信息一次性加入到数据库,从而缓解MySQL压力,如果生定时任务已经设定期间又有新的更新到来,仅更新Redis不创建新的延时任务
//往redis存
public void interact(Interact interact, boolean state);
//往数据库里面存
private void saveInteract(String type);
往redis存,key是类型,hashkey是帖子id+用户id,hashvalue是状态state(加锁,防止脏数据)
@Override
public void interact(Interact interact, boolean state){
String type = interact.getType();
synchronized(type.intern()){
template.opsForHash().put(type,interact.toKey(),state);
this.saveInteractSchedule(type);
}
}
//定时任务
private final Map<String,Boolean> state = new HashMap<>();
ScheduledExecutorService service = Executors.newScheduledThreadPool( corePoolSize: 2);
private void saveInteractSchedule(String type){
if(!state.getOrDefault(type,defaultValue: false)){
state.put(type, true);
service.schedule(()->{this.saveInteract(type);state.put(type,false);},3,TimeUnit.SECONDS);
}
}
将redis插入数据库时候使用动态sql批量插入:
<script>
insert ignore into db_topic_interact ${type} values
<foreach collection ="interacts" item="item" separator =",">
(#{item,tid}, #{item.uid}, #{item.time})
</foreach>
</script>
private void saveInteract(String type){
//加锁
synchronized(type.intern()){
List<Interact> check = new LinkedList<>();
List<Interact>uncheck = new LinkedList<>();
template.opsForHash().entries(type).forEach((k,v)-> {
if(Boolean.parseBoolean(v.toString()))
check.add(Interact.parseInteract(k.tostring(),type));
else
uncheck.add(Interact.parseInteract(k.tostring(),type));
});
if(!check.isEmpty( ))
baseMapper.addInteract(check,type);
if(!uncheck.isEmpty( ))
baseMapper.deleteInteract(check,type);
//入库成功,删hash
template.delete(type);
}
}
7.限流
1.设置一个计数
2.超过一定数量,就封禁,并提示请求频繁
@Component
@Order(Const.ORDER_FLOW_LIMIT)
public class FlowLimitingFilter extends HttpFilter {
@Resource
StringRedisTemplate template;
//指定时间内最大请求次数限制
@Value("${spring.web.flow.limit}")
int limit;
//计数时间周期
@Value("${spring.web.flow.period}")
int period;
//超出请求限制封禁时间
@Value("${spring.web.flow.block}")
int block;
@Resource
FlowUtils utils;
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String address = request.getRemoteAddr();
if (!tryCount(address))
this.writeBlockMessage(response);
else
chain.doFilter(request, response);
}
private boolean tryCount(String address) {
synchronized (address.intern()) {
if(Boolean.TRUE.equals(template.hasKey(Const.FLOW_LIMIT_BLOCK + address)))
return false;
String counterKey = Const.FLOW_LIMIT_COUNTER + address;
String blockKey = Const.FLOW_LIMIT_BLOCK + address;
return utils.limitPeriodCheck(counterKey, blockKey, block, limit, period);
}
}
private void writeBlockMessage(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(RestBean.forbidden("操作频繁,请稍后再试").asJsonString());
}
}