基于SSM框架的在线抽奖系统
一、项目概述
本项目从前端角度来看分为三个模块,登录注册模块、抽奖设置模块和抽奖模块。管理员注册登录后,进入抽奖设置页面,在该页面设置奖项,添加抽奖人员,之后就可以进入先后将页面抽奖。
从后端的设计思路分为以下几个模块:
二、设计统一响应类
1、统一返回类
统一返回类方便前后端更好的进行数据的交互,有利于项目统一数据的维护和修改,还可以统一标准,确保不出现稀奇古怪的响应内容。
统一返回数据的字段
@Getter @Setter @ToString public class JSONResponse { private boolean success; private String code; private String message; private Object data; }
统一的数据返回格式可以使用@ControllerAdvice + 实现ResponseBodyAdvice 的方式完成,但这种方式不够灵活,这里我们使用HandlerMethodReturnValueHandler来自定义返回值类型
1)、自定义一个类实现HandlerMethodReturnValueHandler接口
public class RequestResponseBodyMethodProcessorWrapper implements HandlerMethodReturnValueHandler { private final HandlerMethodReturnValueHandler delegate; public RequestResponseBodyMethodProcessorWrapper(HandlerMethodReturnValueHandler delegate) { this.delegate = delegate; } @Override public boolean supportsReturnType(MethodParameter returnType) { return delegate.supportsReturnType(returnType); } @Override public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception { //returnValue是Controller请求方法执行完,返回值 if(!(returnValue instanceof JSONResponse)){//返回值本身就是需要的类型,不进行处理 JSONResponse json = new JSONResponse(); json.setSuccess(true); json.setData(returnValue); returnValue = json; } delegate.handleReturnValue(returnValue, returnType, mavContainer, webRequest); } }
HandlerMethodReturnValueHandler本身是定义在Spring MVC框架中的,其作用是处理Controller的方法返回值。
-
supportsReturnType() : 判断当前HandlerMethodReturnValueHandler是否支持处理给定的返回值类型,为true支持,false不支持;
-
handleReturnValue() : 处理实际的返回值,当supportsReturnType()返回true时才调用此方法。
2)、添加自定义的返回值处理器
使用@ControllerAdvice + 实现ResponseBodyAdvice接口 的方式处理统一返回数据的包装,但无法处理返回数据为null的数据包装,我们添加自定义返回值为null的包装。
@Configuration public class AppConfig implements InitializingBean { @Autowired private ObjectMapper objectMapper; @Resource private RequestMappingHandlerAdapter adapter; //之前以@ControllerAdvice+实现ResponseBodyAdvice接口,完成统一处理返回数据包装:无法解决返回值为null需要包装 //改用现在这种方式,可以解决返回null包装为自定义类型 @Override public void afterPropertiesSet() throws Exception { List<HandlerMethodReturnValueHandler> returnValueHandlers = adapter.getReturnValueHandlers(); List<HandlerMethodReturnValueHandler> handlers = new ArrayList(returnValueHandlers); for(int i=0; i<handlers.size(); i++){ HandlerMethodReturnValueHandler handler = handlers.get(i); if(handler instanceof RequestResponseBodyMethodProcessor){ handlers.set(i, new RequestResponseBodyMethodProcessorWrapper(handler)); } } adapter.setReturnValueHandlers(handlers); } }
2、自定义异常类
先定义异常的基类
@Getter @Setter public class AppException extends RuntimeException { private String code; public AppException( String code, String message) { super(message); this.code = code; } public AppException( String code, String message, Throwable cause) { super(message, cause); this.code = code; } }
统⼀异常处理使用的是 @ControllerAdvice + @ExceptionHandler 来实现的,@ControllerAdvice 表 示控制器通知类,@ExceptionHandler 是异常处理器,两个结合表示当出现异常的时候执行某个通知。
@ControllerAdvice @Slf4j//使用lombok日志日志注解,之后使用log属性来完成日志打印 public class ExceptionAdvice { //自定义异常报错错误码和错误消息 @ExceptionHandler(AppException.class) @ResponseBody public Object handle1(AppException e){ JSONResponse json = new JSONResponse(); json.setCode(e.getCode()); json.setMessage(e.getMessage()); // log.debug(transfer(e)); log.debug("自定义异常", e); return json; } //非自定义异常(英文错误信息,堆栈信息,不能给用户看): // 指定一个错误码,错误消息(未知错误,请联系管理员) @ExceptionHandler(Exception.class) @ResponseBody public Object handle2(Exception e){ JSONResponse json = new JSONResponse(); json.setCode("ERR000"); json.setMessage("未知错误,请联系管理员"); log.error("未知错误", e); return json; } public String transfer(Exception e){ StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw, true); e.printStackTrace(pw);//打印异常堆栈信息到PrintWriter return sw.toString(); } }
三、建立数据库
根据后端的设计思路建立数据库表
drop database if exists lucky_draw; create database lucky_draw character set utf8mb4; use lucky_draw; drop table if exists user; create table user( id int primary key auto_increment, username varchar(20) not null unique comment '用户账号', password varchar(65) not null comment '密码', nickname varchar(20) comment '用户昵称', email varchar(50) comment '邮箱', age int comment '年龄', head varchar(255) comment '头像url', create_time timestamp default NOW() comment '创建时间' ) comment '用户表'; drop table if exists setting; create table setting( id int primary key auto_increment, user_id int not null comment '用户id', batch_number int not null comment '每次抽奖人数', create_time timestamp default NOW() comment '创建时间', foreign key (user_id) references user(id) ) comment '抽奖设置'; drop table if exists award; create table award( id int primary key auto_increment, name varchar(20) not null comment '奖项名称', count int not null comment '奖项人数', award varchar(20) not null comment '奖品', setting_id int not null comment '抽奖设置id', create_time timestamp default NOW() comment '创建时间', foreign key (setting_id) references setting(id) ) comment '奖项'; drop table if exists member; create table member( id int primary key auto_increment, name varchar(20) not null comment '姓名', no varchar(20) not null comment '工号', setting_id int not null comment '抽奖设置id', create_time timestamp default NOW() comment '创建时间', foreign key (setting_id) references setting(id) ) comment '抽奖人员'; drop table if exists record; create table record( id int primary key auto_increment, member_id int not null comment '中奖人员id', award_id int not null comment '中奖奖项id', create_time timestamp default NOW() comment '创建时间', foreign key (member_id) references member(id), foreign key (award_id) references award(id) ) comment '中奖记录';
四、项目实现过程
1、配置MyBatis
在pom.xml文件中添加如下依赖:
<!-- 添加MyBatis 框架 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.4</version> </dependency> <!-- 添加MySQL 驱动--> <dependency><groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.38</version> <scope>runtime</scope> </dependency>
在下图的目录中建立好mapper文件,这里运用了EditStarters插件,该插件可以使方法关联对应的SQL语句。
在application.properties文件中配置 MyBatis中的XML路径,连接数据库。
2、用户登录
1)、约定前后端交互
请求:
POST api/user/login
Content-Type: application/json
{username: "zhangsan", password: "123"}
响应:
{
"success" : true
}
2)创建用户实体类
/** * 用户表 */ @Getter @Setter @ToString public class User implements Serializable { private Integer id; /** * 用户账号 */ private String username; /** * 密码 */ private String password; /** * 用户昵称 */ private String nickname; /** * 邮箱 */ private String email; /** * 年龄 */ private Integer age; /** * 头像url */ private String head; /** * 创建时间 */ private Date createTime; }
3)在指定路径添加UserMapper.xml文件
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.example.mapper.UserMapper"> <resultMap id="BaseResultMap" type="org.example.model.User"> <id column="id" jdbcType="INTEGER" property="id" /> <result column="username" jdbcType="VARCHAR" property="username" /> <result column="password" jdbcType="VARCHAR" property="password" /> <result column="nickname" jdbcType="VARCHAR" property="nickname" /> <result column="email" jdbcType="VARCHAR" property="email" /> <result column="age" jdbcType="INTEGER" property="age" /> <result column="head" jdbcType="VARCHAR" property="head" /> <result column="create_time" jdbcType="TIMESTAMP" property="createTime" /> </resultMap> <sql id="Base_Column_List"> id, username, password, nickname, email, age, head, create_time </sql> </mapper>
这里我们使用resultMap配置映射,实现一对多映射关系。
用户登录根据用户名查询数据库,在UserMapper.xml中写入SQL查询语句:
<select id="selectByUsername" resultMap="BaseResultMap"> select <include refid="Base_Column_List" /> from user where username = #{username} </select>
在mapper层实现以下代码:
@Mapper public interface UserMapper extends BaseMapper<User> { User selectByUsername(String username); }
不要忘记添加@Mapper注解。在Service层实现如下代码:
@Service public class UserService { @Autowired private UserMapper userMapper;
public User queryByUsername(String username) {
return userMapper.selectByUsername(username);
}
4)、用户登录实现
前端向后端发起登录请求,后端根据用户名查询该用户是否存在,若不存在,则抛出异常,若存在,判断密码是否正确,错误抛出异常,正确则登录成功,代码实现如下:
@PostMapping("/login") public Object login(@RequestBody User user, HttpServletRequest req){//username, password //根据账号查用户 User exist = userService.queryByUsername(user.getUsername()); //用户不存在 if(exist == null) throw new AppException("LOG001", "用户不存在"); //用户存在,校验密码 if(PasswordUtils.check(user.getPassword(),exist.getPassword())) throw new AppException("LOG002", "账号或密码错误"); //校验通过,保存数据库的用户(包含所有字段)到session HttpSession session = req.getSession();//先创建session session.setAttribute("user", exist); return null;//登录成功 }
用户校验密码使用了加盐算法,加盐算法在后续注册部分解析。以上就完成了用户登录的功能。
5)、统一登录功能的实现
使用统一登录功能时为了减免不必要的重复性操作,若不使用统一登录功能,那么将会在每个方法中都要进行登录验证,即便封装成公共方法也要传参调用和在方法中进行判断,这样繁复的操作会增加修改成本和维护成本。
实现统一登录功能代码:
public class LoginInterceptor implements HandlerInterceptor { private ObjectMapper objectMapper; public LoginInterceptor(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if(session != null){//获取登录时设置的用户信息 User user = (User) session.getAttribute("user"); if(user != null){//登录了,允许访问 return true; } } //登录失败,不允许访问的业务:区分前后端 //TODO:前端跳转登录页面,后端返回json // new ObjectMapper().writeValueAsString(object);//序列化对象为json字符串 //请求的服务路径 String servletPath = request.getServletPath();// /apiXXX.html if(servletPath.startsWith("/api/")){//后端逻辑:返回json response.setCharacterEncoding("UTF-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); JSONResponse json = new JSONResponse(); json.setCode("USR000"); json.setMessage("用户没有登录,不允许访问"); String s = objectMapper.writeValueAsString(json); response.setStatus(HttpStatus.UNAUTHORIZED.value()); PrintWriter pw = response.getWriter(); pw.println(s); pw.flush(); }else{//前端逻辑:跳转到登录页面 /views/index.html //相对路径的写法,一定是请求路径作为相对位置的参照点 //使用绝对路径来重定向,不建议使用相对路径和转发 String schema = request.getScheme();//http String host = request.getServerName();//ip int port = request.getServerPort();//port String contextPath = request.getContextPath();//application Context path应用上下文路径 String basePath = schema+"://"+host+":"+port+contextPath; //重定向到登录页面 response.sendRedirect(basePath+"/index.html"); } return false; } }
添加拦截器
添加一个统一访问前缀为"/api",排除所有静态资源。
addPathPatterns:表示需要拦截的 URL,“ **” 表示拦截任意⽅法(也就是所有⽅法)
excludePathPatterns:表示需要排除的 URL
public void configurePathMatch(PathMatchConfigurer configurer) { //设置路径前缀的规则,以第二个参数的返回值作为请求映射方法是否添加前缀 configurer.addPathPrefix("api", c->true); } @Override public void addInterceptors(InterceptorRegistry registry) { //前端处理逻辑和后端处理逻辑是否一样? //前端:敏感资源在拦截器中处理为:没登录跳转首页 //后端:敏感资源在拦截器中处理为:返回json,401状态码 // localhost:8080/xxx registry.addInterceptor(new LoginInterceptor(objectMapper)) // *代表路径下一级,**代表路径的所有子级 //所有后端非/user/开头,只有指定的两个前端资源执行拦截器的逻辑 .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login")//后端开放的资源 .excludePathPatterns("/api/user/register")//后端开放的资源 .addPathPatterns("/draw.html")//抽奖页面 .addPathPatterns("/setting.html");//抽奖设置页面 }
3、用户注册
1)约定前后端交互
请求:
POST api/user/register
Content-Type: multipart/form-data; boundary=----
WebKitFormBoundarypOUwkGIMUyL0aOZT
username: haha
password: 111
nickname: 牛牛牛
email: 666@163.com
age: 66
headFile: (binary)
响应:
{
"success" : true
}
2)用户注册
用户注册时,用户名和密码时必填项,而其他选项为非必填项,如下图:
当存在非必填项时,不确定是否有字段传入,这时候就需要动态标签<if>,若存在多个非必填字段时,使用<trim>标签结合<if>标签实现。<trim>标签有以下属性:
- prefix:表示整个语句块,以prefix的值作为前缀
- suffix:表示整个语句块,以suffix的值作为后缀
- prefixOverrides:表示整个语句块要去除掉的前缀
- suffixOverrides:表示整个语句块要去除掉的后缀
在UserMapper.xml中写入如下语句:
<insert id="insertSelective" keyColumn="id" keyProperty="id" parameterType="org.example.model.User" useGeneratedKeys="true"> insert into user <trim prefix="(" suffix=")" suffixOverrides=","> <if test="username != null"> username, </if> <if test="password != null"> password, </if> <if test="nickname != null"> nickname, </if> <if test="email != null"> email, </if> <if test="age != null"> age, </if> <if test="head != null"> head, </if> <if test="createTime != null"> create_time, </if> </trim> <trim prefix="values (" suffix=")" suffixOverrides=","> <if test="username != null"> #{username,jdbcType=VARCHAR}, </if> <if test="password != null"> #{password,jdbcType=VARCHAR}, </if> <if test="nickname != null"> #{nickname,jdbcType=VARCHAR}, </if> <if test="email != null"> #{email,jdbcType=VARCHAR}, </if> <if test="age != null"> #{age,jdbcType=INTEGER}, </if> <if test="head != null"> #{head,jdbcType=VARCHAR}, </if> <if test="createTime != null"> #{createTime,jdbcType=TIMESTAMP}, </if> </trim> </insert>
由于在多个模块中存在操作重复的语句,所以我们创建一个mapper层的基类接口,其他模块的接口继承基类接口,这样就可以减少重复性的操作。
public interface BaseMapper<T> { int deleteByPrimaryKey(Integer id); int insert(T record); int insertSelective(T record); T selectByPrimaryKey(Integer id); int updateByPrimaryKeySelective(T record); int updateByPrimaryKey(T record); }
3)保存用户头像
我们需要将用户上传的头像保存到本地,并将头像生成唯一的文件名,然后通过tomcat直接访问文件。
在配置文件中设置以下配置:
user.head.local-path=D:/TMP user.head.remote-path=http://localhost:${server.port:8080}${server.servlet.context-path:}
user.head.local-path为自定义用户头像保存根路径,user.head.remote-path为tomcat访问文件路径。
对于如何将用户头像保存为唯一文件名的思路是:在根路径下创建以当前日期为文件夹,随机生成字符串作为头像的前缀,保证头像文件名的唯一性,
并保存在当前日期文件夹下,实现代码如下:
private static final DateFormat DF = new SimpleDateFormat("yyyyMMdd"); @Value("${user.head.local-path}") private String headLocalPath; @Value("${user.head.remote-path}") private String headRemotePath; public String saveHead(MultipartFile headFile) {//保存在本地路径:拍脑门决定的路径 //文件夹为当天:文件路径的间隔符和操作系统相关,可以使用File.separator,但是java也会根据操作系统自行设置 Date now = new Date(); String dirUri = "/"+DF.format(now);//20240203 File dir = new File(headLocalPath+dirUri); if(!dir.exists()) dir.mkdirs(); //保存在本地以天为单位的文件夹,保证文件唯一:随机字符串作为文件名,但是后缀还需要保留 String suffix = headFile.getOriginalFilename() .substring(headFile.getOriginalFilename().lastIndexOf(".")); //创建唯一文件名 String headName = UUID.randomUUID().toString()+suffix; String uri = dirUri+"/"+headName; try { headFile.transferTo(new File(headLocalPath+uri)); } catch (IOException e) { throw new AppException("REG001", "上传用户头像出错"); } return headRemotePath+uri; }
最后保存在数据库的头像路径就是localhost:8080/日期文件夹/头像唯一文件名,如: http://localhost:8080/20240109/a9084f89-3e53-44af-bd6f71f247b6c867.jpg
4)加盐算法
实现思路:每次调用方法产生的盐值 + 密码 = 最终密码,使用UUID生成唯一盐值,用该盐值加上初始密码处理成MD5密码,然后用生成的盐值+"$"+MD5密码作为最终的密码存储起来("$"作为分隔符)。
加密实现代码如下:
public static String encrypt(String password){ //1、产生随机的盐值,使用UUID产生唯一的值 String salt = UUID.randomUUID().toString().replace("-",""); //2、进行MD5加密 String saltPassword = DigestUtils.md5DigestAsHex((salt+password).getBytes()); //3、生成最终的密码 String finalPassword = salt + "$" +saltPassword; return finalPassword; }
加密方法是在用户注册的时候调用,那么登录的时候就要对用户输入的密码进行校验,将数据存储的密码解密之后与输入密码进行对比。那么如何解密呢?最终的密码是盐值+"$"+MD5密码,那么我们可以根据分隔符得到盐值,将盐值与输入密码再进行一次MD5加密得到校验密码,最后验证输入密码是否正确。代码实现如下:
/** * 2、生成加盐的密码 * @param password * @param salt * @return */ public static String encrypt(String password,String salt){ String saltPassword = DigestUtils.md5DigestAsHex((salt+password).getBytes()); String finalPassword = salt + "$" + saltPassword; return finalPassword; } /** * 3、进行验证 * @param inputPassword * @param finalPassword * @return */ public static boolean check(String inputPassword,String finalPassword){ //1、非空校验 if(StringUtils.hasLength(inputPassword) && StringUtils.hasLength(finalPassword) && finalPassword.length() == 65){ //1、得到盐值,获取finalpassword的第一部分 String salt = finalPassword.split("\\$")[0]; //2、进行加密 String confirmPassword = PasswordUtils.encrypt(inputPassword,salt); //3、进行验证 return confirmPassword.equals(finalPassword); } return false; }
生成的密码格式是32位盐值+"$"+32位MD5密码,所以最终的密码是65位。
至此,本项目的难点基本已经全部完成。