知识分享社区测试

项目概述

基于 Spring 的前后端分离版本知识分享社区,采用了SSM架构 ,前端使用ajax与后端进行交互 ,数据存储在MySQL 数据库中,实现了用户登录注册、个人信息管理、站内私信,帖⼦列表,发布帖⼦, 回复帖⼦,点赞帖子,搜索帖子等功能。
项目体验:http://175.178.163.221:58080/sign-in.html
项目源码:https://gitee.com/dragon-yushuang/forum.git

技术实现过程

  1. 使⽤统⼀返回格式+全局错误信息定义处理前后端交互时的返回结果
  2. 使⽤@ControllerAdvice+@ExceptionHandler实现全局异常处理
  3. 使⽤拦截器实现⽤⼾登录校验
  4. 使⽤MybatisGeneratorConfig⽣成常的增删改查⽅法
  5. 集成Swagger实现⾃动⽣成API测试接⼝
  6. 使⽤jQuery完成AJAX请求,并处理HTML⻚⾯标签

测试

测试用例

1692088260413.png

测试环境

操作系统:Windows 10家庭版
项目运行:CentOS、maven、JDK1.8
浏览器:Chorme、Edge、FireFox
自动化脚本环境:IDEA2022.2.3
网络:http://175.178.163.221:58080/
测试技术: 主要采用自动化测试以及手工测试
测试人员: 我

功能测试

对登陆页面进行的测试

场景1:输入账号和密码可以正常登录,点击登录
预期结果:页面跳转到首页
实际结果:页面跳转到首页,与预期结果相符

场景2:输入正确的用户名,密码错误,点击登录
预期结果:提示密码错误
实际结果:提示密码错误,与预期结果相符

对注册页面进行的测试

场景1:输入数据库不存在的用户名,昵称,密码与确认密码相同,点击注册
预期结果:页面跳转到登录页面
实际结果:页面跳转到登录页面,与预期结果相符

场景2:输入数据库不存在的用户名,昵称,密码与确认密码不相同,点击注册
预期结果:显示‘请检查确认密码’
实际结果:显示‘请检查确认密码’,与预期结果相符

场景3:输入数据库存在的用户名,昵称,密码与确认密码相同,点击注册
预期结果:提示用户已经存在
实际结果:提示用户已经存在,与预期结果相符

对首页进行的测试

场景1:未登录状态输入首页地址
预期结果:跳转到登录页面
实际结果:跳转到登录页面,与预期结果相符

场景2:登录后,跳转到首页,在搜索栏输入关键字‘数据库’,点击搜索
预期结果:展示与数据库有关的帖子列表
实际结果:展示与数据库有关的帖子列表,与预期结果相符

场景3:登录后,跳转到首页,在搜索栏输入关键字‘1’(数据库中不存在的标题),点击搜索
预期结果:展示‘还没有帖子’
实际结果:展示‘还没有帖子’,与预期结果相符

场景4:登录后,搜索后,点击任意一个板块
预期结果:展示具体板块的帖子列表
实际结果:展示与搜索关键字有关的帖子列表,与预期结果不相符

场景5:登录后,点击板块‘Java’,
预期结果:展示‘Java’板块的帖子列表,显示板块名,帖子总数
实际结果:展示‘Java’板块的帖子列表,与预期结果相符

场景6:登录后,点击板块‘Java’,
预期结果:展示‘Java’板块的帖子列表
实际结果:展示‘Java’板块的帖子列表,与预期结果相符

场景6:登录后,点击板块‘排行榜’,
预期结果:展示按(50%点赞数量+50%浏览数量)降序排列的帖子列表,展示帖子总数量
实际结果:展示按(50%点赞数量+50%浏览数量)降序排列的帖子列表,帖子总数量为0,与预期结果不相符

场景7:登录后,点击具体的一个帖子
预期结果:跳转到帖子详情页面,展示帖子的具体信息,帖子的作者,创作时间,浏览量
实际结果:跳转到帖子详情页面,展示帖子的具体信息,帖子的作者,创作时间,浏览量,与预期结果相符

场景8:登录后,点击‘发布帖子’,
预期结果:跳转到发布帖子页面
实际结果:跳转到发布帖子页面,与预期结果相符

场景9:登录后,右上点击‘铃铛’,
预期结果:跳出所有站内私信
实际结果:跳出所有站内私信,与预期结果相符

场景9:登录后,右上点击用户,点击我的帖子
预期结果:跳转到我的帖子页面,展示所有作者发布的帖子,个人介绍,发贴数,邮箱,注册日期
实际结果:跳转到我的帖子页面,展示所有作者发布的帖子,未展示个人介绍,发贴数、邮箱、注册日期不正确,与预期结果不相符

场景9:登录后,右上点击用户,点击个人中心
预期结果:跳转到个人中心,展示个人信息
实际结果:跳转到个人中心,展示个人信息,与预期结果相符

其他测试不一一展示了

兼容性测试

编号 具体内容 测试数据 步骤 预期结果 实际结果
1 IE浏览器 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 与预期结果一致
2 QQ浏览器 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 与预期结果一致
3 Chorme浏览器 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 与预期结果一致
4 Firefox浏览器 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 与预期结果一致
5 各个浏览器的不同版本测试 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 未测试
6 Mac系统 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 未测试
7 Linux系统 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 未测试
8 华硕、联想、苹果、华为等不同电脑 账号:test01,密码:123456 打开浏览器,输入http://175.178.163.221:58080/sign-in.html,输入账号密码,点击登录 可以正常使用 未测试

性能测试

登录:

Action()
{
    
    

    lr_rendezvous("login");
    lr_start_transaction("Login");
    web_submit_data("login", 
                    "Action=http://175.178.163.221:58080/user/login", 
                    "Method=POST", 
                    "RecContentType=application/json", 
                    "Referer=http://175.178.163.221:58080/sign-in.html", 
                    "Snapshot=t1.inf", 
                    "Mode=HTML", 
                    ITEMDATA, 
                    "Name=username", "Value={user_name}", ENDITEM, 
                    "Name=password", "Value={password}", ENDITEM, 
                    EXTRARES, 
                    "Url=info", "Referer=http://175.178.163.221:58080/index.html", ENDITEM, 
                    "Url=../board/topList", "Referer=http://175.178.163.221:58080/index.html", ENDITEM, 
                    "Url=../message/getUnreadCount", "Referer=http://175.178.163.221:58080/index.html", ENDITEM, 
                    "Url=../message/getAll", "Referer=http://175.178.163.221:58080/index.html", ENDITEM, 
                    "Url=../article/getAllByBoardId", "Referer=http://175.178.163.221:58080/index.html", ENDITEM, 
                    LAST);
    lr_end_transaction("Login",LR_AUTO);

    return 0;
}

1692153075849.png
1692153092150.png

1692154270167.png

1692154468379.png

1692154508190.png

相关技术及⼯具

服务器端技术 :Spring 、Spring Boot 、Spring MVC 、MyBatis
浏览器端技术 :HTML, CSS, JavaScript 、jQuery 、Bootstrap
数据库 :MySQL
项⽬构建⼯具 :Maven
版本控制⼯具 :Git + GITEE

核心功能

  • 登录注册
  • 帖⼦按版块分类,对帖子热度进行排行,按关键字搜索帖子
  • 帖⼦列表, 发布帖⼦, 删除帖⼦, 回复帖⼦,点赞帖子等功能
  • 站内私信其他用户,个⼈主⻚的展⽰,个人信息密码编辑,密码修改

技术实现过程

  1. 使⽤统⼀返回格式+全局错误信息定义处理前后端交互时的返回结果
  2. 使⽤@ControllerAdvice+@ExceptionHandler实现全局异常处理
  3. 使⽤拦截器实现⽤⼾登录校验
  4. 使用MD5加盐加密实现密码加密,保护用户账号安全性
  5. 使⽤MybatisGeneratorConfig⽣成常的增删改查⽅法
  6. 集成Swagger实现⾃动⽣成API测试接⼝
  7. 使⽤jQuery完成AJAX请求,并处理HTML⻚⾯标签

数据库设计

经过简单分析:“版块类别” 和 “版块帖⼦数量” 都可以归结到 “版块” 类,做为 “版块” 类的属性;“帖⼦标题” 和 “帖⼦正⽂” 都可以归结到 “帖⼦” 类,做为 “帖⼦” 类的属性;“权限” 可以归结到 “⽤⼾”类,做为“⽤⼾”类的属性。⾄此,针对发布帖⼦这个⽤例,就确定了三个
类,分别是:⽤⼾、版块、帖⼦。
再者就是帖子回复在帖子详情里,站内私信在主页,因此还有帖子回复表和站内私信表。

-- 创建数据库,并指定字符集
-- ----------------------------
drop database if exists forum_db;
create database forum_db character set utf8mb4 collate utf8mb4_general_ci;
-- 选择数据库
use forum_db;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 创建帖⼦表 t_article
-- ----------------------------
DROP TABLE IF EXISTS `t_article`;
CREATE TABLE `t_article` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '帖⼦编号,主键,⾃增',
 `boardId` bigint(20) NOT NULL COMMENT '关联板块编号,⾮空',
 `userId` bigint(20) NOT NULL COMMENT '发帖⼈,⾮空,关联⽤⼾编号',
 `title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '标题,⾮空,最⼤⻓度100个字符',
 `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL
COMMENT '帖⼦正⽂,⾮空',
 `visitCount` int(11) NOT NULL DEFAULT 0 COMMENT '访问量,默认0',
 `replyCount` int(11) NOT NULL DEFAULT 0 COMMENT '回复数据,默认0',
 `likeCount` int(11) NOT NULL DEFAULT 0 COMMENT '点赞数,默认0',
 `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态 0正常 1 禁⽤,默认0',
 `deleteState` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否删除 01 是,默认0',
 `createTime` datetime NOT NULL COMMENT '创建时间,精确到秒,⾮空',
 `updateTime` datetime NOT NULL COMMENT '修改时间,精确到秒,⾮空',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = 
utf8mb4_general_ci COMMENT = '帖⼦表' ROW_FORMAT = Dynamic;


DROP TABLE IF EXISTS `t_article_reply`;
CREATE TABLE `t_article_reply` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号,主键,⾃增',
 `articleId` bigint(20) NOT NULL COMMENT '关联帖⼦编号,⾮空',
 `postUserId` bigint(20) NOT NULL COMMENT '楼主⽤⼾,关联⽤⼾编号,⾮空',
 `replyId` bigint(20) NULL DEFAULT NULL COMMENT '关联回复编号,⽀持楼中楼',
 `replyUserId` bigint(20) NULL DEFAULT NULL COMMENT '楼主下的回复⽤⼾编号,⽀持楼
中楼',
 `content` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '回贴内容,⻓度500个字符,⾮空',
 `likeCount` int(11) NOT NULL DEFAULT 0 COMMENT '点赞数,默认0',
 `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态 0 正常,1禁⽤,默认0',
 `deleteState` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否删除 01是,默认0',
 `createTime` datetime NOT NULL COMMENT '创建时间,精确到秒,⾮空',
 `updateTime` datetime NOT NULL COMMENT '更新时间,精确到秒,⾮空',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = 
utf8mb4_general_ci COMMENT = '帖⼦回复表' ROW_FORMAT = Dynamic;


-- ----------------------------
-- 创建版块表 t_board
-- ----------------------------
DROP TABLE IF EXISTS `t_board`;
CREATE TABLE `t_board` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '版块编号,主键,⾃增',
 `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL
COMMENT '版块名,⾮空',
 `articleCount` int(11) NOT NULL DEFAULT 0 COMMENT '帖⼦数量,默认0',
 `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序优先级,升序,默认0,',
 `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态,0 正常,1禁⽤,默认0',
 `deleteState` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否删除 0否,1是,默认0',
 `createTime` datetime NOT NULL COMMENT '创建时间,精确到秒,⾮空',
 `updateTime` datetime NOT NULL COMMENT '更新时间,精确到秒,⾮空',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = 
utf8mb4_general_ci COMMENT = '版块表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- 创建站内信表 for t_message
-- ----------------------------
DROP TABLE IF EXISTS `t_message`;
CREATE TABLE `t_message` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '站内信编号,主键,⾃增',
 `postUserId` bigint(20) NOT NULL COMMENT '发送者,并联⽤⼾编号',
 `receiveUserId` bigint(20) NOT NULL COMMENT '接收者,并联⽤⼾编号',
 `content` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '内容,⾮空,⻓度255个字符',
 `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态 0未读 1已读,默认0',
 `deleteState` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否删除 0否,1是,默认0',
 `createTime` datetime NOT NULL COMMENT '创建时间,精确到秒,⾮空',
 `updateTime` datetime NOT NULL COMMENT '更新时间,精确到秒,⾮空',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = 
utf8mb4_general_ci COMMENT = '站内信表' ROW_FORMAT = Dynamic;


-- ----------------------------
-- 创建⽤⼾表 for t_user
-- ----------------------------
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '⽤⼾编号,主键,⾃增',
 `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '⽤⼾名,⾮空,唯⼀',
 `password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '加密后的密码',
 `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT
NULL COMMENT '昵称,⾮空',
 `phoneNum` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL
DEFAULT NULL COMMENT '⼿机号',
 `email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL
DEFAULT NULL COMMENT '邮箱地址',
 `gender` tinyint(4) NOT NULL DEFAULT 2 COMMENT '012保密,⾮空,默认2',
 `salt` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL
COMMENT '为密码加盐,⾮空',
 `avatarUrl` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci 
NULL DEFAULT NULL COMMENT '⽤⼾头像URL,默认系统图⽚',
 `articleCount` int(11) NOT NULL DEFAULT 0 COMMENT '发帖数量,⾮空,默认0',
 `isAdmin` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否管理员,01是,默认0',
 `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL
DEFAULT NULL COMMENT '备注,⾃我介绍',
 `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态 0 正常,1 禁⾔,默认0',
 `deleteState` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否删除 01是,默认0',
 `createTime` datetime NOT NULL COMMENT '创建时间,精确到秒',
 `updateTime` datetime NOT NULL COMMENT '更新时间,精确到秒',
 PRIMARY KEY (`id`) USING BTREE,
 UNIQUE INDEX `user_username_uindex`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = 
utf8mb4_general_ci COMMENT = '⽤⼾表' ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;
-- 写⼊版块信息数据
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (1, 'Java', 0, 1, 0, 0, 
'2023-01-14 19:02:18', '2023-01-14 19:02:18');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (2, 'C++', 0, 2, 0, 0, '2023-
01-14 19:02:41', '2023-01-14 19:02:41');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (3, '前端技术', 0, 3, 0, 0, 
'2023-01-14 19:02:52', '2023-01-14 19:02:52');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (4, 'MySQL', 0, 4, 0, 0, 
'2023-01-14 19:03:02', '2023-01-14 19:03:02');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (5, '⾯试宝典', 0, 5, 0, 0, 
'2023-01-14 19:03:24', '2023-01-14 19:03:24');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (6, '经验分享', 0, 6, 0, 0, 
'2023-01-14 19:03:48', '2023-01-14 19:03:48');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (7, '招聘信息', 0, 7, 0, 0, 
'2023-01-25 21:25:33', '2023-01-25 21:25:33');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (8, '福利待遇', 0, 8, 0, 0, 
'2023-01-25 21:25:58', '2023-01-25 21:25:58');
INSERT INTO `t_board` (`id`, `name`, `articleCount`, `sort`, `state`, 
`deleteState`, `createTime`, `updateTime`) VALUES (9, '灌⽔区', 0, 9, 0, 0, 
'2023-01-25 21:26:12', '2023-01-25 21:26:12');

    

项目搭建与软件开发

application.yml配置

spring:
  application:
    name: 线上论坛 # 项目名
  output:
    ansi:
      enabled: ALWAYS # 控制台输出彩色日志
  profiles:
    active: dev
  mvc:
    pathmatch:
      matching-strategy: ANT_PATH_MATCHER #Springfox-Swagger兼容性配置
    favicon:
      enable: false
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/forum_db?characterEncoding=utf8&useSSL=false&zeroDateTimeBehavior=convertToNull
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

    # JSON序列化配置
    jackson:
#      date-format: yyyy-MM-dd HH:mm:ss # 日期格式
      date-format: yyyy-MM-dd HH:mm:ss
      time-zone: GMT+8
      default-property-inclusion: NON_NULL # 不为null时序列化


# 设置 Mybatis 的 xml 保存路径
mybatis:
  mapper-locations: classpath:mapper/**/*.xml # 指定 xxxMapper.xml的扫描路径
#  mapper-locations: classpath:/resources/mapper/*Mapper.xml
  type-aliases-package: com.example.forum.dao
  configuration: # 配置打印 MyBatis 执行的 SQL
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    map-underscore-to-camel-case: true  #自动驼峰转换

# 配置打印 MyBatis 执行的 SQL
# 日志配置
logging:
  pattern:
    dateformat: yyyy-MM-dd HH:mm:ss # 日期格式
  level:
    root: info # 默认日志级别
    com.bitejiuyeke.forum: debug # 指定包的日志级别
  file:
    path: ./logs # 日志保存目录

server:
  port: 58080

# 项目自定义相关配置
forum:
  login: # 关于登录的配置项
    url: sign-in.html  # 未登录状况下强制跳转页面
  index: # 关于首页的配置项
    board-num: 10 # 首页要显示的版块数量



公共部分

创建⼯程结构

⼯具层(common) => 统⼀返回类, 定义状态码
配置层(config) => session,Mybatis,Swagger配置
控制器层(controller) => 提供URL映射,⽤来接收参数并做校验,调⽤Service中的业务代码,返回执⾏结果
持久层(dao) => 数据库访问
异常捕获(exception) => 统⼀异常处理
拦截(interceptor) => 登录拦截器
实体层(model) => 实体类
服务层(service) => 业务处理相关的接⼝与实现,所有业务都在Services中实现
工具类(utils) => MD5+盐加密
resources/mapper => Mybaits映射⽂件,配置数据库实体与类之间的映射关系
resources/static => 前端资源
resources/mybatis => 前端资源
1692065823387.png

定义状态码
public enum ResultCode {
    
    
    /** 定义状态码 */
    SUCCESS                 (0, "操作成功"),
    FAILED                  (1000, "操作失败"),
    FAILED_UNAUTHORIZED     (1001, "未授权"),
    FAILED_PARAMS_VALIDATE  (1002, "参数校验失败"),
    FAILED_FORBIDDEN        (1003, "禁止访问"),
    FAILED_CREATE           (1004, "新增失败"),
    FAILED_NOT_EXISTS       (1005, "资源不存在"),
    FAILED_USER_EXISTS      (1101, "用户已存在"),
    FAILED_USER_NOT_EXISTS  (1102, "用户不存在"),
    FAILED_LOGIN            (1103, "用户名或密码错误"),
    FAILED_USER_BANNED      (1104, "您已被禁言, 请联系管理员, 并重新登录."),
    FAILED_TWO_PWD_NOT_SAME (1105, "两次输入的密码不一致"),
    // 版块相关的定义
    FAILED_BOARD_EXISTS      (1201, "版块已存在"),
    FAILED_BOARD_NOT_EXISTS  (1202, "版块不存在"),
    ERROR_SERVICES          (2000, "服务器内部错误"),
    ERROR_IS_NULL           (2001, "IS NULL.");

    long code;
    String message;

    public long getCode() {
    
    
        return code;
    }

    public String getMessage() {
    
    
        return message;
    }


    ResultCode(long code, String message) {
    
    
        this.code = code;
        this.message = message;
    }

    @Override
    public String toString() {
    
    
        return "code = " + code + ", message = " + message + ".";
    }
}

关于数据库的操作

使用Mybatis-Generator生成
  1. 实体类
  2. 映射⽂件xxxMapper.xml
  3. Dao类,xxxMapper.java

pom.xml配置:

<plugin>
                <groupId>org.mybatis.generator</groupId>
                <artifactId>mybatis-generator-maven-plugin</artifactId>
                <version>${mybatis-generator-plugin-version}</version>
                <executions>
                    <execution>
                        <id>Generate MyBatis Artifacts</id>
                        <phase>deploy</phase>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <!-- 相关配置 -->
                <configuration>
                    <!-- 打开日志 -->
                    <verbose>true</verbose>
                    <!-- 允许覆盖 -->
                    <overwrite>true</overwrite>
                    <!-- 配置文件路径 -->
                    <configurationFile>
                        src/main/resources/mybatis/generatorConfig.xml
                    </configurationFile>
                </configuration>
            </plugin>

generatorConfig.xml配置:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<generatorConfiguration>
    <!-- 驱动包路径,location中路径替换成自己本地路径 -->
    <classPathEntry location="D:\JavaUltimate\.m2\repository\com\mysql\mysql-connector-j\8.0.33\mysql-connector-j-8.0.33.jar"/>

    <context id="DB2Tables" targetRuntime="MyBatis3">
        <!-- 禁用自动生成的注释 -->
        <commentGenerator>
            <property name="suppressAllComments" value="true"/>
            <property name="suppressDate" value="true"/>
        </commentGenerator>

        <!-- 连接配置 -->
        <jdbcConnection driverClass="com.mysql.jdbc.Driver"
                        connectionURL="jdbc:mysql://127.0.0.1:3306/forum_db?characterEncoding=utf8&amp;useSSL=false"
                        userId="root"
                        password="123456">
        </jdbcConnection>

        <javaTypeResolver>
            <!-- 小数统一转为BigDecimal -->
            <property name="forceBigDecimals" value="false"/>
        </javaTypeResolver>

        <!-- 实体类生成位置 -->
        <javaModelGenerator targetPackage="com.example.forum.model" targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
            <property name="trimStrings" value="true"/>
        </javaModelGenerator>

        <!-- mapper.xml生成位置 -->
        <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
            <property name="enableSubPackages" value="true"/>
        </sqlMapGenerator>

        <!-- DAO类生成位置 -->
        <javaClientGenerator type="XMLMAPPER" targetPackage="com.example.forum.dao" targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
        </javaClientGenerator>

        <!-- 配置生成表与实例, 只需要修改表名tableName, 与对应类名domainObjectName 即可-->
        <table tableName="t_article" domainObjectName="Article" enableSelectByExample="false"
               enableDeleteByExample="false" enableDeleteByPrimaryKey="false" enableCountByExample="false"
               enableUpdateByExample="false">
            <!-- 类的属性用数据库中的真实字段名做为属性名, 不指定这个属性会自动转换 _ 为驼峰命名规则-->
            <property name="useActualColumnNames" value="true"/>
        </table>
        <table tableName="t_article_reply" domainObjectName="ArticleReply" enableSelectByExample="false"
               enableDeleteByExample="false" enableDeleteByPrimaryKey="false" enableCountByExample="false"
               enableUpdateByExample="false">
            <property name="useActualColumnNames" value="true"/>
        </table>
        <table tableName="t_board" domainObjectName="Board" enableSelectByExample="false" enableDeleteByExample="false"
               enableDeleteByPrimaryKey="false" enableCountByExample="false" enableUpdateByExample="false">
            <property name="useActualColumnNames" value="true"/>
        </table>
        <table tableName="t_message" domainObjectName="Message" enableSelectByExample="false"
               enableDeleteByExample="false" enableDeleteByPrimaryKey="false" enableCountByExample="false"
               enableUpdateByExample="false">
            <property name="useActualColumnNames" value="true"/>
        </table>
        <table tableName="t_user" domainObjectName="User" enableSelectByExample="false" enableDeleteByExample="false"
               enableDeleteByPrimaryKey="false" enableCountByExample="false" enableUpdateByExample="false">
            <property name="useActualColumnNames" value="true"/>
        </table>
    </context>
</generatorConfiguration>

1692061498215.png
1692061513811.png
1692061527174.png
1692061540658.png
1692061556219.png

自己编写

1692061269275.png
1692061302888.png
1692061316357.png
1692061387559.png
1692061414067.png

加盐加密

项⽬中使⽤commons-codec,它是Apache提供的⽤于摘要运算、编码解码的⼯具包。常⻅的编码 解码⼯具Base64、MD5、Hex、SHA1、DES等。

<!-- 编码解码加密⼯具包-->
<dependency>
 <groupId>commons-codec</groupId>
 <artifactId>commons-codec</artifactId>
</dependency>

盐生成:

public class UUIDUtils {
    
    

    /**
* 生成一个标准的36字符的UUID
* @return
*/
    public static String UUID_36 () {
    
    
        return UUID.randomUUID().toString();
    }

    /**
* 生成一个32字符的UUID
* @return
*/
    public static String UUID_32 () {
    
    
        return UUID.randomUUID().toString().replace("-", "");
    }

}

MD5加密:

public class MD5Utils {
    
    
    /**
     * 普通MD5加密
     * @param str 原始字符串
     * @return 一次MD5加密后的密文
     */
    public static String md5 (String str) {
    
    
        return DigestUtils.md5Hex(str);
    }

    /**
     * 原始字符串与Key组合进行一次MD5加密
     * @param str 原始字符串
     * @param key
     * @return 组合字符串一次MD5加密后的密文
     */
    public static String md5 (String str, String key) {
    
    
        return DigestUtils.md5Hex(str + key);
    }

    /**
     * 原始字符串加密后与扰动字符串组合再进行一次MD5加密
     * @param str 原始字符串
     * @param salt 扰动字符串
     * @return 加密后的密文
     */
    public static String md5Salt (String str, String salt) {
    
    
        return DigestUtils.md5Hex(DigestUtils.md5Hex(str) + salt);
    }

    /**
     * 校验原文与盐加密后是否与传入的密文相同
     * @param original 原字符串
     * @param salt 扰动字符串
     * @param ciphertext 密文
     * @return true 相同, false 不同
     */
    public static boolean verifyOriginalAndCiphertext (String original, String salt, String ciphertext) {
    
    
        String md5text = md5Salt(original, salt);
        if (md5text.equalsIgnoreCase(ciphertext)) {
    
    
            return true;
        }
        return false;
    }
}

以登录为例

在dao层的UserMapper定义方法
/**
* 根据用户名查询用户信息
* @param username 用户名
* @return
*/
User selectByName (String username);
在对应映射文件UserExtMapper.xml中编写sql语句:
<!-- 根据用户名查询用户信息-->
<select id="selectByName" parameterType="java.lang.String" resultMap="BaseResultMap">
  select
  <include refid="Base_Column_List" />
  from t_user
  where username = #{username,jdbcType=VARCHAR}
</select>
在service层的IUserService定义接口:
/**
* 根据用户名查询用户信息
* @param username 用户名
* @return
*/
User selectByName (String username);

/**
* 创建普通用户
* @param user 用户信息
*/
void createNormalUser(User user);

/**
* 用户登录
* @param username 用户名
* @param password 密码
* @return
*/
User login(String username, String password);
在UserServiceImpl中实现接口:
@Resource
    private UserMapper userMapper;

@Override
    public User selectByName(String username) {
    
    
    // 非空校验
    if (StringUtils.isEmpty(username)) {
    
    
    // 打印日志
    log.warn(ResultCode.FAILED_PARAMS_VALIDATE.toString());
    // 抛出异常
    throw new ApplicationException(AppResult.failed(ResultCode.FAILED_PARAMS_VALIDATE));
}
// 根据用户名查询用户信息
User user = userMapper.selectByName(username);
// 返回结果
return user;
}
@Override
    public User login(String username, String password) {
    
    
    // 非空校验
    if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
    
    
        // 打印日志
        log.warn(ResultCode.FAILED_PARAMS_VALIDATE.toString());
        // 抛出异常
        throw new ApplicationException(AppResult.failed(ResultCode.FAILED_PARAMS_VALIDATE));
    }
    // 根据用户名查询用户信息
    User user = selectByName(username);
    // 校验用户是否存在
    if (user == null) {
    
    
        // 打印日志
        log.info(ResultCode.FAILED_USER_NOT_EXISTS + ", username = " + username);
        // 抛出异常
        throw new ApplicationException(AppResult.failed(ResultCode.FAILED_LOGIN));
    }
    // 校验密码是否正确
    String encryptPassword = MD5Utils.md5Salt(password, user.getSalt());
    if (!encryptPassword.equalsIgnoreCase(user.getPassword())) {
    
    
        // 打印日志
        log.info( "密码输入错误 , username = " + username + ", password = " + password);
        // 抛出异常
        throw new ApplicationException(AppResult.failed(ResultCode.FAILED_LOGIN));
    }
    // 返回用户信息
    return user;
}
测试
@Test
    void login() throws JsonProcessingException {
    
    
        // 正常用户
        User user = userService.login("yuni", "123456");
System.out.println(objectMapper.writeValueAsString(user));

//        // 错误用户
//        user = userService.login("123456", "123456");
//        System.out.println(objectMapper.writeValueAsString(user));
}
在controller层的UserController中编写(session):
@ApiOperation("用户登录")
    @PostMapping("login")
    @ResponseBody
    public AppResult login(HttpServletRequest request,
                           @ApiParam("用户名") @RequestParam("username") @NonNull String username,
                           @ApiParam("密码") @RequestParam("password")  @NonNull String password){
    
    
        // 调用Service
        User user = userService.login(username, password);
        // 在session中保存当前登录的用户信息
        // 1. 获取session对象
        HttpSession session = request.getSession(true);
        // 2. 把用户信息保存在Session中
        session.setAttribute(AppConfig.SESSION_USER_KEY, user);
        // 返回成功
        return AppResult.success("登录成功");
    }
进行接口测试(Swagger接口详情在下边介绍)

http://127.0.0.1:58080/swagger-ui/index.html

1692089733789.png

编写前端接口:
// 发送AJAX请求,成功后跳转到index.html
$.ajax({
    
    
  // 请求的方法类型
  type : 'POST',
  // API的URL
  url : 'user/login',
  // 数据格式
  contentType : 'application/x-www-form-urlencoded',
  // 提交的数据
  data : postData,
  // 成功回调
  success : function(respData) {
    
    
    if (respData.code == 0) {
    
    
      // 成功
      location.assign('/index.html');
    } else {
    
    
      // 失败
      $.toast({
    
    
        heading: '警告',
        text: respData.message,
        icon: 'warning'
      });
    }
  },
  // 失败 (HTTP)
  error : function() {
    
    
    $.toast({
    
    
      heading: '错误',
      text: '访问出现问题,请联系管理员',
      icon: 'error'
    });
  }
});

拦截器

public class LoginInterceptor implements HandlerInterceptor {
    
    
    // 从配置⽂件中获取默认登录⻚的URL
    // 从配置文件中读取配置内容
    @Value("${forum.login.url}")
    private String defaultURL;

    /**
     * 预处理(请求的前置处理)回调方法<br/>
     *
     * @return true 继续请求流程 </br> false 中止请求流程
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    
        // 获取session
        HttpSession session = request.getSession(false);
        // 判断session和session中保存的用户信息是否有效
        if (session != null && session.getAttribute(AppConfig.SESSION_USER_KEY) != null) {
    
    
            // 校验通过
            return true;
        }
        // 校验不通过时要处理的逻辑
        // 1. 返回一个错误的HTTP状态码
//        response.setStatus(403);
        // 2. 跳转到某一个页面
//        response.sendRedirect("/sign-in.html");
        // 对URL前缀做校验(确保目标URL从根目录开发)
        if (!defaultURL.startsWith("/")) {
    
    
            defaultURL = "/" + defaultURL;
        }
        response.sendRedirect(defaultURL);
        // 校验不能过
        return false;
    }
}
@Configuration
public class AppInterceptorConfigurer implements WebMvcConfigurer {
    
    
    @Resource
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        // 添加登录拦截器
        registry.addInterceptor(loginInterceptor) // 添加⽤⼾登录拦截器
                .addPathPatterns("/**") // 拦截所有请求
                .excludePathPatterns("/sign-in.html") // 排除登录HTML
                .excludePathPatterns("/sign-up.html") // 排除注册HTML
                .excludePathPatterns("/user/login") // 排除登录api接⼝
                .excludePathPatterns("/user/register") // 排除注册api接⼝
                .excludePathPatterns("/user/logout") // 排除退出api接⼝
                .excludePathPatterns("/swagger*/**") // 排除登录swagger下所有
                .excludePathPatterns("/v3*/**") // 排除登录v3下所有,与swag
                .excludePathPatterns("/dist/**") // 排除所有静态⽂件
                .excludePathPatterns("/image/**")
                .excludePathPatterns("/**.ico")
                .excludePathPatterns("/js/**");
    }
}

接口

http://127.0.0.1:58080/swagger-ui/index.html#/
解决SpringBoot 2.6.0以上与Springfox3.0.0 不兼容的问题,涉及SpringBoot 版本升级过程中的⼀些内部实现变化,具体说明在修改配置⽂件部分
使用Swagger生成接口:

@EnableOpenApi
@Configuration
public class SwaggerConfig {
    
    
    @Bean
    public Docket createApi() {
    
    
        Docket docket = new Docket(DocumentationType.OAS_30)
                .apiInfo(apiInfo())
                .select()

                .apis(RequestHandlerSelectors.basePackage("com.example.forum.controller"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }
    // 配置API基本信息
    private ApiInfo apiInfo() {
    
    
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("线上论坛系统API")
                .description("线上论坛系统前后端分离API测试")
                .contact(new Contact("Bit Tech",
                        "https://edu.bitejiuyeke.com", "[email protected]"))
                .version("1.0")
                .build();
        return apiInfo;
    }
    /**
     * 解决SpringBoot 6.0以上与Swagger 3.0.0 不兼容的问题
     * 复制即可
     **/
    @Bean
    public WebMvcEndpointHandlerMapping
    webEndpointServletHandlerMapping(WebEndpointsSupplier webEndpointsSupplier,

                                     ServletEndpointsSupplier servletEndpointsSupplier,

                                     ControllerEndpointsSupplier controllerEndpointsSupplier,

                                     EndpointMediaTypes endpointMediaTypes, CorsEndpointProperties corsProperties,

                                     WebEndpointProperties webEndpointProperties, Environment environment) {
    
    
        List<ExposableEndpoint<?>> allEndpoints = new ArrayList();
        Collection<ExposableWebEndpoint> webEndpoints =
                webEndpointsSupplier.getEndpoints();
        allEndpoints.addAll(webEndpoints);
        allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
        allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
        String basePath = webEndpointProperties.getBasePath();
        EndpointMapping endpointMapping = new EndpointMapping(basePath);
        boolean shouldRegisterLinksMapping =
                this.shouldRegisterLinksMapping(webEndpointProperties, environment,
                        basePath);
        return new WebMvcEndpointHandlerMapping(endpointMapping, webEndpoints,
                endpointMediaTypes,
                corsProperties.toCorsConfiguration(), new
                EndpointLinksResolver(allEndpoints, basePath),
                shouldRegisterLinksMapping, null);
    }
    private boolean shouldRegisterLinksMapping(WebEndpointProperties
                                                       webEndpointProperties, Environment environment,
                                               String basePath) {
    
    
        return webEndpointProperties.getDiscovery().isEnabled() &&
                (StringUtils.hasText(basePath)
                        ||
                        ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT));
    }
}
spring
  mvc:
    pathmatch:
      matching-strategy: ANT_PATH_MATCHER #Springfox-Swagger兼容性配置

API常⽤注解 :
• @Api: 作⽤在Controller上,对控制器类的说明
◦ tags=“说明该类的作⽤,可以在前台界⾯上看到的注解”
• @ApiModel: 作⽤在响应的类上,对返回响应数据的说明
• @ApiModelProerty:作⽤在类的属性上,对属性的说明
• @ApiOperation: 作⽤在具体⽅法上,对API接⼝的说明
• @ApiParam: 作⽤在⽅法中的每⼀个参数上,对参数的属性进⾏说明

格式规范:

// 请求
GET /user/info HTTP/1.1
GET /user/info?id=1 HTTP/1.1
// 响应
HTTP/1.1 200
Content-type: applicatin/json

{
    
    
 "code": 200,
 "message": "成功",
 "data": null
}
用户接口

1692063100679.png

板块接口

1692063125302.png

帖子接口

1692087803923.png

站内私信接口

1692063193236.png

帖子回复接口

1692063435918.png

<!-- API⽂档⽣成,基于swagger2 -->
<dependency>
  <groupId>io.springfox</groupId>
  <artifactId>springfox-boot-starter</artifactId>
  <version>$
package com.example.forum.config;

import org.springframework.core.env.Environment;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.web.*;
import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.oas.annotations.EnableOpenApi;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
* @author longyushuang
* @description: $
* @param:
* @return:
* @date:
*/
@EnableOpenApi
    @Configuration
    public class SwaggerConfig {
    
    
        @Bean
        public Docket createApi() {
    
    
            Docket docket = new Docket(DocumentationType.OAS_30)
                .apiInfo(apiInfo())
                .select()

                .apis(RequestHandlerSelectors.basePackage("com.example.forum.controller"))
                .paths(PathSelectors.any())
                .build();
            return docket;
        }
        // 配置API基本信息
        private ApiInfo apiInfo() {
    
    
            ApiInfo apiInfo = new ApiInfoBuilder()
                .title("线上论坛系统API")
                .description("线上论坛系统前后端分离API测试")
                .contact(new Contact("Bit Tech",
                                     "https://edu.bitejiuyeke.com", "[email protected]"))
                .version("1.0")
                .build();
            return apiInfo;
        }
        /**
* 解决SpringBoot 6.0以上与Swagger 3.0.0 不兼容的问题
* 复制即可
**/
        @Bean
        public WebMvcEndpointHandlerMapping
        webEndpointServletHandlerMapping(WebEndpointsSupplier webEndpointsSupplier,

                                         ServletEndpointsSupplier servletEndpointsSupplier,

                                         ControllerEndpointsSupplier controllerEndpointsSupplier,

                                         EndpointMediaTypes endpointMediaTypes, CorsEndpointProperties corsProperties,

                                         WebEndpointProperties webEndpointProperties, Environment environment) {
    
    
            List<ExposableEndpoint<?>> allEndpoints = new ArrayList();
        Collection<ExposableWebEndpoint> webEndpoints =
        webEndpointsSupplier.getEndpoints();
        allEndpoints.addAll(webEndpoints);
        allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
        allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
        String basePath = webEndpointProperties.getBasePath();
        EndpointMapping endpointMapping = new EndpointMapping(basePath);
        boolean shouldRegisterLinksMapping =
        this.shouldRegisterLinksMapping(webEndpointProperties, environment,
        basePath);
        return new WebMvcEndpointHandlerMapping(endpointMapping, webEndpoints,
        endpointMediaTypes,
        corsProperties.toCorsConfiguration(), new
        EndpointLinksResolver(allEndpoints, basePath),
        shouldRegisterLinksMapping, null);
        }
        private boolean shouldRegisterLinksMapping(WebEndpointProperties
        webEndpointProperties, Environment environment,
        String basePath) {
    
    
        return webEndpointProperties.getDiscovery().isEnabled() &&
        (StringUtils.hasText(basePath)
        ||
        ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT));
        }
        }

成果展示

登录页面
1692064031733.png
注册:
1692064116654.png
主页:
1692087845780.png
帖子详情:
![(3Z~QN$TM}KSSB_]5H0Z{]S.png](https://img-blog.csdnimg.cn/img_convert/6cf23abb1601d48f090f5430651a4540.png#averageHue=#e4e6cd&clientId=u44a7ef89-db93-4&from=paste&height=1603&id=u4bbae20a&originHeight=2004&originWidth=1912&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=137536&status=done&style=none&taskId=ud684c8d4-a674-4c78-a993-00dea371b8c&title=&width=1529.6)

个人主页:
1692065078567.png
个人中心:
![@BC(V]4PD2}8@_%4TD{%ACH.png](https://img-blog.csdnimg.cn/img_convert/32e6b6c91d86eb471c97073e4e819bba.png#averageHue=#f7f1cc&clientId=u44a7ef89-db93-4&from=paste&height=1182&id=u4a13f06f&originHeight=1477&originWidth=1910&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=98270&status=done&style=none&taskId=u7ab8e635-7a3a-43ae-83c2-475c35566bd&title=&width=1528)
发新帖:
![@6W$KM%[C5U}YZYB]E]}CIE.png](https://img-blog.csdnimg.cn/img_convert/29632d7905c076b8412539a41bb21a0f.png#averageHue=#efd8ad&clientId=u44a7ef89-db93-4&from=paste&height=944&id=u792a3e9c&originHeight=1180&originWidth=1849&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=70483&status=done&style=none&taskId=u19cd8777-e4e6-49a4-916e-d8411b5940b&title=&width=1479.2)
私信:
1692065187854.png
1692065230007.png
1692065283460.png

猜你喜欢

转载自blog.csdn.net/qq_53869058/article/details/132345626