基于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位。

至此,本项目的难点基本已经全部完成。

posted @ 2024-03-19 11:26  司丝思  阅读(35)  评论(0编辑  收藏  举报