【Java从零到架构师第③季】【项目实战】驾考管理系统


持续学习&持续更新中…

守破离


【Java从零到架构师第③季】【项目实战】驾考管理系统

    • 企业开发中常见的后台管理系统
    • 技术栈及第三方库
      • 技术栈
      • pom.xml
    • 各种Object
    • 前后端分离
      • 前后端分离—实现
      • 前后端分离—约定数据格式
    • 跨域问题
    • Layui
    • MySQL建议
    • 数据字典
    • 封装MyBatis-Plus方便查询
    • 封装给客户端的返回值
    • 统一异常处理+HTTP响应状态码
    • 统一异常处理—配合Shiro
    • 数据的一致性
    • 拼音库—tinypinyin的使用
    • MapStruct
    • 登录—简单登录
    • 登录—Token
      • 后端
      • 前端
    • 权限管理—RBAC
    • 逻辑删除
    • 逻辑删除—MyBatisPlus
    • 企业级文件上传
    • @RequestBody修饰的请求参数
    • 单元测试
      • Spring单元测试
      • SpringBoot单元测试
    • 打包部署
      • 打包部署—jar
      • 打包部署—war
    • 注意
    • 补充:ChromeJSON插件
    • 参考

【Github】项目源码地址:https://github.com/lpruoyu/JAVAEE_PROJECT_jiakao

企业开发中常见的后台管理系统

【Java从零到架构师第③季】【项目实战】驾考管理系统_第1张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第2张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第3张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第4张图片

技术栈及第三方库

技术栈

后台:

  • SpringBoot
  • MyBatisPlus、Druid
  • 权限控制:Shiro
  • 后端校验:HibernateValidator
  • 验证码:easy-captcha
  • 缓存:ehcache
  • 对象转换:MapStruct
  • 拼音相关:tinypinyin
  • 接口文档:Swagger
  • 代码生成:EasyCode

前端:

  • layui(LayuiMini)
  • md5加密
  • Freemarker(项目初期使用,前后端分离后就不用了)
  • font-awesome

pom.xml


<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>
    <groupId>programmer.lp.jkbe_v3groupId>
    <artifactId>JiaKaoBE_V3artifactId>

    <packaging>jarpackaging>
    <version>1.0.0version>

    <parent>
        <artifactId>spring-boot-starter-parentartifactId>
        <groupId>org.springframework.bootgroupId>
        <version>2.3.4.RELEASEversion>
    parent>

    <properties>
        <druid.version>1.2.1druid.version>
        <mybatis.plus.version>3.4.1mybatis.plus.version>
        <tinypinyin.version>2.0.3tinypinyin.version>
        <mapStruct.version>1.4.1.FinalmapStruct.version>
        <captcha.version>1.6.2captcha.version>
        <commons.io.version>2.11.0commons.io.version>
        <swagger.models.version>1.6.2swagger.models.version>
        <swagger.triui.version>1.9.6swagger.triui.version>
        <swagger.version>2.9.2swagger.version>
        <shiro.version>1.7.0shiro.version>
        <springfox.version>3.0.0springfox.version>
    properties>

    <dependencies>





        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>






        dependency>

        
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
        dependency>
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>druid-spring-boot-starterartifactId>
            <version>${druid.version}version>
        dependency>
        <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-boot-starterartifactId>
            <version>${mybatis.plus.version}version>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-aopartifactId>
        dependency>

        
        <dependency>
            <groupId>com.github.promeggroupId>
            <artifactId>tinypinyinartifactId>
            <version>${tinypinyin.version}version>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-validationartifactId>
        dependency>

        
        <dependency>
            <groupId>io.springfoxgroupId>
            <artifactId>springfox-swagger2artifactId>
            <version>${swagger.version}version>
        dependency>
        <dependency>
            <groupId>io.springfoxgroupId>
            <artifactId>springfox-swagger-uiartifactId>
            <version>${swagger.version}version>
        dependency>
        <dependency>
            <groupId>io.swaggergroupId>
            <artifactId>swagger-modelsartifactId>
            <version>${swagger.models.version}version>
        dependency>
        <dependency>
            <groupId>com.github.xiaoymingroupId>
            <artifactId>swagger-bootstrap-uiartifactId>
            <version>${swagger.triui.version}version>
        dependency>

        
        <dependency>
            <groupId>org.mapstructgroupId>
            <artifactId>mapstructartifactId>
            <version>${mapStruct.version}version>
            <scope>providedscope>
        dependency>
        <dependency>
            <groupId>org.mapstructgroupId>
            <artifactId>mapstruct-processorartifactId>
            <version>${mapStruct.version}version>
            <scope>providedscope>
        dependency>

        
        <dependency>
            <groupId>com.github.whvcsegroupId>
            <artifactId>easy-captchaartifactId>
            <version>${captcha.version}version>
        dependency>

        
        <dependency>
            <groupId>org.ehcachegroupId>
            <artifactId>ehcacheartifactId>
        dependency>

        
        <dependency>
            <groupId>org.apache.shirogroupId>
            <artifactId>shiro-spring-boot-web-starterartifactId>
            <version>${shiro.version}version>
        dependency>

        
        <dependency>
            <groupId>commons-iogroupId>
            <artifactId>commons-ioartifactId>
            <version>${commons.io.version}version>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-configuration-processorartifactId>
            <scope>providedscope>
        dependency>
        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
            <scope>providedscope>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
            <scope>providedscope>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>










    dependencies>
    <build>
        <finalName>jkfinalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.bootgroupId>
                <artifactId>spring-boot-maven-pluginartifactId>
            plugin>
        plugins>
    build>
project>

各种Object

  • 数据库中对于一张表的数据,由于拥有隐私字段、多余字段、字段过少等原因,不应该直接传递给客户端让客户端直接使用。
  • 并且要知道数据的传输是要经过网络通信的,考虑到数据大小对于用户流量、系统并发量/吞吐量等的影响,我们也不应该给客户传递冗余或者缺失的JSON数据。
  • 还有很多原因,比如我们的一个业务所需要的信息有时候其实并不是仅由一张表就能覆盖的、比如数据库中的字段信息其实并不适合展示给用户看,需要做处理、…
  • 综上,我们肯定要对从数据库中查询出来的表数据进行一些加工处理、业务逻辑处理之后再传递给上一层,直到客户端,而不是简单的一张表对应一个Model对象。
    【Java从零到架构师第③季】【项目实战】驾考管理系统_第5张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第6张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第7张图片

前后端分离

【Java从零到架构师第③季】【项目实战】驾考管理系统_第8张图片

以前这种协作模式的问题:

  • 前端地位比较低,大部分工作都在后台
  • 调试、修改页面比较麻烦,需要前端和后台相互充分配合修改动态模板
  • 浪费用户流量以及增加服务器负担:每次请求服务器都会返回整个HTML页面

前后端分离—实现

  1. 前端页面保存使用静态页面HTML
  2. 静态页面使用JS发送异步请求(AJAX)给后台服务器,后台服务器返回JSON数据
  3. 前端使用JS解析JSON数据,动态生成HTML标签,显示在浏览器给用户展示
  4. 前端一个项目、后端一个项目,各自独立开发
  5. 前端一个服务器、后端一个服务器,分开部署
    【Java从零到架构师第③季】【项目实战】驾考管理系统_第9张图片
    【Java从零到架构师第③季】【项目实战】驾考管理系统_第10张图片

前后端分离—约定数据格式

  • 项目开发应该撰写开发文档,前端照着开发文档编写页面,后端照着开发文档编写API接口
  • 后端和前端之间应该有一种数据格式、有一种协议,双方事先约定好一种数据格式,来规范、辅助开发,比如让后端返回JSON数据
  • 比如后端约定好给前端返回:
    # 错误的返回结果,并且设置HTTP状态码为400/500...
    {
    	"code": 8001,
    	"msg": "密码错误"
    }
    
    # 正确的返回结果
    {
    	"code": 8000,
    	"msg": "添加成功",
    	"data": [ ... ]
    }
    

跨域问题

  • https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy

  • https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS/Errors/CORSMissingAllowOrigin

【Java从零到架构师第③季】【项目实战】驾考管理系统_第11张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第12张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第13张图片

@RestController
//@CrossOrigin("*") // 所有的源都可以跨域访问该Controller下的请求
//@CrossOrigin({"http://localhost:63343","http://192.168.152.130:8888"})
//@CrossOrigin("http://localhost:63343")
public class UserController {

    @GetMapping("/users")
    @CrossOrigin("http://localhost:63343")
    public List<User> user() {
        List<User> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(new User("lp" + i, i));
        }
        return list;
    }

}

【Java从零到架构师第③季】【项目实战】驾考管理系统_第14张图片

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
//        registry.addMapping("/users/*")
        registry.addMapping("/**")
//                .allowedOrigins("*")
                .allowedOrigins("http://localhost:63343")
                .allowCredentials(true) // 允许客户端发送Cookie
                .allowedMethods("GET", "POST");
    }
}

项目中可以这样配置:

@Component
@Data
@ConfigurationProperties("cors")
public class CORSProperties {
    String pathPattern; // 允许哪些路径下的API被跨域访问
    String[] origins; // 允许跨域请求的源
    String[] methods; // 允许跨域请求的方法类型
    boolean allowCredentials; // 是否允许Cookie
//    详细信息可以参考org.springframework.web.bind.annotation.CrossOrigin
//    String[] allowedHeaders;
//    String[] exposedHeaders;
}
cors:
  path-pattern: /**
  methods:
    - GET
    - POST
  origins:
    - http://localhost:63343
    - http://192.168.152.130:8888
  allow-credentials: true
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private CORSProperties corsProperties;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping(corsProperties.getPathPattern())
                .allowedOrigins(corsProperties.getOrigins())
                .allowCredentials(corsProperties.isAllowCredentials())
                .allowedMethods(corsProperties.getMethods());
    }
}

客户端使用

<button type="button" id="load-btn">加载用户信息button>

<script src="./js/jquery.min.js">script>
<script>
    $(() => {
        $('#load-btn').click(() => {
            $.getJSON('http://localhost:8080/jk/users', (users) => {
                const $table = $('')$(document.body).append($table)for(const user of users){const $tr =$('')
                    $table.append($tr)

                    $tr.append(``)
                    $tr.append(``)}})})})script>

Layui

  • 官网:http://layuimini.99php.cn/
  • 文档:http://layuimini.99php.cn/docs/
  • 演示:http://layuimini.99php.cn/onepage/v2/index.html
  • Github:https://github.com/zhongshaofa/layuimini/tree/v2-onepage
  • 下载:https://codeload.github.com/zhongshaofa/layuimini/zip/refs/heads/v2-onepage

【Java从零到架构师第③季】【项目实战】驾考管理系统_第15张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第16张图片

注意:Layui中的相对路径都是相对于index.html来说的。

注意:layuimini的表格要求服务器返回的JSON数据格式如下:

{
  "code": 0,
  "data": [
    {
      "id": 1,
      "name": "职业",
      "value": "job",
      "intro": "一份工作"
    },
    {
      "id": 2,
      "name": "性格",
      "value": "character",
      "intro": "人的性格"
    }
  ],
  "count": 87
}

MySQL建议

【Java从零到架构师第③季】【项目实战】驾考管理系统_第17张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第18张图片

最大IP地址:255.255.255.255

CREATE TABLE user(
	age TINYINT UNSIGNED,
	ip VARCHAR(15) #需要15个字节
)

CREATE TABLE user(
	age TINYINT UNSIGNED,
	ip INT UNSIGNED #只需要4个字节
)

INSERT INTO user VALUES(10, INET_ATON('255.255.255.255'))

SELECT INET_NTOA(ip) FROM user

【Java从零到架构师第③季】【项目实战】驾考管理系统_第19张图片

注意:

  • 索引只能给非空列进行优化

  • 不使用外键有可能导致数据的一致性出现问题,因此需要自己在Java代码的业务层做好业务逻辑控制

  • 数据的一致性远远没有数据库的性能重要

  • 高并发&分布式下的系统为了性能更优以及更好维护,不能使用外键。一般而言只要自己在应用层面做好数据库之间关系的维护,那么不使用外键是完全没有问题的。

数据字典

【Java从零到架构师第③季】【项目实战】驾考管理系统_第20张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第21张图片

使用PowerDesigner:

【Java从零到架构师第③季】【项目实战】驾考管理系统_第22张图片

/*==============================================================*/
/* DBMS name:      MySQL 5.0                                    */
/* Created on:     2022-05-05 23:16:43                          */
/*==============================================================*/


drop table if exists dict_item;

drop table if exists dict_type;

/*==============================================================*/
/* Table: dict_item                                             */
/*==============================================================*/
create table dict_item
(
   id                   bigint not null,
   name                 varchar(20) not null,
   value                varchar(20) not null,
   no                   int not null default 0 comment '用来排序,数字越小,优先级越高,越先展示',
   type_id              bigint comment '该条目所属的数据字典类型',
   status               int not null default 1 comment '是否启用该条目,0:不启用,1:启用',
   primary key (id),
   unique key AK_UK_1 (name, type_id),
   unique key AK_UK_2 (value, type_id)
);

alter table dict_item comment '数据字典每一项具体的内容';

/*==============================================================*/
/* Table: dict_type                                             */
/*==============================================================*/
create table dict_type
(
   id                   bigint not null auto_increment,
   name                 varchar(20) not null comment '名称是展示在客户端的,是有可能会发生改变的',
   value                varchar(20) not null comment '值不会发生改变,编写SQL操作数据时,一般使用value而不是name',
   intro                varchar(100) comment '防止程序员忘记该数据字典类型的作用、功能(根据项目需求可有可无)',
   primary key (id),
   unique key AK_UK_1 (name),
   unique key AK_UK_2 (value)
);

alter table dict_type comment '数据字典类型';

alter table dict_item add constraint FK_Reference_1 foreign key (type_id)
      references dict_type (id) on delete restrict on update restrict;

使用IDEA自带的数据库工具+不使用外键+数据库使用最优字段类型:

【Java从零到架构师第③季】【项目实战】驾考管理系统_第23张图片

create table jk.dict_type
(
    id    smallint unsigned auto_increment comment '主键'
        primary key,
    name  varchar(20)  default '' not null comment '名称',
    value varchar(20)  default '' not null comment '值',
    intro varchar(100) default '' not null comment '简介',
    constraint dict_type_name_uindex
        unique (name),
    constraint dict_type_value_uindex
        unique (value)
)
    comment '数据字典类型';

create table jk.dict_item
(
    id      smallint unsigned auto_increment comment '主键'
        primary key,
    name    varchar(20)       default '' not null comment '名称',
    value   varchar(20)       default '' not null comment '值',
    type_id smallint unsigned            not null comment '类型id',
    sn      smallint unsigned default 0  not null comment '排序序号:默认为0,值越大,越优先排列展示',
    # 其实个人认为enabled这个字段的类型可以设置为bool或者boolean
    enabled tinyint unsigned  default 1  not null comment '是否启用:0,禁用;1,启用;默认为1',
    constraint dict_item_name_type_id_uindex
        unique (name, type_id),
    constraint dict_item_value_type_id_uindex
        unique (value, type_id)
)
    comment '数据字典条目';

客户端:

【Java从零到架构师第③季】【项目实战】驾考管理系统_第24张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第25张图片

服务端:

POJO—Query:

@Data
public class PageQuery {
    private static final int MIN_SIZE = 1;
    private static final int DEFAULT_SIZE = 10;
    private long size; // 一页展示多少条数据
    private long page; // 第几页
    /**
     * 查询出来的数据集
     * 由于将来查出来的类型不确定(VO、BO、PO),因此泛型使用类型通配符
     */
    private List<?> data;
    private long total; // 总条数
    private long pages; // 总页数

    public long getSize() {
        return size < MIN_SIZE ? DEFAULT_SIZE : size;
    }

    public long getPage() {
        return page < MIN_SIZE ? MIN_SIZE : page;
    }
}
@EqualsAndHashCode(callSuper = true)
@Data
public class KeywordQuery extends PageQuery {
    private String keyword;
}
@EqualsAndHashCode(callSuper = true)
@Data
public class DictTypeQuery extends KeywordQuery {
}

ServiceImpl:

@Service
@Transactional
public class DictTypeServiceImpl extends ServiceImpl<DictTypeMapper, DictType> implements DictTypeService {
    @Autowired
    private DictTypeMapper mapper;

    @Override
    @Transactional(readOnly = true)
    public void list(DictTypeQuery query) {
        final String keyword = query.getKeyword();
        LambdaQueryWrapper<DictType> wrapper = new LambdaQueryWrapper<>();
        // 按照关键字查询
        if (!StringUtils.isEmpty(keyword)) {
            wrapper.like(DictType::getName, keyword).or()
                    .like(DictType::getIntro, keyword).or()
                    .like(DictType::getValue, keyword);
        }
        // 按照id降序排序
        wrapper.orderByDesc(DictType::getId);
        // 分页查询
        Page<DictType> page = new Page<>(query.getNo(), query.getSize());
        mapper.selectPage(page, wrapper);
        // 更新query对象
        query.setData(page.getRecords());
        query.setPages(page.getPages());
        query.setTotal(page.getTotal());
        // 如果客户端的查询条件有问题,MyBatisPlus会自动识别并修正,因此可以修改一下query中的查询条件
        query.setSize(getSize());
        query.setPage(getCurrent());
    }
}

Controller:

@RestController
@RequestMapping("/dictTypes")
public class DictTypeController {
    @Autowired
    private DictTypeService service;

    @GetMapping
    public Map<String, Object> list(DictTypeQuery query) {
        service.list(query);
        final Map<String, Object> map = new HashMap<>();
        map.put("msg", "");
        map.put("data", query.getData());
        map.put("count", query.getTotal());
        map.put("code", 0);
        return map;
    }

    @PostMapping("/remove")
    public Map<String, Object> remove(String id) {
        // id = "10"
        // id = "1, 20, 23"
        final String[] ids = id.split(",");
        final Map<String, Object> map = new HashMap<>();
        if (service.removeByIds(Arrays.asList(ids))) {
            map.put("msg", "删除成功");
            map.put("code", 0);
        } else {
            map.put("msg", "删除失败");
            map.put("code", 1);
        }
        return map;
    }

    @PostMapping("/save")
    public Map<String, Object> save(DictType dictType) {
        final Map<String, Object> map = new HashMap<>();
        if (service.saveOrUpdate(dictType)) {
            map.put("msg", "保存成功");
            map.put("code", 0);
        } else {
            map.put("msg", "保存失败");
            map.put("code", 1);
        }
        return map;
    }
}

封装MyBatis-Plus方便查询

以查询DictType(数据字典类型)为例

enhance—MPPage、MPQueryWrapper:

public class MPPage<T> extends Page<T> {
    private final PageQuery query;

    public MPPage(PageQuery query) {
        super(query.getPage(), query.getSize());
        this.query = query;
    }

    public void updateQuery() {
        query.setData(getRecords());
        query.setPages(getPages());
        query.setTotal(getTotal());
        // 如果客户端的查询条件有问题,MyBatis会自动识别并修正,因此可以修改一下query中的查询数据
        query.setSize(getSize());
        query.setPage(getCurrent());
    }
}
public class MPQueryWrapper<T> extends LambdaQueryWrapper<T> {
    @SafeVarargs
    public final MPQueryWrapper<T> like(Object val, SFunction<T, ?>... funcs) {
        if (val == null || funcs == null || funcs.length == 0) return this;
        final String str = val.toString();
        if (str.length() == 0) return this;

        return (MPQueryWrapper<T>) nested((wrapper) -> {
            for (SFunction<T, ?> func : funcs) {
                wrapper.like(func, str).or();
            }
        });
    }
}

动态代理更新Query对象:

@Configuration
@EnableAspectJAutoProxy
public class SpringConfig {
}
@Aspect
@Component
public class PageMapperInterceptor {
    @Around("execution(public com.baomidou.mybatisplus.core.metadata.IPage com.baomidou.mybatisplus.core.mapper.BaseMapper.selectPage(com.baomidou.mybatisplus.core.metadata.IPage, com.baomidou.mybatisplus.core.conditions.Wrapper))")
    public Object updateQuery(ProceedingJoinPoint point) throws Throwable {
        Object result = point.proceed();
        final Object[] args = point.getArgs();
        if (args != null && args.length > 0) {
            Object arg = args[0];
            if (arg instanceof MPPage) {
                ((MPPage<?>) arg).updateQuery();
            }
        }
        return result;
    }
}

使用—ServiceImpl:

@Transactional
@Service
public class DictTypeServiceImpl extends ServiceImpl<DictTypeMapper, DictType> implements DictTypeService {
    @Override
    @Transactional(readOnly = true)
    public void list(DictTypeQuery query) {
        MPQueryWrapper<DictType> wrapper = new MPQueryWrapper<>();
        wrapper.like(query.getKeyword(), DictType::getName, DictType::getValue, DictType::getIntro);
        wrapper.orderByDesc(DictType::getId);
        baseMapper.selectPage(new MPPage<>(query), wrapper);
    }
}

Controller:

@RestController
@RequestMapping("/dictTypes")
public class DictTypeController {
    @Autowired
    private DictTypeService service;

    @GetMapping
    public Map<String, Object> list(DictTypeQuery query) {
        service.list(query);
        final Map<String, Object> map = new HashMap<>();
        map.put("msg", "");
        map.put("data", query.getData());
        map.put("count", query.getTotal());
        map.put("code", 0);
        return map;
    }
}

封装给客户端的返回值

public class R extends HashMap<String, Object> {
    public static final int CODE_SUCCESS = 0;
    
    private static final String K_CODE = "code";
    private static final String K_MSG = "msg";
    private static final String K_DATA = "data";

    public R setCode(int code) {
        return add(K_CODE, code);
    }

    public R setMsg(String msg) {
        return add(K_MSG, msg);
    }

    public R setData(Object data) {
        return add(K_DATA, data);
    }

    public R add(String key, Object data) {
        put(key, data);
        return this;
    }
}
public final class Rs {
    private Rs() {
    }

    // 事先和前端约定好:成功code:0,失败code:1
    public static final int CODE_SUCCESS = 0;
    public static final int CODE_ERROR_DEFAULT = 1;

    private static R success() {
        return new R().setCode(CODE_SUCCESS);
    }

    public static R success(PageQuery query) {
        return success().setData(query.getData());
    }

    public static R success(String msg) {
        return success().setMsg(msg);
    }

    public static R success(PageQuery query, String msg) {
        return success().setData(query.getData()).setMsg(msg);
    }

    public static R error() {
        return new R().setCode(CODE_ERROR_DEFAULT);
    }

    public static R error(String msg) {
        return error().setMsg(msg);
    }

    public static R error(int code, String msg) {
        return new R().setCode(code).setMsg(msg);
    }

    public static R r(boolean success) {
        return new R().setCode(success ? CODE_SUCCESS : CODE_ERROR_DEFAULT);
    }

    public static R r(boolean success, String msg) {
        return r(success).setMsg(msg);
    }

    public static R r(boolean success, Object data) {
        return r(success).setData(data);
    }
}

Controller使用:

@RestController
@RequestMapping("/dictTypes")
public class DictTypeController {
    @Autowired
    private DictTypeService service;

    @GetMapping
    public R list(DictTypeQuery query) {
        service.list(query);
        return Rs.success(query).add("count", query.getTotal());
    }

    @PostMapping("/remove")
    public R remove(String id) {
        // id = "10"
        // id = "1, 20, 23"
        final String[] ids = id.split(",");

//        if (service.removeByIds(Arrays.asList(ids))) {
//            return Rs.success("删除成功");
//        } else {
//            return Rs.error("删除失败");
//        }

        final boolean success = service.removeByIds(Arrays.asList(ids));
        final String msg = success ? "删除成功" : "删除失败";
        return Rs.r(success, msg);
    }

    @PostMapping("/save")
    public R save(DictType dictType) {
        if (!service.saveOrUpdate(dictType)) {
            throw new RuntimeException("保存失败");
        }
        return Rs.success("保存成功");
    }
}

统一异常处理+HTTP响应状态码

  • 如果服务器端操作失败的话,比如删除失败、保存失败,那么给客户端返回的StatusCode就不应该是200,应该是400/500,原因如下:

  • 客户端(前端)极大可能是根据HTTP请求的响应状态码来判断某个请求是否成功的,而不是通过服务器返回的JSON数据的某个属性值来判断

  • 比如AJAX的回调方法默认就是通过HTTP的响应状态码来判断是否请求成功的。

  • 因此如果服务器处理数据失败,应该修改响应状态码200(OK)为其它StatusCode,比如400、500。

    public interface JSONable {
        default String jsonString() throws Exception {
            return JSONs.getMAPPER().writeValueAsString(this);
        }
    }
    
    public class R extends HashMap<String, Object> implements JSONable {
    	// ...
    }
    
    @ControllerAdvice
    public class ExceptionInterceptor {
        // 默认处理所有的异常
        @ExceptionHandler(Throwable.class)
        public void exceptionHandlerOther(Throwable throwable, HttpServletResponse response) throws Exception {
            response.setCharacterEncoding("UTF-8");
            response.setStatus(400);
    //        response.getWriter().write(Rs.error(getRealCause(throwable).getMessage()).jsonString());
            response.getWriter().write(Rs.error(throwable.getMessage()).jsonString());
        }
    
        private Throwable getRealCause(Throwable throwable) {
            Throwable cause = throwable.getCause();
            while (cause != null) {
                throwable = cause;
                cause = cause.getCause();
            }
            return throwable;
        }
    }
    
    @RestController
    @RequestMapping("/dictTypes")
    public class DictTypeController {
        @Autowired
        private DictTypeService service;
    
        @PostMapping("/remove")
        public R remove(String id) {
            final String[] ids = id.split(",");
            if (!service.removeByIds(Arrays.asList(ids))) {
                throw new RuntimeException("删除失败");
            }
            return Rs.success("删除成功");
        }
    }
    
  • 还可以继续封装:

    public enum CodeMsg {
        BAD_REQUEST(400, "请求出错"),
        UNAUTHORIZED(401, "未授权"),
        FORBIDDEN(403, "禁止访问"),
        NOT_FOUND(404, "资源不存在"),
        INTERNAL_SERVER_ERROR(500, "服务器内部错误"),
    
        OPERATE_OK(R.CODE_SUCCESS, "操作成功"),
        SAVE_OK(R.CODE_SUCCESS, "保存成功"),
        REMOVE_OK(R.CODE_SUCCESS, "删除成功"),
    
        OPERATE_ERROR(40001, "操作失败"),
        SAVE_ERROR(40002, "保存失败"),
        REMOVE_ERROR(40003, "删除失败"),
        UPLOAD_IMG_ERROR(40004, "图片上传失败"),
    
        WRONG_USERNAME(50001, "用户名不存在"),
        WRONG_PASSWORD(50002, "密码错误"),
        USER_LOCKED(50003, "用户被锁定,无法正常登录"),
        WRONG_CAPTCHA(50004, "验证码错误"),
    
        NO_TOKEN(60001, "没有Token,请登录"),
        TOKEN_EXPIRED(60002, "Token过期,请重新登录"),
        NO_PERMISSION(60003, "没有相关的操作权限");
    
        private final int code;
        private final String msg;
    
        CodeMsg(int code, String msg) {
            this.code = code;
            this.msg = msg;
        }
    
        public int getCode() {
            return code;
        }
    
        public String getMsg() {
            return msg;
        }
    }
    
    @EqualsAndHashCode(callSuper = true)
    @Data
    public class CommonException extends RuntimeException {
        private int code;
    
        public CommonException() {
            this(CodeMsg.BAD_REQUEST.getCode(), null);
        }
    
        public CommonException(String msg) {
            this(msg, null);
        }
    
        public CommonException(int code, String msg) {
            this(code, msg, null);
        }
    
        public CommonException(String msg, Throwable cause) {
            this(CodeMsg.BAD_REQUEST.getCode(), msg, cause);
        }
    
        public CommonException(int code, String msg, Throwable cause) {
            super(msg, cause);
            this.code = code;
        }
    
        public CommonException(CodeMsg codeMsg) {
            this(codeMsg, null);
        }
    
        public CommonException(CodeMsg codeMsg, Throwable cause) {
            this(codeMsg.getCode(), codeMsg.getMsg(), cause);
        }
    
        public int getCode() {
            return code;
        }
    }
    
    public final class Rs {
        private Rs() {
        }
    
        public static final String K_COUNT = "count";
    
        private static final int CODE_SUCCESS = 0;
        private static final int CODE_ERROR_DEFAULT = CodeMsg.BAD_REQUEST.getCode();
    
        private static R success() {
            return new R().setCode(CODE_SUCCESS);
        }
    
        public static R success(PageQuery query) {
            return success().setData(query.getData());
        }
    
        public static R success(String msg) {
            return success().setMsg(msg);
        }
    
        public static R success(Object data) {
            return success().setData(data);
        }
    
        public static R success(PageQuery query, String msg) {
            return success().setData(query.getData()).setMsg(msg);
        }
    
        public static R success(CodeMsg codeMsg) {
            return success().setMsg(codeMsg.getMsg());
        }
    
        public static R error() {
            return error(CODE_ERROR_DEFAULT);
        }
    
        public static R error(int code) {
            return new R().setCode(code);
        }
    
        public static R error(String msg) {
            return error().setMsg(msg);
        }
    
        public static R error(int code, String msg) {
            return error(code).setMsg(msg);
        }
    
        public static R error(Throwable e) {
    //        R r = error(e.getMessage()); // 开发阶段
            R r = error(); // 项目上线 项目上线了就不要把其它异常信息给用户看了
            if (e instanceof CommonException) {
                r.setCode(((CommonException) e).getCode());
            }
            return r;
        }
    
        public static R r(boolean success) {
            return new R().setCode(success ? CODE_SUCCESS : CODE_ERROR_DEFAULT);
        }
    
        public static R r(boolean success, String msg) {
            return r(success).setMsg(msg);
        }
    
        public static R r(boolean success, Object data) {
            return r(success).setData(data);
        }
        
        public static R exception(String msg) {
            throw new CommonException(msg);
        }
    
        public static R exception(CodeMsg codeMsg) {
            throw new CommonException(codeMsg);
        }
    }
    
    @ControllerAdvice
    public class ExceptionInterceptor {
        @ExceptionHandler(Throwable.class)
        public void handle(Throwable throwable,
                           HttpServletResponse response) throws Exception {
    //        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE/*"application/json; charset=UTF-8"*/);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(StandardCharsets.UTF_8.displayName());
            response.setStatus(400);
    //        response.getWriter().write(Rs.error(getRealCause(throwable)).json());
            response.getWriter().write(Rs.error(throwable).json());
        }
    
        private Throwable getRealCause(Throwable throwable) {
            Throwable cause = throwable.getCause();
            while (cause != null) {
                throwable = cause;
                cause = cause.getCause();
            }
            return throwable;
        }
    }
    
  • 其实异常处理器还有更简便的写法:
    【Java从零到架构师第③季】【项目实战】驾考管理系统_第26张图片

    @RestControllerAdvice
    @Slf4j
    public class CommonExceptionHandler {
        @ExceptionHandler(Throwable.class)
        @ResponseStatus(code = HttpStatus.BAD_REQUEST)
        public JSONResult handle(Throwable throwable) {
    //        System.out.println(throwable);
            log.error("error", throwable);
            return JSONResults.exception(throwable);
        }
    }
    
  • Controller使用:

    @RestController
    @RequestMapping("/dictTypes")
    public class DictTypeController {
        @Autowired
        private DictTypeService service;
    
        @GetMapping
        public R list(DictTypeQuery query) {
            service.list(query);
            return Rs.success(query).add(Rs.K_COUNT, query.getTotal());
        }
    
        @PostMapping("/remove")
        public R remove(String id) {
            // id = "10"
            // id = "1, 20, 23"
            final String[] ids = id.split(",");
            if (!service.removeByIds(Arrays.asList(ids))) {
                throw new CommonException(CodeMsg.REMOVE_ERROR);
            }
            return Rs.success(CodeMsg.REMOVE_OK);
        }
    
        @PostMapping("/save")
        public R save(DictType dictType) {
            if (!service.saveOrUpdate(dictType)) {
                throw new CommonException(CodeMsg.SAVE_ERROR);
            }
            return Rs.success(CodeMsg.SAVE_OK.getMsg());
        }
    }
    
  • 还可以将Controller中的公共代码抽取出来:

    public abstract class BaseController<T> {
        protected abstract IService<T> service();
    
        @GetMapping("/list")
        public R list() {
            return Rs.success(service().list());
        }
        
        @PostMapping("/remove")
        public R remove(String id) {
            final String[] ids = id.split(",");
            if (!service().removeByIds(Arrays.asList(ids))) {
                Rs.exception(CodeMsg.REMOVE_ERROR);
            }
            // return Rs.success(CodeMsg.REMOVE_OK.getMsg());
            return Rs.success(CodeMsg.REMOVE_OK);
        }
    
        @PostMapping("/save")
        public R save(T entity) {
            if (!service().saveOrUpdate(entity)) {
                // throw new CommonException(CodeMsg.SAVE_ERROR);
                Rs.exception(CodeMsg.SAVE_ERROR);
            }
            return Rs.success(CodeMsg.SAVE_OK);
        }
    }
    
    @RestController
    @RequestMapping("/dictTypes")
    public class DictTypeController extends BaseController<DictType> {
        @Autowired
        private DictTypeService service;
    
        @GetMapping
        public R list(DictTypeQuery query) {
            service.list(query);
            return Rs.success(query).add(Rs.K_COUNT, query.getTotal());
        }
    
        @Override
        protected IService<DictType> service() {
            return service;
        }
    }
    

统一异常处理—配合Shiro

@RestControllerAdvice只能拦截到Controller抛出的异常

【Java从零到架构师第③季】【项目实战】驾考管理系统_第27张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第28张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第29张图片

public class ErrorFilter implements Filter {
    public static final String ERROR_URI = "/handleError";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (Exception e) {
            request.setAttribute(ERROR_URI, e);
            request.getRequestDispatcher(ERROR_URI).forward(request, response);
        }
    }
}
@Configuration
public class SpringMVCConfig implements WebMvcConfigurer {
    @Bean
    public FilterRegistrationBean<Filter> filterRegistrationBean() {
        FilterRegistrationBean<Filter> bean = new FilterRegistrationBean<>();
        // 设置Filter
        bean.setFilter(new ErrorFilter());
        bean.addUrlPatterns("/*");
        // 最高权限
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return bean;
    }
}
@RestController
public class ErrorController {
    @RequestMapping(ErrorFilter.ERROR_URI)
    public void handle(HttpServletRequest request) throws Exception {
        // 抛出异常
        throw (Exception) request.getAttribute(ErrorFilter.ERROR_URI);
    }
}
@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler {
    @ExceptionHandler(Throwable.class)
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    public JSONResult handle(Throwable t) {
        log.error("handle", t);

        // 一些可以直接处理的异常
        if (t instanceof CommonException) {
            return handle((CommonException) t);
        } else if (t instanceof BindException) {
            return handle((BindException) t);
        } else if (t instanceof ConstraintViolationException) {
            return handle((ConstraintViolationException) t);
        } else if (t instanceof AuthorizationException) {
            return JSONResults.error(CodeMsg.NO_PERMISSION);
        }

        // 处理cause异常(导致产生t的异常)
        Throwable cause = t.getCause();
        if (cause != null) {
            return handle(cause);
        }

        // 其他异常(没有cause的异常)
        return JSONResults.error();
    }

    private JSONResult handle(CommonException ce) {
        return JSONResults.error(ce.getCode(), ce.getMessage());
    }

    private JSONResult handle(BindException be) {
        List<ObjectError> errors = be.getBindingResult().getAllErrors();
        // 函数式编程的方式:stream
        List<String> defaultMsgs = Streams.map(errors, ObjectError::getDefaultMessage);
        String msg = StringUtils.collectionToDelimitedString(defaultMsgs, ", ");
        return JSONResults.error(msg);
    }

    private JSONResult handle(ConstraintViolationException cve) {
        List<String> msgs = Streams.map(cve.getConstraintViolations(), ConstraintViolation::getMessage);
        String msg = StringUtils.collectionToDelimitedString(msgs, ", ");
        return JSONResults.error(msg);
    }
}
@Configuration
public class ShiroConfig {
    /**
     * ShiroFilterFactoryBean用来告诉Shiro如何进行拦截
     * 1.拦截哪些URL
     * 2.每个URL需要经过哪些filter
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm/*, JkProperties properties*/) {
        ShiroFilterFactoryBean filterBean = new ShiroFilterFactoryBean();

        // 安全管理器
        filterBean.setSecurityManager(new DefaultWebSecurityManager(realm));

        // 添加一些自定义Filter
        Map<String, Filter> filters = new HashMap<>();
        filters.put("token", new TokenFilter());
        filterBean.setFilters(filters);

        // 设置URL如何拦截
        // Filter的顺序很重要,因此需要使用LinkedHashMap
        Map<String, String> urlMap = new LinkedHashMap<>();

		// ...

        // 放行全局Filter的异常处理
        urlMap.put(ErrorFilter.ERROR_URI, "anon");

        // 其他
        urlMap.put("/**", "token");
        filterBean.setFilterChainDefinitionMap(urlMap);

        return filterBean;
    }
}

数据的一致性

外键有优点,同样,也有它的缺点

  • 如果在项目中使用了外键,那么表与表之间的数据一致性其实是不用我们操心的,因为有外键自动帮我们约束。
  • 因此对于那些小型的、对于数据一致性要求很高的项目,需要使用外键。
  • 但是大型的、分布式的互联网项目出于对数据库的性能、备份、迁移、维护等原因的考虑,一般而言在设计数据库时是不使用外键的,那么这时表与表之间的联系,也就是数据一致性问题怎么解决呢?
  • 答案就是需要我们自己在应用层做好相应的处理,但做好这个处理并不简单。

假设现在要对某张表的进行数据一致性的处理,有许多非常麻烦的点

  • 虽然数据库没有使用外键,但对业务来讲,表与表之间应该是有联系的
  • 那么对这张表进行删除、更新、添加等操作都需要考虑数据一致性,具体到代码,就是remove、save、update这种方法有很多,需要都考虑到
  • 有可能会有很多表在数据上都关联这张表,因此我们需要清楚每一张表与每一张表之间的关联关系,知道了表与表之间的关联关系后,才能去逐个处理

解决方案:自己写一个保证数据一致性的小框架,这个小框架的特点:

  • 注解驱动
  • AOP
  • 反射

MJ老师编写框架经验:

  • 如果你自己想写一个比较好用的框架
  • 首先应该从应用的角度出发,先从使用者(自己、其他开发者)的角度出发
  • 考虑别人应该怎么用这个框架、这个框架能够怎样简化开发、这个框架怎么样能够使开发变得更爽、更高效、更敏捷
  • 然后再考虑减少BUG
  • 然后再考虑安全问题
  • 然后再考虑性能问题
  • 然后再考虑解耦、抽取、可扩展…

拼音库—tinypinyin的使用

	
	<dependency>
	    <groupId>com.github.promeggroupId>
	    <artifactId>tinypinyinartifactId>
	    <version>2.0.3version>
	dependency>
    // IService.saveOrUpdate方法里面会调用updateById或者save方法,因此需要重写这两个方法对拼音进行处理
    @Override
    public boolean updateById(PlateRegion entity) {
        processPinyin(entity);
        return super.updateById(entity);
    }
    @Override
    public boolean save(PlateRegion entity) {
        processPinyin(entity);
        return super.save(entity);
    }
    private void processPinyin(PlateRegion entity) {
        final String name = entity.getName();
        if (StringUtils.isEmpty(name)) return;
        entity.setPinyin(Pinyin.toPinyin(name, "_"));
    }

MapStruct

作用:对象转换

  • po ——> vo
  • vo ——> po
	<dependency>
	    <groupId>org.mapstructgroupId>
	    <artifactId>mapstructartifactId>
	    <version>${map.struct.version}version>
	    <scope>providedscope>
	dependency>
	<dependency>
	    <groupId>org.mapstructgroupId>
	    <artifactId>mapstruct-processorartifactId>
	    <version>${map.struct.version}version>
	    <scope>providedscope>
	dependency>
@Mapper
public interface MapStruct {
    MapStruct INSTANCE = Mappers.getMapper(MapStruct.class);

    DictItem vo2po(ReqSaveDictItem reqSaveVo);
    DictType vo2po(ReqSaveDictType reqSaveVo);
    ExamPlace vo2po(ReqSaveExamPlace reqSaveVo);
    ExamPlaceCourse vo2po(ReqSaveExamPlaceCourse reqSaveVo);
    PlateRegion vo2po(ReqSavePlateRegion reqSaveVo);

    RespDictItem po2vo(DictItem po);
    RespDictType po2vo(DictType po);
    RespExamPlace po2vo(ExamPlace po);
    RespExamPlaceCourse po2vo(ExamPlaceCourse po);
    RespPlateRegion po2vo(PlateRegion po);
}

基本使用:

	final DictItem dictItem = MapStruct.INSTANCE.vo2po(new ReqSaveDictItem());
	final RespDictItem respDictItem = MapStruct.INSTANCE.po2vo(new DictItem());

项目中使用:

public abstract class BaseController<T, ReqSave> {
    protected abstract Function<ReqSave, T> function();
    // ...
    @PostMapping("/save")
    public JSONResult save(@Valid ReqSave entity) {
        service.saveOrUpdate(function().apply(entity));
    }
}
public class DictItemController extends BaseController<DictItem, ReqSaveDictItem> {
    // ...
    @Override
    protected Function<ReqSaveDictItem, DictItem> function() {
        return MapStruct.INSTANCE::vo2po;
    }
}
public class PlateRegionServiceImpl extends ServiceImpl<PlateRegionMapper, PlateRegion> implements PlateRegionService {
    public JSONDataResult<List<RespPlateRegion>> listProvinces() {
	    // ...
        final List<RespPlateRegion> data = baseMapper.selectList(wrapper)
                .stream().map(MapStruct.INSTANCE::po2vo)
                .collect(Collectors.toList());
        return JSONResults.success(data);
    }
}

自定义转换规则

public class MapStructFormatter {
    @Qualifier
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.CLASS)
    public @interface Date2Millis {}

    @Date2Millis
    public static Long date2millis(Date date) {
        if (date == null) return null;
        return date.getTime();
    }

	/*
    @Qualifier
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.CLASS)
    public @interface Mills2Date {}

    @Mills2Date
    public static Date millis2date(Long mills) {
        if (mills == null) return null;
        return new Date(mills);
    }
    */
}
@Data
public class SysUser {
	// ...
    //最后一次登录的时间
    private Date loginTime;
}
@Data
@ApiModel("系统用户")
public class RespSysUser {
	// ...
	
    @ApiModelProperty("最后一次登录的时间")
    // 前后端分离一般返回UNIX时间戳
    // UNIX时间戳:从 1970-1-1 0:0:0 开始到现在走过的毫秒数
    private Long loginTime;
}
/**
 * ReqVo -> Po
 * Po -> Vo
 */
@Mapper(uses = {
        MapStructFormatter.class
})
public interface MapStructs {
	// ...
    @Mapping(source = "loginTime",
            target = "loginTime",
            qualifiedBy = MapStructFormatter.Date2Millis.class)
    RespSysUser po2vo(SysUser po);
	// ...
}

登录—简单登录

	
	<dependency>
	    <groupId>com.github.whvcsegroupId>
	    <artifactId>easy-captchaartifactId>
	    <version>1.6.2version>
	dependency>

【Java从零到架构师第③季】【项目实战】驾考管理系统_第30张图片

@RestController
@RequestMapping("/sysUsers")
@Api(tags = "系统用户", description = "SysUser")
public class SysUserController extends BaseController<SysUser, ReqSaveSysUser> {
    @Autowired
    private SysUserService service;

    @GetMapping("/captcha")
    @ApiOperation("生成验证码")
    public void captcha(HttpServletRequest request,
                        HttpServletResponse response) throws Exception {
        CaptchaUtil.out(request, response);
    }

    @PostMapping("/login")
    @ApiOperation("登录")
    public JSONDataResult<RespLogin> login(ReqLogin reqVo, HttpServletRequest request) {
        if (CaptchaUtil.ver(reqVo.getCaptcha(), request)) {
            return JSONResults.success(service.login(reqVo));
        }
        JSONResults.exception(CodeMsg.WRONG_CAPTCHA);
        return null;
    }
}
	@Override
	public RespLogin login(ReqLogin reqVo) {
	    // 根据用户名查询用户
	    MPLambdaQueryWrapper<SysUser> wrapper = new MPLambdaQueryWrapper<>();
	    wrapper.eq(SysUser::getUsername, reqVo.getUsername());
	    SysUser po = baseMapper.selectOne(wrapper);
	
	    // 用户名不存在
	    if (po == null) {
	        return JsonVos.raise(CodeMsg.WRONG_USERNAME);
	    }
	
	    // 密码不正确
	    if (!po.getPassword().equals(reqVo.getPassword())) {
	        return JsonVos.raise(CodeMsg.WRONG_PASSWORD);
	    }
	
	    // 账号锁定
	    if (po.getStatus() == Constants.SysUserStatus.LOCKED) {
	        return JsonVos.raise(CodeMsg.USER_LOCKED);
	    }

		// 登录成功
	
	    // 更新登录时间
	    po.setLoginTime(new Date());
	    baseMapper.updateById(po);
	
	    // 返回给客户端的具体数据
	    RespLogin vo = MapStruct.INSTANCE.po2loginVo(po);
	    return vo;
	}

前端Ajax登录:

Ajaxs.loadPost({
    uri: 'sysUsers/login',
    data: data.field,
    success: (response) => {
        location.href = '../index.html'
    },
    xhrFields: { // 需要跨域带上cookie
        withCredentials: true
    }
}

登录—Token

后端

  • 登录

    	@Data
    	@ApiModel("登录成功的结果")
    	public class RespLogin {
    		// ...
    	    @ApiModelProperty("登录令牌")
    	    private String token;
    	}
    
        @PostMapping("/login")
        @ApiOperation("登录")
        public JSONDataResult<RespLogin> login(ReqLogin reqVo, HttpServletRequest request) {
            if (CaptchaUtil.ver(reqVo.getCaptcha(), request)) {
                return JSONResults.success(service.login(reqVo));
            }
            JSONResults.exception(CodeMsg.WRONG_CAPTCHA);
            return null;
        }
    
        @Override
        public RespLogin login(ReqLogin reqVo) {
            // 根据用户名查询用户
            MPLambdaQueryWrapper<SysUser> wrapper = new MPLambdaQueryWrapper<>();
            wrapper.eq(SysUser::getUsername, reqVo.getUsername());
            SysUser po = baseMapper.selectOne(wrapper);
    
            // 用户名不存在
            if (po == null) {
                return JsonVos.raise(CodeMsg.WRONG_USERNAME);
            }
            // 密码不正确
            if (!po.getPassword().equals(reqVo.getPassword())) {
                return JsonVos.raise(CodeMsg.WRONG_PASSWORD);
            }
            // 账号锁定
            if (po.getStatus() == Constants.SysUserStatus.LOCKED) {
                return JsonVos.raise(CodeMsg.USER_LOCKED);
            }
    
            /**** 登录成功 ****/
            
            // 更新登录时间
            po.setLoginTime(new Date());
            baseMapper.updateById(po);
    
            // 生成Token,发送Token给用户
            String token = UUID.randomUUID().toString();
            // 存储token到缓存中
            Caches.putToken(token, po);
            
            // 返回给客户端的具体数据
            RespLogin vo = MapStruct.INSTANCE.po2loginVo(po);
            vo.setToken(token);
            return vo;
        }
    
  • 退出登录

        @PostMapping("/logout")
        @ApiOperation("退出登录")
        public JSONResult logout(@RequestHeader("Token") String token) {
            Caches.removeToken(token);
            return JSONResults.success(CodeMsg.LOGOUT_OK);
        }
    

前端

  • 登录:

    class DataKey {
        static USER = 'user'
        static TOKEN = 'token'
        static TOKEN_HEADER = 'Token'
    }
    
    	Ajaxs.loadPost({
    	    uri: 'sysUsers/login',
    	    data: data.field,
    	    success: (response) => {
    	        Datas.save(DataKey.USER, response.data)
    	        location.href = '../index.html'
    	    },
    	    xhrFields: { // 需要跨域带上cookie
    	        withCredentials: true
    	    }
    	})
    
  • 需要确保登录后的每次请求都带上Token信息

        static get() { // Datas.get
            let ret = layui.data(this.TABLE)
            for (let i = 0; i < arguments.length; i++) {
                if (!ret) return null
                ret = ret[arguments[i]]
            }
            return ret
        }
        
        static _addTokenHeader(cfg) {
            // 取出token
            const token = Datas.get(DataKey.USER, DataKey.TOKEN)
            if (token) {
                if (!cfg.headers) {
                    cfg.headers = {}
                }
                // 将token放到请求头
                cfg.headers[DataKey.TOKEN_HEADER] = token
            }
        }
    
    	// 自己封装的Ajax请求,每一次请求都需要带上Token信息
        static ajax(cfg) {
            cfg.url = Commons.url(cfg.uri)
            Commons._addTokenHeader(cfg)
            $.ajax(cfg)
        }
    
    	// Layui发送请求时也需要带上Token信息
        _init() {
            const cfg = this._commonCfg()
            cfg.url = Commons.url(this._cfg.uri)
            $.extend(cfg, this._cfg)
            cfg.elem = cfg.selector
    
            Commons._addTokenHeader(cfg)
    
            this._innerTable = this._layuiTable().render(cfg)
            this._cfg = cfg
        }
    
  • 登出:

    	$('.login-out').click(() => {
    	    // 发送请求给服务器:退出登录
    	    Ajaxs.loadPost({
    	        uri: 'sysUsers/logout',
    	        success: () => {
    	            // 清除客户端缓存
    	            Datas.remove(DataKey.USER)
    	            // 提示
    	            Layers.msgSuccess('退出登录成功', () => {
    	                location.href = 'page/login.html'
    	            })
    	        }
    	    })
    	})
    

权限管理—RBAC

可以登录后台管理系统的员工/系统管理员:比如:sys_user(表名以sys_开头)

使用产品的用户/客户(APP、小程序、网页):比如:user

【Java从零到架构师第③季】【项目实战】驾考管理系统_第31张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第32张图片

表结构设计

可以登录后台管理系统的员工/系统管理员:比如:sys_user(表名以sys_开头)

使用产品的用户/客户(APP、小程序、网页):比如:user

create table if not exists jk.sys_resource
(
	id tinyint unsigned auto_increment comment '主键'
		primary key,
	name varchar(15) default '' not null comment '名称',
	uri varchar(100) default '' not null comment '链接地址',
	permission varchar(100) default '' not null comment '权限标识',
	type tinyint unsigned default 0 not null comment '资源类型(0是目录,1是菜单,2是按钮)PS:按钮就是增删改查之类的能点击的',
	icon varchar(100) default '' not null comment '图标',
	sn tinyint unsigned default 0 not null comment '序号',
	parent_id tinyint unsigned default 0 not null comment '父资源id',
	constraint sys_resource_parent_id_name_uindex
		unique (parent_id, name)
)
comment '资源';

create table if not exists jk.sys_role
(
	id tinyint unsigned auto_increment comment '主键'
		primary key,
	name varchar(15) default '' not null comment '角色名称',
	constraint sys_role_name_uindex
		unique (name)
)
comment '角色';

create table if not exists jk.sys_role_resource
(
	role_id tinyint unsigned default 0 not null comment '角色id',
	resource_id tinyint unsigned default 0 not null comment '资源id',
	primary key (resource_id, role_id)
)
comment '角色-资源';

create table if not exists jk.sys_user
(
	id smallint unsigned auto_increment comment '主键'
		primary key,
	nickname varchar(15) default '' not null comment '昵称',
	username varchar(15) default '' not null comment '登录用的用户名',
	password char(32) default '' not null comment '登录用的密码,密码经过MD5加密之后就是32位的字符串',
	create_time datetime default CURRENT_TIMESTAMP not null comment '创建的时间',
	login_time datetime null comment '最后一次登录的时间',
	status tinyint unsigned default 0 not null comment '账号的状态,0是正常,1是锁定',
	constraint sys_user_username_uindex
		unique (username)
)
comment '用户(可以登录后台系统的)';

create table if not exists jk.sys_user_role
(
	role_id tinyint unsigned default 0 not null comment '角色id',
	user_id smallint unsigned default 0 not null comment '用户id',
	primary key (user_id, role_id)
)
comment '用户-角色';

逻辑删除

  • 物理删除:真正从数据库中删除了,永久消失。

  • 逻辑删除(假删除、软删除):数据还在数据库中,只是对用户来说,数据被删掉了。

  • 逻辑删除的实现:在需要实现逻辑删除的表中增加一个字段来标识数据是否被删除。

【Java从零到架构师第③季】【项目实战】驾考管理系统_第33张图片

逻辑删除—MyBatisPlus

【Java从零到架构师第③季】【项目实战】驾考管理系统_第34张图片

# db: test
create table user
(
	id int unsigned auto_increment
		primary key,
	name varchar(15) default '' not null,
	deleted tinyint unsigned default 0 not null comment '1是被删除,0是未删除',
	constraint user_name_uindex
		unique (name)
);

企业级文件上传

  • 方案一:文件数据和表单数据一起提交(之前Java②学过的那种)

  • 方案二:专门弄一个文件服务器,用来操作文件(文件上传、下载、删除等)。文件数据先单独提交,从文件服务器返回一个uri,拿着这个uri和表单数据一起提交【前端需要进行用户的行为控制,比较复杂一点】【Java从零到架构师第③季】【项目实战】驾考管理系统_第35张图片

@RequestBody修饰的请求参数

  1. 前端/客户端将Content-Type改为:application/json

  2. 请求体传递符合要求的JSON字符串【JSON优势:灵活、前端处理方便、第三方库也多】

        @PostMapping("/save1")
        @ApiOperation("添加或更新")
        public JsonVo save1(User user) { // 相当于是加了@RequestParam
            JsonVo jsonVo = new JsonVo();
            jsonVo.setMsg(service.saveOrUpdate(user) ? "保存成功" : "保存失败");
            return jsonVo;
        }
        
        @PostMapping("/save2")
        @ApiOperation("添加或更新")
        public JsonVo save2(@RequestBody User user) { // 要求前端客户端传一个User类型的JSON字符串过来,请求体是一个JSON
            JsonVo jsonVo = new JsonVo();
            jsonVo.setMsg(service.saveOrUpdate(user) ? "保存成功" : "保存失败");
            return jsonVo;
        }
    

单元测试

Spring单元测试

【Java从零到架构师第③季】【项目实战】驾考管理系统_第36张图片

【Java从零到架构师第③季】【项目实战】驾考管理系统_第37张图片












SpringBoot单元测试

【Java从零到架构师第③季】【项目实战】驾考管理系统_第38张图片

 
 <dependency>
     <groupId>org.springframework.bootgroupId>
     <artifactId>spring-boot-starter-testartifactId>
     <scope>testscope>
 dependency>
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAMQPTest {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSendSimpleQueue() {
        String queueName = "simple.queue";
        Object message = "Hello, SpringAMQP! I am LP!";
        rabbitTemplate.convertAndSend(queueName, message);
    }
}

打包部署

【Java从零到架构师第③季】【项目实战】驾考管理系统_第39张图片

打包部署—jar

【Java从零到架构师第③季】【项目实战】驾考管理系统_第40张图片

打包部署—war

【Java从零到架构师第③季】【项目实战】驾考管理系统_第41张图片
【Java从零到架构师第③季】【项目实战】驾考管理系统_第42张图片

注意

配置JackSON将Model转为JSON时,不包含值为null的属性:

  • application.yml:

    spring:
      jackson:
        default-property-inclusion: non_null
    
  • Java代码:

    public final class JSONs {
        private JSONs() {
        }
    
        private static final ObjectMapper MAPPER = new ObjectMapper();
    
        static {
            MAPPER.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
        }
        public static ObjectMapper getMAPPER() {
            return MAPPER;
        }
    }
    

  • MySQL配置:

    #url=jdbc:mysql://127.0.0.1:3306/lp_resume
    #url=jdbc:mysql://localhost:3306/lp_resume
    #url=jdbc:mysql:///lp_resume
    
    #UTC:世界同一时间
    #url=jdbc:mysql:///lp_resume?serverTimezone=UTC&useSSL=false
    #中国时间:serverTimezone=Asia/Shanghai == serverTimezone=GMT+8
    #url=jdbc:mysql:///lp_resume?serverTimezone=GMT+8&useSSL=false
    url=jdbc:mysql:///lp_resume?serverTimezone=Asia/Shanghai&useSSL=false
    
    ############使用IDEA连接数据库############
    #使用IDEA连接MySQL数据库时,由于“+”是一个特殊字符,因此需要编码处理为:“%2B”
    #例如:jdbc:mysql:///?serverTimezone=GMT%2B8&useSSL=false
    #或者:jdbc:mysql:///?serverTimezone=Asia/Shanghai&useSSL=false
    
  • HTML的button标签,默认类型是,因此button如果是其它类型的话,最好显示声明button的type,比如:

  • 客户端向服务器发送请求参数时

    • 如果http://localhost:8080/jk/dictTypes/list?page=1&size=20,那么服务器获取到的keyword就是null
    • 如果http://localhost:8080/jk/dictTypes/list?page=1&size=20&keyword=,那么服务器获取到的keyword就是""(空字符串)
  • 数据库中,表名和字段名建议使用``、字符串建议使用’'(单引号)

  • MySQL数据库,行(记录)从0开始,列(字段)从1开始

  • 标准JSON格式:key使用""(双引号):

    [
      {
        "age": 10,
        "name": "lp"
      },
      {
        "age": 20,
        "name": "ruoyu"
      }
    ]
    
    {
      "string": "value",
      "integer": 10,
      "bool": true,
      "null": null,
      "array": [],
      "obj": {}
    }
    

补充:ChromeJSON插件

https://chrome.google.com/webstore/detail/json-handle/iahnhfdhidomcpggpaimmmahffihkfnj

【Java从零到架构师第③季】【项目实战】驾考管理系统_第43张图片

参考

小码哥-李明杰: Java从0到架构师③进阶互联网架构师.


本文完,感谢您的关注支持!


你可能感兴趣的:(JAVA,java,spring,boot,MyBatisPlus,Shiro)

${user.name}${user.age}