社区系统项目复盘-3
创始人
2024-02-28 16:56:59
0

文章目录

      • 过滤敏感词
      • 发布帖子
      • 帖子详情
      • 添加评论
      • 私信列表
      • 发送私信
      • 统一处理异常
      • 统一记录日志

基于Springboot的核心功能实现

包括自定义前缀树过滤敏感词;使用异步请求的方式发布帖子;查看帖子详情;添加评论时需要同时增加评论的数据和修改帖子的评论数量,进行两步数据库操作,出于安全考虑采用事务管理;私信列表部分包括显示私信列表与私信详情两个子功能;采用异步方式发送私信;配置Controller的全局配置类进行统一异常处理;采用AOP实现统一记录日志。

过滤敏感词

  • 前缀树
    • 名称:Tire、字典树、查找树
    • 特点:查找效率高,消耗内存大
    • 应用:字符串检索、词频统计、字符串排序等
  • 敏感词过滤器
    • 定义前缀树

    • 根据敏感词,初始化前缀树

    • 编写过滤敏感词的方法

      https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/9E3219F6-85F7-480D-8265-7D08EB91804C_2/JD4Ydd5uwLuqv4zkrNls6vRbdYyP9bUQB36h1j4SsHAz/Image.png

发布帖子

该功能的实现需要异步请求,使用了AJAX

示例:怎么使用jQuery发送AJAX请求?

1.需要在Controller里添加处理异步请求的方法,一般请求方式为post,因为通常是浏览器通过异步的方式向服务器提交一些数据,然后服务端向浏览器响应一个提示,不需要返回网页而是返回一个字符串,所以需要添加@ResponseBody注解

// ajax示例
@RequestMapping(path = "/ajax", method = RequestMethod.POST)
@ResponseBody
public String testAjax(String name, int age) {// 请求处理逻辑System.out.println(name);System.out.println(age);// 返回JSON字符串,方法getJSONString()封装在工具类中return CommunityUtil.getJSONString(0, "操作成功!");
}

2.需要在网页中写一段jQuery代码,访问处理异步请求的方法。

分为两步:引入jQuery,使用jQuery发送异步请求

// ajax示例

采用AJAX请求,实现发布帖子的功能:

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/33A61C32-9765-4A58-91B0-E3542A3DB34F_2/xBH8A0EQkg1ONoI6wV0aFGFylxZTvEyjydwp6SkCWXUz/Image.png

具体实现:

首先需要定义一个工具类方法处理Json相关的转换,因为服务端要向客户端返回一些提示信息和数据,使用了fastjson,需要提前导入fastjson的jar包

public class CommunityUtil {public static String getJSONString(int code, String msg, Map map){JSONObject json = new JSONObject();json.put("code",code);json.put("msg",msg);if(map != null){for(String key:map.keySet()){json.put(key,map.get(key));}}return json.toJSONString();}public static String getJSONString(int code, String msg){return getJSONString(code,msg,null);}public static String getJSONString(int code){return getJSONString(code,null,null);}
}

在DiscussPostService中添加增加帖子的相关业务层代码逻辑:

public int addDiscussPost(DiscussPost post){// 参数不能为空if(post == null){throw new IllegalArgumentException("参数不能为空!");}// 转义HTML标记post.setTitle(HtmlUtils.htmlEscape(post.getTitle()));post.setContent(HtmlUtils.htmlEscape(post.getContent()));// 过滤敏感词post.setTitle(sensitiveFilter.filter(post.getTitle()));post.setContent(sensitiveFilter.filter(post.getContent()));return discussPostMapper.insertDiscussPost(post);}

新建DiscussPostController类,在里面写发布帖子的方法:

// 发布帖子
@RequestMapping(path = "/add",method = RequestMethod.POST)
@ResponseBody
public String addDiscussPost(String title,String content){// 判断是否是登录状态User user = hostHolder.getUser();if(user == null){return CommunityUtil.getJSONString(403,"你还没有登录哦!");}// 添加帖子DiscussPost post = new DiscussPost();post.setUserId(user.getId());post.setTitle(title);post.setContent(content);post.setCreateTime(new Date());discussPostService.addDiscussPost(post);// 报错的情况,将来统一处理return CommunityUtil.getJSONString(0,"发布成功!");
}

浏览器发送异步请求:

// 获取标题和内容var title = $("#recipient-name").val();var content = $("#message-text").val();// 发送异步请求$.post(CONTEXT_PATH+"/discuss/add",{"title":title,"content":content},function (data){data = $.parseJSON(data);// 在提示框中显示返回的消息$("#hintBody").text(data.msg);// 显示提示框$("#hintModal").modal("show");// 2秒后,自动隐藏提示框setTimeout(function(){$("#hintModal").modal("hide");// 刷新页面if(data.code == 0){window.location.reload();}}, 2000);})

帖子详情

按照数据层,业务层,表现层逐级来写就行,注意 帖子详情中的内容很多,点赞功能暂未实现,点赞的相关内容后面补充(大概redis部分…)

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/F89C0D01-64DA-48CC-A241-345FFCE48BFA_2/T5oH6HVMrkxG24AnOURIpc6MlrNqxm2sTC7PmrbgRTYz/Image.png

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/E795B24E-D81F-4F3B-8A0C-B542D93C6D28_2/gRNQY3llCkBV0rvmObmRfdlhdgI9wt6HINs4qtGyKscz/Image.png

表现层代码相对复杂(详细如下),主要是套娃套娃套娃🪆🪆🪆…

帖子,评论(帖子的评论),回复(帖子的评论的回评论)…

// 帖子详情@RequestMapping(path = "/detail/{discussPostId}", method = RequestMethod.GET)public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model, Page page) {// 帖子DiscussPost post = discussPostService.findDiscussPostById(discussPostId);model.addAttribute("post", post);// 作者User user = userService.findUserById(post.getUserId());model.addAttribute("user", user);// 点赞数量// 点赞状态// 评论分页信息page.setLimit(5);page.setPath("/discuss/detail/" + discussPostId);page.setRows(post.getCommentCount());// 评论: 给帖子的评论// 回复: 给评论的评论// 评论列表List commentList = commentService.findCommentsByEntity(ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());// 评论VO(详情)列表List> commentVoList = new ArrayList<>();if (commentList != null) {for (Comment comment : commentList) {// 评论VOMap commentVo = new HashMap<>();// 评论commentVo.put("comment", comment);// 评论的作者commentVo.put("user", userService.findUserById(comment.getUserId()));// 点赞数量// 点赞状态// 回复列表List replyList = commentService.findCommentsByEntity(ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE);// 回复VO(详情)列表List> replyVoList = new ArrayList<>();if (replyList != null) {for (Comment reply : replyList) {Map replyVo = new HashMap<>();// 回复replyVo.put("reply", reply);// 回复的作者replyVo.put("user", userService.findUserById(reply.getUserId()));// 回复的目标User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId());replyVo.put("target", target);// 点赞数量// 点赞状态replyVoList.add(replyVo);}}commentVo.put("replys", replyVoList);// 回复数量int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId());commentVo.put("replyCount", replyCount);commentVoList.add(commentVo);}}model.addAttribute("comments", commentVoList);return "/site/discuss-detail";}

添加评论

添加评论是在帖子详情页面,给帖子评论,或者是给帖子的评论进行评论(也叫回复)

因为既要增加评论的数据,又要修改帖子的评论数量,所以需要进行两步数据库操作,从安全性考虑,将这两步数据库操作放到一个事务里进行管理。(⚠️:事务管理)

使用声明式事务管理,用@Transactional进行注解,使用isolation属性声明事务的隔离级别,使用propagation属性声明事务的传播机制。

⚠️注意:只有对帖子进行评论的时候才需要更新帖子的评论数量,需要判断一下。业务层逻辑如下:

// 添加评论@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int addComment(Comment comment){if(comment == null){throw new IllegalArgumentException("参数不能为空!");}// 添加评论comment.setContent(HtmlUtils.htmlEscape(comment.getContent()));comment.setContent(sensitiveFilter.filter(comment.getContent()));int rows =  commentMapper.insertComment(comment);// 更新帖子评论数量if(comment.getEntityType() == ENTITY_TYPE_POST){int count = commentMapper.selectCountByEntity(comment.getEntityType(),comment.getEntityId());discussPostService.updateCommentCount(comment.getEntityId(),count);}return rows;}

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/F9345099-7A71-4C54-83AC-B48F4E98CDFF_2/0SvwAtO5zMJcZUQdBcPxzWtxsBScaZKtIymj0CaTxWEz/Image.png

补充:为什么在添加评论的时候既需要触发评论事件,又需要触发发帖事件?

  • 触发评论事件是因为,系统向被评论用户发送系统通知时是采用消息队列的方式实现的。
  • 仅在对帖子进行评论的时候会触发发帖事件,因为当对帖子进行评论时,帖子评论数量发生改变,也就是post.commentCount会有变化,所以要将Elasticsearch中的数据刷新一下。

私信列表

私信列表包括两个子功能,显示会话列表和私信详情信息。

▶️ 对于显示会话列表,需要查询当前用户的会话列表,每个会话只显示一条最新的私信,分页显示该列表,详细步骤主要包括:首先获得当前登录用户,并且设置分页信息,然后查询当前用户的会话列表,遍历会话列表,查询每一条会话所需要显示的其他信息(该会话私信的总数,该会话未读私信的数量,该会话对面用户的用户信息),除此之外,会话列表中还需要显示当前用户所有未读消息的数量。

▶️ 对于私信详情功能,需要查询某个会话所包含的私信列表,分页显示该列表,并且将显示的私信设置为已读状态,详细步骤主要包括:设置分页信息,根据会话Id查询私信列表,遍历私信列表,查询该条私信发送者有关的用户信息,最后将所有的未读私信设置为已读。

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/51D39B57-A1FB-422D-8A0E-7B85E6348494_2/xlILUHrnIJWaUn4maZkvm3muyyqk2WxniemKR6livwUz/Image.png

发送私信

采用异步方式发送私信,发送成功后刷新私信列表

🔎详细步骤:首先根据私信接收者的用户名获得接收者的用户信息,如果接收者用户不存在,直接返回错误提示;如果接收者用户存在,那么构造message对象,将message存到数据库中。

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/05983D89-9BB3-495B-90D0-2303AF9CB665_2/lcFdk1n7GWMam7HoPKBmTDHZn5upTg8IisWKegwRz2wz/Image.png

统一处理异常

服务端的三层架构:表现层 → 业务层 → 数据层。

浏览器发送的请求一律发给表现层,表现层调用业务层,业务层调用数据层。数据层出现异常后会抛出给它的调用者业务层,业务层会把异常抛出给表现层,所以无论是哪个层的异常,最终都会汇集到表现层,所以对表现层的异常进行捕获和处理就可以处理所有的异常。

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/93DB8CA5-66D0-47A1-8D94-53A45D1EB35D_2/RdT4bGqyNsp2TvHMRWRZqpwbnU6Fdu65wATALDmFXX0z/Image.png

🔎 Springboot提供的方案:只需要在特定路径src/main/resources/templates/error下,添加对应错误状态的页面,那么在发现相应错误的时候,就会自动的跳转到对应页面。错误状态页面的名字必须是错误状态,比如404。

跳出错误页面是表面上的处理,内在记录日志部分还没有处理。spring提供了@ControllerAdvice注解进行相关处理。

  • @ControllerAdvice:用于修饰类,表示该类是Controller的全局配置类,在此类中,可以对Controller进行如下三种全局配置:异常处理方案、绑定数据方案、绑定参数方案
  • 异常处理方案:@ExceptionHandler:用于修饰方法,该方法会在Controller出现异常后被调用,用于处理捕获到的异常
  • 绑定数据方案:@ModelAttribute:用于修饰方法,该方法会在Controller方法执行前被调用,用于为Model对象绑定参数
  • 绑定参数方案:@DataBinder:用于修饰方法,该方法会在Controller方法执行前被调用,用于绑定参数的转换器

具体实现:

  • 在Controller层写好处理error请求的方法getErrorPage()。
  • 利用@ControllerAdvice注解声明一个Controller全局配置类,对所有Controller的异常做统一的处理。在controller/advice路径下,新建一个类ExceptionAdvice,并用@ControllerAdvice进行注解。
  • 定义处理异常的方法handleException()并用@ExceptionHandler进行注解。
  • 记录日志,包括详细的日志信息。
  • 给浏览器一个响应,重定向到错误页面。注意:这里可能是普通请求也有可能是异步请求,需要区分处理,通过request对象来获取请求方式request.getHeader("x-requested-with");如果请求方式为 XMLHttpRequest,说明是异步请求,响应一个字符串。否则重定向到错误页面。

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/F3F39B20-BCB2-470A-B5CF-6593E72E8E0B_2/wCOszwfxzz984aA7j4Gv5BYvZWuEHYmdU7tFxmjXSZcz/Image.png

小结:不需要对Controller做任何处理,只需要使用@ControllerAdvice注解声明一个Controller的全局配置类,对添加了@Controller注解的所有类进行一个统一的异常处理。在全局配置类中使用@ExceptionHandler注解定义一个异常处理方法,对于所有类型的异常进行处理,处理过程是:首先输出异常信息(包括异常的详细信息),然后根据请求方式的不同,进行不同的响应,如果是异步请求,则响应给浏览器一个字符串;如果是普通请求,则重定向到错误页面。请求方式是通过request对象来获取的。

// 代码实现
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);@ExceptionHandler({Exception.class})public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {logger.error("服务器发生异常:" + e.getMessage());for(StackTraceElement element : e.getStackTrace()){logger.error(element.toString());}String xRequestedWith = request.getHeader("x-requested-with");if("XMLHttpRequest".equals(xRequestedWith)){response.setContentType("application/plain;charset=utf-8");PrintWriter writer = response.getWriter();writer.write(CommunityUtil.getJSONString(1,"服务器异常!"));}else{response.sendRedirect(request.getContextPath()+"/error");}}
}

统一记录日志

1.AOP的基本实现:

首先定义一个方面组件,该方面组件用@Component @Aspect处理,在方面组件里定义切点和通知。

切点:通过@Pointcut注解实现

// 示例
@Pointcut("execution(* com.community.service.*.*(..))")
public void pointcut() {}

通知:(共有五种通知方式)

  1. 在连接点前织入 @Before
// 示例
@Before("pointcut()")
public void before() {System.out.println("before");
}
  1. 在连接点后织入@After
// 示例
@After("pointcut()")
public void after() {System.out.println("after");
}
  1. 在有了返回值以后再处理逻辑 @AfterReturning
// 示例
@AfterReturning("pointcut()")
public void afterRetuning() {System.out.println("afterRetuning");
}
  1. 在抛异常的时候织入代码 @AfterThrowing
// 示例
@AfterThrowing("pointcut()")
public void afterThrowing() {System.out.println("afterThrowing");
}
  1. 环绕通知,既想在前面织入逻辑,又想在后面织入逻辑 @Around

要有返回值(Object)和参数(ProceedingJoinPoint joinPoint),抛出异常throws Throwable。除了环绕通知以外的其他通知也可以添加连接点JointPoint的参数。

利用连接点,jointPoint.proceed();就是调用目标对象的方法逻辑,将目标组件的返回值return(return obj)。

// 示例
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {System.out.println("around before");  // 在目标组件前面织入逻辑Object obj = joinPoint.proceed();System.out.println("around after");   // 在目标组件后面织入逻辑return obj;
}

2.统一记录日志需求:对所有的业务组件记录日志,在业务组件调用的一开始记录日志,采用@Before的方式。日志记录格式:用户xxx,在xxx,访问了xxx。

https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/8DD0B865-8ACD-4D75-881A-7B9664DC763F/BE7C609C-8CB4-4BC4-9D35-2413BBA348D0_2/XpQPCZI4Iuh8GF7a5inl9AaHz1Evntj18q1epT9U960z/Image.png

具体实现:

  • 定义一个方面组件:ServiceLogAspect,使用@Component@Aspect进行注解
  • 声明切点pointcut(),使用了@Pointcut注解, 切点为所有的业务层方法
@Pointcut("execution (* com.nowcoder.community.service.*.*(..))")
public void pointcut(){}
  • 定义通知,日志格式:用户[1.2.3.4],在[xxx],访问了[service.xxx()].因为是在业务组件调用的一开始就记录日志,也就是在切点前织入代码逻辑,所以使用@Before注解。
    问题1:用户IP怎么获取?可以通过request对象,先利用RequestContextHolder工具类中的getRequestAttributes()方法获取attributes;然后attributes.getRequest()获得request对象;最后使用request.getRemoteHost获得用户IP。
    问题2:如何获得当前时间?new Date()获取。
    问题3:如果获得调用的是哪个类哪个方法?jointPoint是程序织入的目标,也就是目标组件要调用的方法,通过jointPoint就可以得到调用的是哪个类的哪个方法。

    • joinPoint.getSignature().getDeclaringTypeName():获得类名;
    • joinPoint.getSignature().getName():获得方法名。

    补充:JoinPoint类,用来获取代理类和被代理类的信息。JoinPoint.getSignature()

@Before("pointcut()")
public void before(JoinPoint joinPoint){// 用户[1.2.3.4],在[xxx],访问了[com.nowcoder.community.service.xxx()].ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if(attributes == null){return;}HttpServletRequest request = attributes.getRequest();String ip = request.getRemoteHost();String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());String target = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();logger.info(String.format("用户[%s],在[%s],访问了[%s].",ip,now,target));
}

相关内容

热门资讯

中国人民银行发布一次性信用修复... 人民网北京12月22日电 (记者黄盛)中国人民银行今日发布关于实施一次性信用修复政策有关安排的通知。...
一次性信用修复政策6问6答 符合哪些条件的逾期信息可以适用一次性信用修复政策,作不予展示处理? 个人是否需要主动申请适用一次性...
一次性信用修复政策,一图读懂! 相关报道: 责编:李文玉 | 审核:李震 | 监审:古筝 (来源:中国人民银行)
家校纠纷的细节背后,藏着资源和... 石门县第二中学 一起普通的家校纠纷,源头是高二学生与班主任之间产生隔阂,后来演变成班主任退群、学生推...
一次性信用修复政策来了!细则详... 本文转自【央视新闻客户端】; 今天(22日),中国人民银行发布通知,实施一次性信用修复政策,支持信用...
原创 泰... 12月18日,中国驻泰国大使张建卫会见泰国新任总检察长易提蓬的这场会面,看似是一次常规外交互动,实则...
郑重提醒:一次性信用修复政策不... 央行发布一次性信用修复政策,支持信用受损但积极还款的个人高效便捷重塑信用。一次性信用修复政策实行“免...
央行发布一次性信用修复政策! 12月22日,中国人民银行发布通知,实施一次性信用修复政策,支持信用受损但积极还款的个人高效便捷重塑...
支持个人信用重塑!央行一次性信... 为支持信用受损但积极还款的个人高效便捷重塑信用,12月22日中国人民银行对外发布一次性信用修复政策有...