SSM综合案例

RBAC+CRM

SSM综合案例

1、项目集成

集成步骤:

先用 Spring 集成 MyBatis

  • 搭建项目,添加依赖
  • 将 Spring 、MyBatis 配置文件放进 resources
  • 配置数据库连接池
  • 配置 SqlSessionFactory
  • 配置 Mapper 对象
  • 配置业务 Service 对象
  • 配置事务相关

再加入 SpringMVC

  • 在 web.xml 中配置中央调度器、编码过滤器
  • 在 resources 中添加 MVC 配置文件,配置mvc注解解析器,扫描控制器,静态资源处理、视图解析器等
  • 在 mvc 配置文件中引入 Spring 配置文件

1、MyBatis逆向工程

配置插件

<!-- mybatis的generator插件,运行命令:mybatis-generator:generate  -->
<plugin>
    <groupId>org.mybatis.generator</groupId>
    <artifactId>mybatis-generator-maven-plugin</artifactId>
    <version>1.3.6</version>
    <configuration>
        <verbose>true</verbose>
        <!-- 代表mybatis generator生成的内容不要覆盖已有的内容 -->
        <overwrite>false</overwrite>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.45</version>
        </dependency>
    </dependencies>
</plugin>

新建配置文件

在 resources 目录中新建配置文件 generatorConfig.xml

注意:

generatorConfig.xml的头文件http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd标红

解决方案:左边有红色小灯泡,点击Fetch external resource即可解决

Fetch external resource 获取外部资源

<?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>
    <context id="mysql" defaultModelType="hierarchical" targetRuntime="MyBatis3Simple">
        <!-- 自动识别数据库关键字,默认false,如果设置为true,根据SqlReservedWords中定义的关键字列表; 一般保留默认值,遇到数据库关键字(Java关键字),使用columnOverride覆盖 -->
        <property name="autoDelimitKeywords" value="false"/>
        <!-- 生成的Java文件的编码 -->
        <property name="javaFileEncoding" value="UTF-8"/>
        <!-- 格式化java代码 -->
        <property name="javaFormatter" value="org.mybatis.generator.api.dom.DefaultJavaFormatter"/>
        <!-- 格式化xml代码 -->
        <property name="xmlFormatter" value="org.mybatis.generator.api.dom.DefaultXmlFormatter"/>

        <!-- beginningDelimiter和endingDelimiter:指明数据库的用于标记数据库对象名的符号,比如ORACLE就是双引号,MYSQL默认是`反引号; -->
        <property name="beginningDelimiter" value="`"/>
        <property name="endingDelimiter" value="`"/>

        <!-- 注释生成器 -->
        <commentGenerator>
            <property name="suppressDate" value="true"/>
            <property name="suppressAllComments" value="true"/>
        </commentGenerator>

        <!-- 必须要有的,使用这个配置链接数据库 :是否可以扩展 -->
        <jdbcConnection driverClass="com.mysql.jdbc.Driver"
                        connectionURL="jdbc:mysql:///rbac" userId="root" password="admin">
            <!-- 这里面可以设置property属性,每一个property属性都设置到配置的Driver上 -->
        </jdbcConnection>

        <!-- java模型创建器,是必须要的元素 负责:1,key类(见context的defaultModelType);2,java类;3,查询类
            targetPackage:生成的类要放的包,真实的包受enableSubPackages属性控制; targetProject:目标项目,指定一个存在的目录下,生成的内容会放到指定目录中,如果目录不存在,MBG不会自动建目录 -->
        <javaModelGenerator targetPackage="com.domain" targetProject="src/main/java">
            <!-- for MyBatis3/MyBatis3Simple 自动为每一个生成的类创建一个构造方法,构造方法包含了所有的field;而不是使用setter; -->
            <property name="constructorBased" value="false"/>
            <!-- for MyBatis3/MyBatis3Simple 是否创建一个不可变的类,如果为true, 那么MBG会创建一个没有setter方法的类,取而代之的是类似constructorBased的类 -->
            <property name="immutable" value="false"/>
            <!-- 设置是否在getter方法中,对String类型字段调用trim()方法 -->
            <!--<property name="trimStrings" value="true"/>-->
        </javaModelGenerator>

        <!-- 生成SQL map的XML文件生成器, 注意,在Mybatis3之后,我们可以使用mapper.xml文件+Mapper接口(或者不用mapper接口),
            或者只使用Mapper接口+Annotation,所以,如果 javaClientGenerator配置中配置了需要生成XML的话,这个元素就必须配置
            targetPackage/targetProject:同javaModelGenerator -->
        <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
            <!-- 在targetPackage的基础上,根据数据库的schema再生成一层package,最终生成的类放在这个package下,默认为false -->
            <property name="enableSubPackages" value="true"/>
        </sqlMapGenerator>

        <!-- 对于mybatis来说,即生成Mapper接口,注意,如果没有配置该元素,那么默认不会生成Mapper接口 targetPackage/targetProject:同javaModelGenerator
            type:选择怎么生成mapper接口(在MyBatis3/MyBatis3Simple下):
            1,ANNOTATEDMAPPER:会生成使用Mapper接口+Annotation的方式创建(SQL生成在annotation中),不会生成对应的XML;
            2,MIXEDMAPPER:使用混合配置,会生成Mapper接口,并适当添加合适的Annotation,但是SQL也会生成在XML中;
            3,XMLMAPPER:会生成Mapper接口,接口完全依赖XML;
            注意,如果context是MyBatis3Simple:只支持ANNOTATEDMAPPER和XMLMAPPER -->
        <javaClientGenerator targetPackage="com.mapper"
                             type="XMLMAPPER" targetProject="src/main/java">
            <!-- 在targetPackage的基础上,根据数据库的schema再生成一层package,最终生成的类放在这个package下,默认为false -->
            <property name="enableSubPackages" value="true"/>

            <!-- 可以为所有生成的接口添加一个父接口,但是MBG只负责生成,不负责检查 <property name="rootInterface"
                value=""/> -->
        </javaClientGenerator>

        <table tableName="department" domainObjectName="Department">
            <property name="useActualColumnNames" value="true"/>
            <!-- 参考 javaModelGenerator 的 constructorBased属性 -->
            <property name="constructorBased" value="false"/>
            <generatedKey column="id" sqlStatement="JDBC"/>
        </table>
    </context>
</generatorConfiguration>

运行插件

运行插件 ,双击 mybatis-generator:generate

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8FafEBlW-1649928393030)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220311111859757.png)]

如果执行时找不到 generatorConfig.xml 文件,可以先打包 package

2、编写业务接口及实现

@Transactional
@Service
public class DepartmentServiceImpl implements DepartmentService {
    @Autowired
    private DepartmentMapper mapper;

    @Override
    public void delete(Long id) {
        mapper.deleteByPrimaryKey(id);
    }

    @Override
    public void save(Department department) {
        mapper.insert(department);
    }

    @Override
    public Department get(Long id) {
        return mapper.selectByPrimaryKey(id);
    }

    @Override
    public List<Department> listAll() {
        return mapper.selectAll();
    }

    @Override
    public void update(Department department) {
        mapper.updateByPrimaryKey(department);
    }
}

3、编写 spring 配置文件

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
   xmlns:context="http://www.springframework.org/schema/context"
   xmlns:tx="http://www.springframework.org/schema/tx"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://www.springframework.org/schema/beans 
      http://www.springframework.org/schema/beans/spring-beans.xsd
      http://www.springframework.org/schema/tx
      http://www.springframework.org/schema/tx/spring-tx.xsd
      http://www.springframework.org/schema/context
      http://www.springframework.org/schema/context/spring-context.xsd">



   <!-- 组件扫描器 -->
   <context:component-scan base-package="com" />
   
   <!-- 关联外部文件
      system-properties-mode="NEVER":
         表示在XML文件里面用${ }获取值的时候,仅仅会从指定的properties文件里面获取
    -->
   <context:property-placeholder location="classpath:db.properties"
                                 system-properties-mode="NEVER"/>

   <!-- 配置数据源 -->
   <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
   init-method="init" destroy-method="close">
      <property name="driverClassName" value="${jdbc.driverClassName}" />
      <property name="url" value="${jdbc.url}" />
      <property name="username" value="${jdbc.username}" />
      <property name="password" value="${jdbc.password}" />
   </bean>

   <!-- 配置 SqlSessionFactory 对象 -->
   <bean class="org.mybatis.spring.SqlSessionFactoryBean">
      <!-- 注入数据源 -->
      <property name="dataSource" ref="dataSource" />
      <!-- 关联MyBatis主配置文件,使用 MyBatis 分页插件时需要进行配置 -->
      <property name="configLocation" value="classpath:mybatis-config.xml" />
      <!-- 关联mapper映射文件 -->
      <property name="mapperLocations" value="classpath:mapper/*Mapper.xml" />
      <!-- 配置别名 -->
      <property name="typeAliasesPackage" value="com.domain" />
   </bean>

   <!-- dao 接口扫描 -->
   <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
      <property name="basePackage" value="com.mapper" />
   </bean>

   <!-- 事务管理器 -->
   <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
      <property name="dataSource" ref="dataSource" />
   </bean>
   <!-- 事务注解驱动器 -->
   <tx:annotation-driven transaction-manager="transactionManager"/>

</beans>

4、编写 web.xml

注意:

识别不到中央调度器

Project Structure>Modules>Web中,配置web.xml和web资源目录

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SKmn5r8Q-1649928393034)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220311120900914.png)]

<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
  version="3.1">

   <!-- 配置中央调度器 -->
   <servlet>
      <servlet-name>dispatcherServlet</servlet-name>
      <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
      <init-param>
         <param-name>contextConfigLocation</param-name>
         <param-value>classpath:mvc.xml</param-value>
      </init-param>
      <load-on-startup>1</load-on-startup>
   </servlet>
   <servlet-mapping>
      <servlet-name>dispatcherServlet</servlet-name>
      <url-pattern>*.do</url-pattern>
   </servlet-mapping>

   <!-- 配置编码过滤器,针对POST方式 -->
   <filter>
      <filter-name>CharacterEncodingFilter</filter-name>
      <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
      <init-param>
         <param-name>encoding</param-name>
         <param-value>utf-8</param-value>
      </init-param>
      <init-param>
         <param-name>forceEncoding</param-name>
         <param-value>true</param-value>
      </init-param>
   </filter>
   <filter-mapping>
      <filter-name>CharacterEncodingFilter</filter-name>
      <url-pattern>/*</url-pattern>  
   </filter-mapping>
   
   <!-- 设置网站的首页 -->
<!--   <welcome-file-list>
      <welcome-file>login.html</welcome-file>
   </welcome-file-list>-->
</web-app>

5、编写 mvc 配置文件

在 mvc 配置文件中引入 spring 配置文件

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">

    <!-- 引入 Spring 配置文件 -->
    <import resource="classpath:applicationContext.xml"/>

    <!-- 配置控制器对象 -->
    <context:component-scan base-package="com.web"/>
    <!-- 注解驱动 -->
    <mvc:annotation-driven/>
    <!--配置视图解析器 -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/views/"/>
        <property name="suffix" value=".jsp"/>
    </bean>
</beans>

6、编写 controller

注意:

RequestMapping 中可不加 .do(默认自动加 .do)

如果在 web.xml 中配置中央调度器的地址是 *.do,则会拦截所有的 .do 请求去匹配

<!-- Map all requests to the DispatcherServlet for handling -->
	<servlet-mapping>
		<servlet-name>springDispatcherServlet</servlet-name>
		<url-pattern>*.do</url-pattern>
	</servlet-mapping>

在 Controller 中的 RequestMapping 的 Value 如果是字符串没有不是 .do 结尾,那么 Spring MVC 会默认的加上 .do

web.xml 中配置了url-pattern后,会起到两个作用:
(1)是限制 url 的后缀名,只能为".do"
(2)就是在没有填写后缀时,默认在你配置的 Controller 的 RequestMapping 中添加".do"的后缀

	id.notnull
直接生成
	if (id != null){}

简单 CRUD

2、部门分页查询

编写封装分页参数类

@Data
public class QueryObject {
    private int currentPage = 1;
    private int pageSize = 5;
    private String keyword;

    public int getStart() {
        return (this.currentPage - 1) * this.pageSize;
    }

    public String getKeyword() {
        return StringUtils.hasLength(this.keyword) ? this.keyword : null;
    }

}

编写封装查询结果类

@Getter
@ToString
public class PageResult<T> {
    private Integer currentPage;   //请求传递数据
    private Integer pageSize;      //请求传递数据
    private List<T> list;          //数据库查询数据
    private Integer totalCount;    //数据库查询数据
    //计算得来
    private Integer prevPage;
    private Integer nextPage;
    private Integer totalPage;

    public PageResult(Integer pageSize) {
        this(1, 0, Collections.EMPTY_LIST, pageSize);
    }

    public PageResult(Integer currentPage, Integer totalCount, List<T> list, Integer pageSize) {
        this.currentPage = currentPage;
        this.pageSize = pageSize;
        this.list = list;
        this.totalCount = totalCount;

        if (this.totalCount <= this.pageSize) {   //数据只够一页
            this.totalPage = 1;
            this.prevPage = 1;
            this.nextPage = 1;
            return;
        }

        this.totalPage = totalCount % pageSize == 0 ? totalCount / pageSize : totalCount / pageSize + 1;
        this.prevPage = currentPage > 1 ? currentPage - 1 : 1;
        this.nextPage = currentPage < this.totalPage ? currentPage + 1 : this.totalPage;
    }
}

部门业务接口添加分页功能

@Transactional
@Service
public class DepartmentServiceImpl implements DepartmentService {
    @Autowired
    private DepartmentMapper mapper;
        
    ......
        
     
    @Override
    public PageResult<Department> query(QueryObject qo) {
        int count = mapper.selectForCournt(qo);
        if (count == 0){
            new PageResult<>(count);
            //new PageResult<>(qo.getCurrentPage(), count, Collections.emptyList(), qo.getPageSize());
        }
        //当查询有数据时
        List<Department> list = mapper.selectLimitList(qo);
        return new PageResult<>(qo.getCurrentPage(),count,list,qo.getPageSize());
    }
    
}

部门mapper及 Mapper.xml修改

public interface DepartmentMapper {
    
    ......
        
    int selectForCournt(QueryObject queryObject);
    List<Department> selectLimitList(QueryObject qo);
}
......
<select id="selectForCournt" resultType="java.lang.Integer">
  select count(*)
  from Department
</select>
<select id="selectLimitList" resultType="com.domain.Department">
  select id, name, sn
  from Department
  limit #{start},#{pageSize}
</select>

修改controller查询方法

@Controller
@RequestMapping("/department")
public class DepartmentController {
    @Autowired
    private DepartmentService service;

    //查询部门
    @RequestMapping("/list")   //可以访问 8080/department/list.do 默认自动添加.do
    public String list(Model model, QueryObject queryObject){
        //分页查询数据
        PageResult<Department> result = service.query(queryObject);
        model.addAttribute("result",result);
        // 跳转页面 /WEB-INF/views/department/list.jsp
        return "department/list";
    }

    //删除部门
    @RequestMapping("/delete")
    public String delete(Long id){
        if (id != null)
            service.delete(id);
        return "redirect:/department/list.do";  //让浏览器重新发一次请求
    }

    //添加、编辑
    //从前端页面可知,如携带id,则是编辑;若没有id,则是添加
    @RequestMapping("/input")
    public String input(Long id,Model model){
        if (id != null) {  //是编辑,需要查询被修改的部门,回显在页面上
            model.addAttribute("entity",service.get(id));
        }
        // 跳转页面 /WEB-INF/views/department/input.jsp
        return "department/input";
    }

    //保存部门
    @RequestMapping("/saveOrUpdate")
    public String saveOrUpdate(Department department){
        if (department.getId() != null) {  //是编辑
            service.update(department);
        }else{   //是添加
            service.save(department);
        }
        return "redirect:/department/list.do";  //让浏览器重新发一次请求
    }

}

修改 list.jsp

这里的分页是使用了 twbsPagination ,是 bootstrap样式的分页插件

<script src="/js/jquery/jquery.min.js"></script>
<script src="/js/bootstrap/js/bootstrap.js"></script>
<script src="/js/plugins/twbsPagination/jquery.twbsPagination.min.js"></script>

遍历显示数据

<c:forEach items="${result.list}" var="entity" varStatus="vs">
    <tr>
        <td>${vs.count}</td>
        <td>${entity.name}</td>
        <td>${entity.sn}</td>
        <td>
            <a href="/department/input.do?id=${entity.id}"
               class="btn btn-info btn-xs btn-input">
                <span class="glyphicon glyphicon-pencil"></span> 编辑
            </a>
            <a href="/department/delete.do?id=${entity.id}"
               class="btn btn-danger btn-xs btn-delete">
                <span class="glyphicon glyphicon-trash"></span> 删除
            </a>
        </td>
    </tr>
</c:forEach>

进行分页

<!--分页-->
<div style="text-align: center;">
 <ul id="pagination-demo" class="pagination-sm"></ul>
</div>
<script>
    // twbsPagination是 bootstrap样式的分页插件
    $('#pagination-demo').twbsPagination({
        totalPages: ${result.totalPage}, //总页数
        visiblePages: 5, //显示出来的页数
        startPage: ${result.currentPage}, //当前页
        first:'首页',
        prev:'上一页',
        next:'下一页',
        last:'尾页',
        onPageClick: function (event, page) { //点击页码
            //设置当前页
            $("#currentPage").val(page);
            //提交表单
            $("#searchForm").submit();
        }
    });
</script>

3、员工增删改查

逆向工程生成员工表数据

生成的 实体类中,注意将 admin 字段的类型改为 boolean 类型

编写封装查询参数类

@Data
public class EmployeeQueryObject extends QueryObject {
    private String keyword;
    private Long deptId;
}

拷贝部门业务类、控制器

替换关键字

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hfsAGKy1-1649928393036)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220311155747515.png)]

员工mapper添加分页功能

其中使用了高级查询:根据名字、email模糊查询

public interface EmployeeMapper {
    int deleteByPrimaryKey(Long id);
    int insert(Employee record);
    Employee selectByPrimaryKey(Long id);
    List<Employee> selectAll();
    int updateByPrimaryKey(Employee record);

    int selectForCournt(QueryObject qo);

    List<Employee> selectLimitList(QueryObject qo);
}
<sql id="where_sql">
  <where>
    <if test="keyword != null">
      and (e.name like concat('%', #{keyword}, '%') or e.email like concat('%', #{keyword}, '%'))
    </if>
    <if test="deptId!=-1">
      and e.dept_id = #{deptId}
    </if>
  </where>
</sql>

<select id="selectForCournt" resultType="java.lang.Integer">
  select count(*)
  from employee e
  <include refid="where_sql"/>
</select>
<select id="selectLimitList" resultType="com.domain.Employee">
  select id, name, password, email, age, admin, dept_id
  from employee e
  <include refid="where_sql"/>
  limit #{start},#{pageSize}
</select>

4、员工查询完善

完善超管显示

<td>${entity.admin ? '是':'否'}</td>
private boolean admin;
CREATE TABLE `employee` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `email` varchar(255) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `admin` bit(1) DEFAULT NULL,
  `dept_id` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8

完善部门显示

修改员工实体类

@Data
public class Employee {
    private Long id;
    private String name;
    private String password;
    private String email;
    private Integer age;
    private boolean admin;
    //private Long deptId;
    private Department department;
}

修改 mapper

因为员工实体类中包含了部门实体类作为属性,属于一对一级联,在mapper中需要使用到 association

association 一对一, 一对多 collection,多对多 discrimination

详见 https://blog.csdn.net/qq_20610631/article/details/81671997

员工表中包含外键 部门id,则

在部门的 mapper 中,必须有 selectByPrimaryKey 方法(就是可以通过ID查询数据的方法)

<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="Department">
  select id, name, sn
  from Department
  where id = #{id,jdbcType=BIGINT}
</select>

在员工的 mapper 中,以下语句

<select id="selectLimitList" resultMap="BaseResultMap">
  select id, name, password, email, age, admin, dept_id
  from employee e
  <include refid="where_sql"/>
  limit #{start},#{pageSize}
</select>

其中员工表中 BaseResultMap 如下

<resultMap id="BaseResultMap" type="com.domain.Employee">
  <id column="id" jdbcType="BIGINT" property="id" />
  <result column="name" jdbcType="VARCHAR" property="name" />
  <result column="password" jdbcType="VARCHAR" property="password" />
  <result column="email" jdbcType="VARCHAR" property="email" />
  <result column="age" jdbcType="INTEGER" property="age" />
  <result column="admin" jdbcType="BIT" property="admin" />
  <!--<result column="dept_id" jdbcType="BIGINT" property="deptId" />-->
  <!-- property值为实体类属性名,column值为表中字段名(一定要的用的外键)
       select值为此属性对应dao接口的全限定名称 + 指定方法名
  -->
  <association property="dept" column="dept_id" select="com.mapper.DepartmentMapper.selectByPrimaryKey"/>
</resultMap>

结果:

修改 list.jsp

显示部门名
<td>${entity.dept.name}</td>

完善过滤查询

页面已经提供 关键词输入框、部门下拉框,让用户输入关键字与选择部门,以便过滤查询员工数据,并回显查询条件

修改控制器

查询的语句之前就写好了

<sql id="where_sql">
  <where>
    <if test="keyword != null">
      and (e.name like concat('%', #{keyword}, '%') or e.email like concat('%', #{keyword}, '%'))
    </if>
    <if test="deptId!=-1">
      and e.dept_id = #{deptId}
    </if>
  </where>
</sql>
<select id="selectForCournt" resultType="java.lang.Integer">
  select count(*)
  from employee e
  <include refid="where_sql"/>
</select>
<select id="selectLimitList" resultMap="BaseResultMap">
  select id, name, password, email, age, admin, dept_id
  from employee e
  <include refid="where_sql"/>
  limit #{start},#{pageSize}
</select>

先更改,让页面获取到部门下拉框的所有部门名

//查询所有部门,存入域中,让页面获取到
model.addAttribute("depts", departmentService.listAll());
<select class="form-control" id="dept" name="deptId">
    <option value="-1">全部</option>
    <c:forEach items="${depts}" var="d">
        <option value="${d.id}">${d.name}</option>
    </c:forEach>
</select>

更改控制器方法,让其能回显查询条件

@RequestMapping("/list")   //可以访问 8080/employee/list.do 默认自动添加.do
public String list(Model model, EmployeeQueryObject queryObject){
    //分页查询数据
    PageResult<Employee> result = service.query(queryObject);
    model.addAttribute("result",result);

    //查询所有部门,存入域中,让页面获取到
    model.addAttribute("depts", departmentService.listAll());

    //将queryObject存入域中,为了让表单回显输入框
    model.addAttribute("qo", queryObject);

    // 跳转页面 /WEB-INF/views/employee/list.jsp
    return "employee/list";
}

修改 list.jsp

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5GmMSvFN-1649928393037)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220311220342759.png)]

下面一个部门名的回显,可以使用以下 替代

<c:forEach items="${depts}" var="d">
  <option value="${d.id}" <c:if test="${d.id == qo.deptId}">selected</c:if>>${d.name}</option>
<%--
  <option value="${d.id}" ${d.id == qo.deptId ? 'selected':''}>${d.name}</option>
--%>
</c:forEach>

5、员工新增修改完善

员工新增修改完善

新增员工时,部门不是输入,而是选择

修改员工控制器

添加页面需要下拉框选择部门,所以在添加的方法里需要传入数据

//部门下拉框数据,查询所有部门存入域中,让页面获取到
model.addAttribute("depts", departmentService.listAll());

添加员工的SQL语句是插入部门id,需要更改SQL语句,更改mapper文件

RBAC

RBAC 是一种模型,就是基于角色的访问控制

这种模型的基本概念是把权限(Permission)与角色(Role)联系在一起,用户通过充当合适角色的成员而获得该角色的权限

1、优化部门

部门删除提示

弹出确认框进行提示,并改为ajax异步方式提交

异步请求给用户的体验效果更好,用户不会有明显等待的感觉,不影响页面其他部分

jquery-bootstrap 消息提示插件

  • 引入插件
<script src="/js/plugins/messager/jquery.bootstrap.min.js"/>
  • 使用插件
$(function(){
    //普通提示
    $.messager.alert('This is message!')
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cnbxtS9c-1649928393038)(F:/workspace/RBAC/image/image-20200514113229062.png)]

$(function(){
    //带标题的提示
    $.messager.alert('Title', 'This is message!')
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EPRGsDBF-1649928393038)(F:/workspace/RBAC/image/image-20200514113304998.png)]

$(function(){
    //修改 确认/取消 框的文本
    $.messager.model = {
        ok: {text: '确定'},
        cancel: {text: '取消'}
    }

    //添加 确认/取消 框
    $.messager.confirm('title', 'This is message!', function() { 
        //点击确定后的回调函数
        console.log('you closed it');
    })
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MWQxYB94-1649928393039)(F:/workspace/RBAC/image/image-20200514113435625.png)]

$(function(){
    //更简洁的弹出框,并自动消失
    $.messager.popup("This is message!")
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jICAJuUH-1649928393040)(F:/workspace/RBAC/image/image-20200514113514837.png)]

修改 list.jsp 使用删除提示插件

将原先删除键提交的链接删除

自定义属性,给属性赋值此行数据的 部门 id (使用HTML中 data-自定义属性名 属性来嵌入自定义数据)

<a href="#" data-id="${entity.id}" class="btn btn-danger btn-xs btn-delete">
    <span class="glyphicon glyphicon-trash"></span> 删除
</a>

确认/取消按钮文本抽取到common.js

$(function(){
    //修改确认框的文本
    $.messager.model = {
    	ok:{text:"确认"},
    	cancel:{text:"取消"}
	}
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lwhiwlR0-1649928393041)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313133233776.png)]

给删除键绑定点击事件,使用确认框

<script>
    $(function () {
        //删除点击事件
        // 此处使用按钮标签的class属性来锁定,而不是标签id
        // 因为如果使用id,就仅仅会锁定第一行数据,数据是循环遍历出来的
        $(".btn-delete").click(function () {
            //获取当前点击的部门id
            var id = $(this).data('id');
            //提示确认框
            $.messager.confirm("警告","是否确认删除?",function () {
                //点击确认后会执行的函数
                //发送ajax异步请求
                $.get('/department/delete.do',{id:id},handlerMessage)
            })
        })
    })
</script>

修改后端控制器

因为前后端分离,后端仅仅返回 JSON 格式的数据,跳转页面等的操作交给前端,所以现在要改造后端控制器方法

定义工具类 JsonResult

@Data
@AllArgsConstructor
@NoArgsConstructor
public class JsonResult {
    private boolean success = true;
    private String msg;

    public JsonResult(boolean success) {
        this.success = success;
    }
}

修改后端控制器方法,使用ajax的方式实现删除功能

@RequestMapping("/delete")
@ResponseBody
public JsonResult delete(Long id){
    try {
        departmentService.delete(id);
        return new JsonResult(); //为了方便使用,JsonResult中直接设置success默认为true
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}

修改 list.jsp 页面,执行删除成功之后,重新加载当前页面

前端可以使用window.location.reload重新加载当前页面,维持原本的分页参数(删除之后跳转在删除的当页)

<script>
    function handlerMessage(data){
        if(data.success){ //用 alert 或者 popup 都可以
            $.messager.popup('操作成功')    //jquery-bootstrap消息提示插件
            window.location.reload();  //重新加载当前页面(携带原本的参数),就能回到当前删除的原页面
        }else{
            $.messager.popup(data.msg)
        }
    }
    $(function () {
        //删除点击事件
        // 此处使用按钮标签的class属性来锁定,而不是标签id
        // 因为如果使用id,就仅仅会锁定第一行数据,数据是循环遍历出来的
        $(".btn-delete").click(function () {
            //获取当前点击的部门id
            var id = $(this).data('id');
            //提示确认框
            $.messager.confirm("警告","是否确认删除?",function () {
                //点击确认后会执行的函数
                //发送ajax异步请求
                $.get('/department/delete.do',{id:id},handlerMessage)
            })
        })
    })
</script>

注意,这里使用 window.location.reload 重新加载当前页面,会有一个问题。

当页面仅有一条数据,删除之后,重新加载当前页面,没有数据

查看页面源码如下:当前页数 > 总页数,是bug!!!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SxvvRnFj-1649928393041)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313164831224.png)]

需要修改 DepartmentServiceImpl.query(QueryObject qo) 方法

@Override
public PageResult<Department> query(QueryObject qo) {
    int count = mapper.selectForCournt(qo);
    if (count == 0){
        return new PageResult<>(qo.getCurrentPage(), count,
                                Collections.emptyList(), qo.getPageSize());
    }
    //获取到总页数
    int totalPage = count % qo.getPageSize() == 0 ? 
        					count / qo.getPageSize() : count / qo.getPageSize() + 1;
    //判断当前页是否大于总页数,如果大于,则将最后一页(总页数)作为当前页,来查询分页结果
    if (qo.getCurrentPage() > totalPage)
        qo.setCurrentPage(totalPage);
    //当查询有数据时
    List<Department> list = mapper.selectLimitList(qo);
    return new PageResult<>(qo.getCurrentPage(),count,list,qo.getPageSize());
}

这样,如果当前页只有一条数据,则删除之后跳转到当前页的前一页

将删除的回调函数,抽取commonAll .js ,其他地方的删除操作也可以进行引用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TnEOtAdJ-1649928393042)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313163950375.png)]

2、优化员工

员工删除提示

引入插件

<script src="/js/plugins/messager/jquery.bootstrap.min.js"/>

修改删除点击链接

<a href="#" data-id="${entity.id}" class="btn btn-danger btn-xs btn_delete">
    <span class="glyphicon glyphicon-trash"></span> 删除
</a>

修改后端控制器方法,使用ajax的方式实现删除功能

将跳转页面的操作交给前端

//删除员工
@RequestMapping("/delete")
@ResponseBody
public JsonResult delete(Long id){
    try {
        if (id != null){  //传id值就删除对应的数据
            service.delete(id);
        }
        return new JsonResult();
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}

给删除键绑定点击事件

前端这么跳转页面:使用 window.location.reload() ,重新加载当前页面

<script>
    //ajax的回调函数
    var handlerMessage = function (data) {
        if(data.success){ //用alert或者popup都可以
            $.messager.popup('操作成功')
            //重新加载当前页面(携带原本的参数),就能回到当前删除的原页面
            window.location.reload();
        }else{
            $.messager.popup(data.msg)
        }
    }
    $(function () {
        //修改确认框的文本
        $.messager.model = {
            ok:{text:"确认"},
            cancel:{text:"取消"}
        }
        //删除点击事件
        // 此处使用按钮标签的class属性来锁定,而不是标签id
        // 因为如果使用id,就仅仅会锁定第一行数据,数据是循环遍历出来的
        $(".btn_delete").click(function () {
            //获取当前点击的部门id
            var id = $(this).data('id');
            //提示确认框
            $.messager.confirm("警告","是否确认删除?",function () {
                //点击确认后会执行的函数
                //发送ajax异步请求
                $.get('/employee/delete.do',{id:id},handlerMessage)
            })
        })
    })
</script>

隐藏编辑的密码输入框

因为添加和编辑是用同一个 jsp 页面,当添加的时候需要使用 密码输入,但员工信息编辑不应该有密码输入框

就要进行隐藏

在密码输入框前加上判断语句,判断是否有 员工对象

<c:if test="${empty employee}">    </c:if>

隐藏了密码输入框之后,需要修改 mapper 文件的 SQL语句

因为隐藏了输入框,传入员工对象的密码为 null ,不应该插入表中,直接去掉

注意,不能注释掉,XML中不能有注释,如果有注释,会报以下错

说是说你参数多了。搞得我找了半天
Could not set parameters for mapping: ParameterMapping{property=‘id’, mode=IN, javaType=class java.lang.Object, jdbcType=null, numericScale=null, resultMapId=‘null’, jdbcTypeName=‘null’, expression=‘null’}. Cause: org.apache.ibatis.type.TypeException: Error setting non null for parameter #1 with JdbcType null . Try setting a different JdbcType for this parameter or a different configuration property. Cause: org.apache.ibatis.type.TypeException: Error setting non null for parameter #1 with JdbcType null . Try setting a different JdbcType for this parameter or a different configuration property. Cause: java.sql.SQLException: Parameter index out of range (7 > number of parameters, which is 6).]
<update id="updateByPrimaryKey" parameterType="com.domain.Employee">
  update employee
  set name = #{name,jdbcType=VARCHAR},
    email = #{email,jdbcType=VARCHAR},
    age = #{age,jdbcType=INTEGER},
    admin = #{admin,jdbcType=BIT},
    dept_id = #{dept.id,jdbcType=BIGINT}
  where id = #{id,jdbcType=BIGINT}
</update>

其他

此时保存按钮仅仅是一个普通的按钮,没有提交功能,给其绑定提交功能

<button id="submitBtn" type="button" class="btn btn-primary">保存</button>
<script>
    $("#submitBtn").click(function () {
        //提交表单
        $("#editForm").submit();
    })
</script>

部门下拉框的回显,有两种方式

第一种:直接使用selected表示被选中
<select class="form-control" id="dept" name="deptId">
    <option value="-1">全部</option>
    <c:forEach items="${depts}" var="d">
        <option value="${d.id}" 
                <c:if test="${d.id == qo.deptId}">selected</c:if>>
    		${d.name}
    	</option>
    </c:forEach>
</select>
第二种:更改标签的value值,表示被选中
<select class="form-control" id="dept" name="dept.id">
    <c:forEach items="${depts}" var="d">
        <option value="${d.id}">${d.name}</option>
    </c:forEach>
</select>
<script>
    $("#dept").val(${entity.dept.id})
</script>

3、角色管理

逆向工程生成角色表数据

修改 xml 文件的此部分,双击 mybatis-generator 即可生成对应数据

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BEw2e9tO-1649928393047)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313184455158.png)]

拷贝部门业务类、控制器

替换关键字 role

(img-TgazsIMb-1649928393049)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313184814336.png)]

员工编辑页面展示并选择角色

角色多选框列表显示

修改员工的后端控制器,角色多选框列表显示

@Controller
@RequestMapping("/employee")
public class EmployeeController {
    
    ......
        
    @Autowired
    private RoleService roleService;
    
    @RequestMapping("/input")
    public String input(Long id,Model model){
        //部门下拉框数据,查询所有部门存入域中,让页面获取到
        model.addAttribute("depts", departmentService.listAll());
        //查询所有角色存入域中,让页面获取到
        model.addAttribute("roles", roleService.listAll());

        if (id != null) {  //是编辑,需要查询被修改的员工,回显在页面上
            model.addAttribute("entity",service.get(id));
        }
        // 跳转页面 /WEB-INF/views/employee/input.jsp
        return "employee/input";
    }
    
    ......
}

角色左移右移效果

给右移按钮绑定点击事件,事件中获取左边选中的数据,然后点击就把数据移动到右边

<script>
    function moveSelected(src, target) {
        console.log(src)
    	//获取指定框中已经被选中的数据       $("."+src+" option:selected")
    	
        $("."+target).append($("."+src+" option:selected"));
    }
    function moveAll(src, target) {
        $("."+target).append($("."+src+" option"));
    }
</script>

注意 :对于这种多选下拉框,会有一个 bug

当右移了多个选项之后,鼠标又点击了一个选项

会导致仅仅提交这一个被点击选中的选项

即下拉框中选中的数据的才会提交,若没有选中的数据是不会提交的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xPELC3PN-1649928393050)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313192004209.png)]

解决办法:

当点击提交的时候,让右边框中的所有选项默认是被选中的

修改按钮为普通按钮,绑定点击事件处理函数,在函数中把右边的select 元素中的 option 设置为选中的,再提交表单

<script>
    $("#submitBtn").click(function () {
        //把右边的下拉框的option全部选择
        $(".selfRoles option").prop("selected",true);
        //提交表单
        $("#editForm").submit();
    })
</script>

选择超管后隐藏角色区域

选择超管后隐藏角色区域

var roleDiv;
$("#admin").click(function () {
    //判断是否是勾选状态
    var checked = $(this).prop('checked');
    if(checked){
        //删除角色的div
        roleDiv = $("#role").detach(); 
    }else{
        //恢复角色div,加到超管的后面
        $("#adminDiv").after(roleDiv);
    }
})

编辑时若员工是超管也需要隐藏角色区域

var checked = $("#admin").prop('checked');
if(checked){
    //删除角色的div
    roleDiv = $("#role").detach(); 
}

保存员工时处理角色

角色与员工的关系,在数据库有单独一个表表示 employee_role

不管是修改还是添加,当点击保存时,会执行后端控制器的saveOrUpdate方法,方法定义形参,来接收选中的 角色id 数据

当点击保存按钮时,可以在浏览器看到提交的请求参数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EXp8vCWz-1649928393052)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220313214901899.png)]

注意:

img

浏览器发送数据时有两种形式,那就是Form Data和Request Payload,前面的方式,数据是键值对拼接的方式进行发送,也就是我们比较常用的用一个对象接收或者加上@RequestParam逐个接收,而后面的方式数据组织成json格式来发送。当以Request Payload方式发送时,这样就解析不了了,我们可以通过在Controller的响应方法的参数列表中对应接收的对象参数前加上@RequestBody即可

如果参数时放在请求体中,application/json传入后台的话,那么后台要用@RequestBody才能接收到;
如果不是放在请求体中的话,那么后台接收前台传过来的参数时,要用@RequestParam来接收,或者形参前什么也不写也能接收

控制器方法 如下:参数名与前端输入框的 name 属性一致

@RequestMapping("/saveOrUpdate")
public String saveOrUpdate(Employee employee, @RequestParam Long[] ids){
 if (employee.getId() != null) {  //是编辑
     service.update(employee,ids);
 }else{   //是添加
     service.save(employee,ids);
 }
 return "redirect:/employee/list.do";  //让浏览器重新发一次请求
}

所以需要更改 后端控制器、业务类接口、dao接口、SQL语句(处理中间表)

<insert id="insertRelation">
  INSERT INTO `employee_role` (`employee_id`,`role_id`) 
  VALUE(#{employeeId}, #{roleId})
</insert>

但是现在有一个问题,当添加新的员工时,是没有 id 的,只有添加之后才会生成 id ,如此就无法将角色数据根据员工 id 来保存,也无法插入 employee_role

所以 业务实现类 方法应该是

@Override
public void save(Employee employee, Long[] roleIds) {
    //保存之后employee就会有id
    mapper.insert(employee);
    //然后根据id插入
    if (roleIds != null && roleIds.length > 0) {
        for (Long roleId : roleIds) {
            mapper.insertRelation(employee.getId(),roleId);
        }
    }
}

为什么保存之后employee就会有id

在 mapper 映射文件中,使用了 useGeneratedKeys=“true”、 keyProperty=“id” ,该语句的作用是将数据库表中 主键id 放到传入对象指定的 属性里面,使用 keyProperty 指定属性名

<insert id="insert" useGeneratedKeys="true" keyProperty="id" parameterType="com.domain.Employee">
  insert into employee (name, password, email, 
    age, admin, dept_id)
  values (#{name,jdbcType=VARCHAR}, #{password,jdbcType=VARCHAR}, 
      #{email,jdbcType=VARCHAR}, #{age,jdbcType=INTEGER}, 
      #{admin,jdbcType=BIT}, #{dept.id,jdbcType=BIGINT})
</insert>

编辑员工时角色去重

编辑时先删除原本关系再插入新的关系到中间表

@Override
public void update(Employee employee, Long[] roleIds) {
    mapper.updateByPrimaryKey(employee);
    //删除关系
    mapper.deleteRelation(employee.getId());
    //关联关系
    if (roleIds != null && roleIds.length > 0) {
        for (Long roleId : roleIds) {
            mapper.insertRelation(employee.getId(),roleId);
        }
    }
}

角色回显

员工编辑时,需要 回显角色信息,所以需要更改员工实体类:添加一个角色数组作为属性,来保存角色信息

@Data
public class Employee {
    private Long id;
    private String name;
    private String password;
    private String email;
    private Integer age;
    private boolean admin;
    //private Long deptId;
    private Department dept;
    private List<Role> roles = new ArrayList();
}

那么此时,就需要在保存员工数据时将角色数据保存进这个实体类

就像前面 级联 保存部门信息,需要更改 mapper 映射文件里面的结果参数

角色的 mapper 中,必须有 selectByEmpId 方法(就是可以通过ID查询数据的方法),如下:

<select id="selectByEmpId" resultMap="BaseResultMap">
   select r.id,r.name,r.sn from role r 
   JOIN employee_role er ON r.id = er.role_id 
   where er.employee_id = #{eid}
</select>

员工的 mapper 中,如下

column值为表中字段名就是后面select值里面方法的 参数id

<!-- property值为实体类属性名,column值为表中字段名(这里的字段名就是后面select值里面方法的参数id)
     select值为此属性对应dao接口的全限定名称 + 指定方法名
-->
<collection property="roles" column="id"select="com.mapper.RoleMapper.selectByEmpId"/>

如此,就不需要再更改查询的 SQL 语句,因为会根据上面 column 的字段,放入后面 select 的方法中去查询

测试如下

public void test1(){
    System.out.println(mapper.selectByPrimaryKey((long) 8));
}

查询结果:
    Employee(id=8, name=李总, password=1, email=liz@wolfcode.cn, age=35, admin=false,
             dept=Department(id=4, name=仓储部, sn=Warehousing Department), 
             roles=[Role(id=3, name=仓储管理, sn=WAREHOUSING_MGR), 
                    Role(id=4, name=行政部管理, sn=Admin_MGR), 
                    Role(id=12, name=市场专员, sn=Market)])

页面循环遍历获取

<select multiple class="form-control selfRoles"  name="ids">
    <c:forEach items="${employee.roles}" var="r" >
    <option value="${r.id}">${r.name}</option>
    </c:forEach>
</select>

回显的角色去重

注意:角色回显的时候,发现左右两边的角色有重复,应该是有右边有的角色,不应该在左边出现。

解决:页面加载完,拿左边两边的option 对比,遍历左边每个角色,若已经在右边列表内,则需要删除。

//1.把已有的角色id放入一个数组中(右边)
var ids = [];
$(".selfRoles > option").each(function (i, ele) {
    ids.push( $(ele).val());
})
//2.遍历所有的角色(左边)
$(".allRoles > option").each(function (i, ele) {
    //3.判断是否存在ids数组中,如果是就删除掉自己
    var id = $(ele).val();
    if($.inArray(id,ids)!=-1){
        $(ele).remove();
    }
})

员工删除

删除员工数据,还要从中间表employee_role 中删除与此员工相关的数据

业务实现类 方法如下

@Override
public void delete(Long id) {
    mapper.deleteRelation(id);
    mapper.deleteByPrimaryKey(id);
}

4、权限管理

在角色页面,需要用到权限管理

权限表要包含什么字段?

权限名称是给分配权限的人看的,用中文,见名知意。

权限表达式要唯一这样才能区分用户 访问的到底是什么资源。

逆向工程生成权限表数据

修改 xml 文件的此部分,双击 mybatis-generator 即可生成对应数据

@Data
public class Permission {
    private Long id;
    //权限名称(给分配权限的人看的)
    private String name;
    //权限表达式(给程序判断的时候用的)
    private String expression;
}

拷贝部门业务类、控制器

替换关键字 role

角色编辑页面展示并选择权限

添加角色时展示权限

@RequestMapping("/input")
public String input(Long id,Model model){
    //查询所有权限存入域中,让页面获取到
    model.addAttribute("permissions", permissionService.listAll());
    if (id != null) {  //是编辑,需要查询被修改的部门,回显在页面上
        model.addAttribute("entity",service.get(id));
    }
    // 跳转页面 /WEB-INF/views/role/input.jsp
    return "role/input";
}

编辑角色时回显权限

更改实体类(为了查询的时候能拿到权限数据)

@Data
public class Role {
    private Long id;
    private String name;
    private String sn;
    private List<Permission> permissions = new ArrayList();
}

更改 权限 的 mapper 文件,增加方法 void selectByRoleId(Long rid);

<select id="selectByRoleId" resultMap="BaseResultMap">
   select p.id,p.name,p.expression from `permission` p
   JOIN `role_permission` rp ON p.id = rp.permission_id
   where rp.role_id = #{rid}
</select>

更改 角色的 mapper 文件,更改查询的结果集,让其能够查询对应的权限列表,并存入 实体类

<resultMap id="BaseResultMap" type="com.domain.Role">
  <id column="id" jdbcType="BIGINT" property="id" />
  <result column="name" jdbcType="VARCHAR" property="name" />
  <result column="sn" jdbcType="VARCHAR" property="sn" />
<!-- 
	property值为实体类属性名,column值为表中字段名(这里的字段名就是后面select值里面方法的参数id)
    select值为此属性对应dao接口的全限定名称 + 指定方法名
-->
  <association property="permissions" column="id" 
  								select="com.mapper.PermissionMapper.selectByRoleId"/>
</resultMap>

保存角色时处理权限

保存之后关联角色与权限的关系,更新第三张表

当点击保存的时候参数名是 ids

<insert id="insertRelation">
  insert into role_permission (role_id,permission_id) VALUES (#{rid},#{pid})
</insert>
      
<delete id="deleteRelation">
  delete from role_permission where role_id = #{rid} 
</delete>

注意接口中使用 @Param 注解指定参数名称

Role selectByEmpId(@Param("eid")Long eid);
void insertRelation(@Param("rid")Long rid, @Param("pid")Long pid);
void deleteRelation(@Param("rid")Long rid);

更改 业务实现类 方法,保存和编辑

@Override
public void save(Role role, Long[] roleIds) {
    //保存之后就会有id
    mapper.insert(role);
    //然后根据id插入
    if (roleIds != null && roleIds.length > 0) {
        for (Long roleId : roleIds) {
            mapper.insertRelation(role.getId(),roleId);
        }
    }
}

@Override
public void update(Role role, Long[] roleIds) {
    mapper.updateByPrimaryKey(role);
    //删除关系
    mapper.deleteRelation(role.getId());
    //关联关系
    if (roleIds != null && roleIds.length > 0) {
        for (Long roleId : roleIds) {
            mapper.insertRelation(role.getId(),roleId);
        }
    }
}

更改后端控制器

@RequestMapping("/saveOrUpdate")
public String saveOrUpdate(Role role, @RequestParam Long[] ids){
    if (role.getId() != null) {  //是编辑
        service.update(role,ids);
    }else{   //是添加
        service.save(role,ids);
    }
    return "redirect:/role/list.do";  //让浏览器重新发一次请求
}

删除角色时处理权限

删除角色数据,还要从中间表中删除关联的关系

@Override
public void delete(Long id) {
    //删除关系
    mapper.deleteRelation(id);
    mapper.deleteByPrimaryKey(id);
}

权限页面的重新加载按钮

每个权限其实是对应着每个后端控制器方法,权限页面没有添加编辑的操作,是因为后端控制器方法较多,最好是后台自动生成权限列表,就要给 重新加载按钮 绑定这个操作

控制器中每个处理方法怎么转化成权限表中的数据?

一条条手动添加太麻烦,需要用代码来批量添加

我们可以自定义一个注解 , 在处理方法上贴该注解,注解的值使用中文名称即可,表明是什么样的权限 , 再利用贴了注解的方法 , 生成唯一的权限表达式

权限表达式的值需要具有唯一性, 那么我们就约定权限表达式组成:控制器类名首字母小写除去Controller:方法名(department:list),这样就可以唯一了

贴注解了除了上面的好处,还可以区分一个处理方法是否要做权限限制,贴了代表要限制,反之不要

(比如有 @ResponseBody 注解,就说明要转为 JSON 格式,没有此注解就不用)

(比如并不需要控制用户登录、用户注销等权限,因为每个用户都应该拥有这个权限,不需要管理)

自定义权限注解

@Target(ElementType.METHOD)   //注解需要放在方法上
@Retention(RetentionPolicy.RUNTIME)   //运行时也可以使用的注解
public @interface RequiredPermission {
    String name();
    String expression();
}

在控制器方法上贴权限注解

@RequiredPermission(name = "权限删除",expression = "permission:delete")
@RequestMapping("/delete")
@ResponseBody
public JsonResult delete(Long id){
    try {
        if (id != null){  //传id值就删除对应的部门数据
            service.delete(id);
        }
        return new JsonResult();
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}

为权限页面的 重新加载按钮 绑定点击事件,点击按钮后发送ajax到后台

//点击重新加载的提示
$(function () {
    $(".btn_reload").click(function () {
        $.messager.confirm("警告","是否重新加载?",function () {
            //点击确认后会执行的函数
            $.get('/permission/load.do',handlerMessage)
        })
    })
})

编写controller处理方法

@RequestMapping("/load")
@ResponseBody
public JsonResult load(){
    try {
        service.load();
        return new JsonResult();
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}

编写 业务实现类 的方法

**需求:**将每个控制器中添加了注解的方法转化为权限数据,并插入数据库

实现步骤:

  1. 首先获取容器对象,从容器获取所有贴有 @Controller 注解的 bean
  2. 再获取里面贴有自己自定义的注解的方法
  3. 获取权限名称和权限表达式(可以手动拼接,亦可使用注解定义获取)
  4. 判断该方法是否贴有我们自定义注解,还要判断这个方法拼接权限表达式是否存放在数据库中,
  5. 若贴有注解且该权限表达式不存在数据库, 则创建 Permission对象,封装数据并存入数据库中
@Autowired
private ApplicationContext ctx;  //spring容器对象
@Override
public void load() {
    //需求:将每个控制器中添加了注解的方法转化为权限数据,并插入数据库
    // 定义一个方法,可以获取到数据库中所有的权限表达式(方便后面去重)
    List<String> permissions = mapper.selectAllExpression();

    // 1、获取所有控制器(根据注解查询更方便)利用spring容器对象,获取带有controller注解的所有bean
    // Map<String,Object>map里面的key为bean标签的id,就是首字母小写的类名;value就是容器中的对象
    Map<String, Object> map = ctx.getBeansWithAnnotation(Controller.class);
    Collection<Object> beans = map.values();

    // 2、遍历获取每个控制器中的方法
    for (Object controller : beans) {
        Class<?> clazz = controller.getClass();        //控制器的字节码对象
        Method[] methods = clazz.getDeclaredMethods();   //通过反射拿到里面所有的方法
        // 3、遍历获取到的每个方法
        for (Method method : methods) {
            // 4、判断方法上是否贴有自定义的权限注解
            RequiredPermission annotation = 
                					method.getAnnotation(RequiredPermission.class);
            //判断注解是否为空
            if(annotation != null){
                // 5、从注解中获取权限相关数据,并封装为权限对象
                String name = annotation.name();
                // 方式一:从注解中获取权限表达式
                String expression = annotation.expression();
                // 方式二:通过反射拼接 获取权限表达式
                //    获取类名DepartmentController,去掉Controller,得到 Department
                //String simpleName = clazz.getSimpleName().replace("Controller","");
                //     uncapitalize方法可以把首字母变为小写,得到 department +":"+method.getName()
                // String expression = StringUtils.uncapitalize(simpleName)+":"+method.getName();

                // 6、判断数据库是否已存在,若无就插入
              //在前面级联关联关系时,为了不出现重复,是将所有关系都删除再重新插入,没有判断是否存在
                //而这里没有进行此种操作
                //是因为如果重新插入,就会导致 id列发生变化,就会导致permission_id表数据错误
                if(!permissions.contains(expression)) { 
                //permissions、expression 类型要保持一致
                    
                    //封装成权限对象
                    Permission permission = new Permission();
                    permission.setName(name);
                    permission.setExpression(expression);
                    //把权限对象保存到数据库
                    mapper.insert(permission);
                }
            }
        }
    }
}

mapper 中新增方法

<select id="selectAllExpression" resultType="java.lang.String">
  select expression from permission
</select>

5、用户登录

一般用户登录使用静态html页面,用户体验更好一些,不用等待

所以使用ajax方式的提交登录

用户体验更好,保留用户刚输入的用户名和密码,失败之后不需要跳转页面可马上提示错误信息

页面获取用户输入参数

先将登录按钮改为普通按钮

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ajUipabC-1649928393055)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220314171744900.png)]

<script>
    $(function () {
        $(".submitBtn").click(function () {
            //获取用户输入的参数
            //var username = $("input[name=username]").val()

            // 表单序列化方法,key1=value1&key2=value2 的方式返回,输入框的name属性值作为key
            //serialize方法可以把表单的所有参数都获取出来,使用&拼接
            $.post('/login.do',$("#loginForm").serialize(),function (data) {
                if(data.success){
                    window.location.href = "/employee/list.do";
                }else{
                    alert(data.msg)
                }
            })
        })
    })
</script>

如果想要实现按 回车 就能登录,需要添加 键盘监听事件

后台接收参数并处理

因为登录需要显示用户信息,以及拦截时候需要用到,所以要将登录存入 session 域中

后台处理登录逻辑

@RequestMapping("/login")
@ResponseBody
public JsonResult login(String username,String password, HttpSession session){
    try {
        Employee employee = service.login(username, password);
        //把员工放到session中
        session.setAttribute("EMP_IN_SESSION",employee);
        return new JsonResult();
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}

业务层

@Override
public Employee login(String username, String password) {
    //判空
    //if (username == null || username.length() <= 0)
    if (!StringUtils.hasText(username)){  //org.springframework.util.StringUtils
        throw new RuntimeException("用户名不可为空");
    }
    if (!StringUtils.hasText(password)){
        throw new RuntimeException("密码不可为空");
    }
    //查询数据库中是否有匹配的数据
    Employee employee = mapper.selectByUsernameAndPassword(username, password);
    if(employee==null){
        //通知调用者出现异常
        throw new RuntimeException("账号和密码不匹配");
    }
    return employee;
}

持久层

void selectByUsernameAndPassword(@Param("username")String username, 
                                 @Param("password")String password);
<select id="selectByUsernameAndPassword">
  select id, name, password, email, age, admin, dept_id
  from employee
  where name = #{username} and password = #{password}
</select>

登陆成功后

登录后页面右上角显示用户名

<span class="hidden-xs">${EMP_IN_SESSION.name}</span>

用户登录拦截器

自定义拦截器类实现 HandlerInterceptor 接口

public class LoginInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
        //获取当前登录的用户
        HttpSession session = req.getSession();
        Employee employee = (Employee) session.getAttribute("EMP_IN_SESSION");
        if (employee == null) {   //没有登录,不放行
            resp.sendRedirect("/login.html");   //回到登录界面
            return false;
        }
        return true;   //已经登录,放行
    }
}

把拦截器配置到 mvc.xml 文件中,使其生效

注意,请求是先经过拦截器,再到达中央调度器

<!-- 注册拦截器 -->
<mvc:interceptors>
    <!-- 配置登录拦截器 -->
    <mvc:interceptor>
        <!-- 对哪些资源起拦截作用 -->
        <mvc:mapping path="/**"/>
        <!-- 对哪些资源不起拦截作用 -->
        <mvc:exclude-mapping path="/login.do"/>
        <!-- 哪个Bean是拦截器 -->
        <bean class="com.interceptor.LoginInterceptor"/>
    </mvc:interceptor>
</mvc:interceptors>

用户注销

<a href="/logout.do">
    <i class="fa fa-power-off"></i>
    注销
</a>
@RequestMapping("/logout")
public String logout(HttpSession session){
    //销毁会话
    session.invalidate();
    return "redirect:/login.html";
}

6、权限控制

权限拦截流程

  1. 得到当前用户:判断是否是超级管理员

    因为本例中,超级管理员没有选择任何角色、权限

  2. 得到当前请求目标方法:判断是否需要权限

  3. 得到权限表达式

  4. 得到当前用户权限列表

  5. 跳转到错误页面

拦截器作用:

  1. 理解用户请求
  2. 预先处理请求,决定是否执行 Controller
  3. 拦截器是在请求之前先执行的
  4. 如果中央调度器设置了 *.do 等路径限制,拦截器是在此筛选之后起作用

如果访问的页面会执行controller,拦截器里面的 Object handler 会变成 HandlerMethod 类型,并且代表执行的那个控制器的具体某个方法

如果访问的是静态页面,拦截器里面的 Object handler 会变成 DefaultServletHttpRequestHandler 类型

SpringMVC应用启动时会搜集并分析每个Web控制器中的每个处理方法,并通过HandlerMethod来包装和表示。

HandlerMethod封装了很多属性,可以方便的访问到请求方法、方法参数、方法上的注解、所属类等信息,并且对方法参数封装处理,也可以方便的访问到方法参数的注解等信息。

mapper 新增方法

<select id="selectExpByEmpId" resultType="java.lang.String">
  select p.expression from permission p
  inner join role_permission rp on p.id = rp.permission_id
  inner join employee_role er on rp.role_id = er.role_id
  where er.employee_id = #{eid}
</select>
List<String> selectExpByEmpId(@Param("eid")Long eid);

自定义拦截器类实现 HandlerInterceptor 接口

注意:还有一个问题,对于部门删除等操作,页面是 ajax 发送请求,提示是否要删除,会有点击之后回调函数

假如现在没有删除的权限,那么就不会执行controller,就不会传递 JSON 数据,回调函数的弹窗就为空

那么拦截器最后需要判断一下,要么跳转到无权限的提示页面,要么就是这种情况给一个无权限的提示 JSON 数据

点击删除之后,如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i3e13GOy-1649928393056)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220315121513798.png)]

public class PermissionInterceptor implements HandlerInterceptor {
    @Autowired
    private PermissionService service;
    //Object handler:被拦截的控制器对象
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1、获取当前登录用户(因为已经有登录拦截器,这里一定能获取到用户,就不需要判空了)
        Employee employee = (Employee) request.getSession().getAttribute("EMP_IN_SESSION");
        // 2、判断是否是超级管理员
        if(employee.isAdmin()){  //因为admin属性是基本数据类型boolean,如果是包装类Boolean,就报错
            //是 -> 放行
            return true;
        }
            //不是 -> 下一步
        // 3、获取当前要执行的控制器的处理方法(被拦截的方法)
        //if(handler instanceof HandlerMethod) 如果中央调度器的路径是 / ,包含了静态资源,就要判断一下
        if (!(handler instanceof HandlerMethod)){
            //说明访问的是静态资源
            return true;
        }
        HandlerMethod method = (HandlerMethod)handler;
        // 4、判断是否贴了权限注解 @RequiredPermission
        RequiredPermission annotation = method.getMethodAnnotation(RequiredPermission.class);
        if(annotation==null){
            //不是 -> 放行
            return true;
        }
            //是 -> 下一步
        // 5、获取方法的权限表达式
        //方式一:通过权限注解获取权限表达式
        String expression = annotation.expression();
        /*
          方式二:通过反射来获取权限表达式
            String simpleName = handlerMethod.getBean().getClass().getSimpleName().replace("Controller","");
            String expression = StringUtils.uncapitalize(simpleName)+":"+handlerMethod.getMethod().getName();
         */
        // 6、获取当前登录用户拥有的权限表达式集合 List<String>
        List<String> permissions = service.selectByEmpId(employee.getId());
        // 7、判断户拥有的权限表达式集合是否包含该方法的权限表达式
        if(permissions.contains(expression)){ //permissions、expression 类型要保持一致
            return true;  //放行
        }
        // 8、判断是否是Ajax请求的地方
        if (!(method.hasMethodAnnotation(ResponseBody.class))){ //判断是否有ResponseBody注解
            //跳转到没权限的提示页面
            request.getRequestDispatcher("/WEB-INF/views/common/nopermission.jsp").forward(request,response);
        }else {
            //响应JSON数据
            JsonResult jsonResult = new JsonResult(false, "您没有权限操作");
            //编码
            response.setContentType("application/json;charset=utf-8");
            //使用工具类将 对象 转为JSON数据
            response.getWriter().print(JSON.toJSONString(jsonResult));
        }
        return false;  //不放行
    }
}

把拦截器配置到 mvc.xml 文件中,使其生效

<!-- 注册拦截器 -->
<mvc:interceptors>
    <mvc:interceptor>
        <mvc:mapping path="/**"/>  <!-- 要拦截的路径 -->
        <mvc:exclude-mapping path="/login.do"/>  <!-- 要排除拦截的路径 -->
        <bean class="com.interceptor.LoginInterceptor"/>   <!--登录拦截器-->
    </mvc:interceptor>
    <mvc:interceptor>
        <mvc:mapping path="/**"/>  <!-- 要拦截的路径 -->
        <mvc:exclude-mapping path="/login.do"/>  <!-- 要排除拦截的路径 -->
        <bean class="com.interceptor.PermissionInterceptor"/>   <!--权限拦截器-->
    </mvc:interceptor>
</mvc:interceptors>

7、修改密码、重置密码

修改密码

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E76HGgoD-1649928393057)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220315162608234.png)]

引入插件

<script type="text/javascript" src="/js/plugquins/jery-form/jquery.form.js"></script>

添加修改密码的链接

<a href="/employee/update.do">
    <i class=" fa fa-user"></i>
    修改密码
</a>
@RequestMapping("/update")
public String update(){
    // 跳转页面 /WEB-INF/views/employee/list.jsp
    return "employee/updatePwd";
}

mapper 添加代码

<update id="updatePasswordById">
  update employee set password = #{password} where id = #{id}
</update>

后端控制器

//修改密码操作
@RequestMapping("/updatePwd")
@ResponseBody
public JsonResult updatePwd(String oldPassword, String newPassword){
    //获取当前登录用户信息
    Employee currentUser = UserContext.getCurrentUser();
    //判空
    if (!StringUtils.hasText(oldPassword) || !StringUtils.hasText(newPassword)){
        return new JsonResult(false, "未输入数据");
    }
    //判断原密码是否正确
    if (!oldPassword.equals(currentUser.getPassword())){
        return new JsonResult(false, "原密码错误");
    }
    //更新新密码
    currentUser.setPassword(newPassword);
    service.updatePassword(currentUser);
    return new JsonResult();
}

前端提交表格

<script>
    $(function () {
        //获取用户输入的参数
        //var oldPassword = $("input[name=oldPassword]").val()
        
        $("#submitBtn").click(function () {
            //serialize方法可以把表单的所有参数都获取出来,使用&拼接
            $.post('/employee/updatePwd.do',$("#editForm").serialize(),function (data) {
                if(data.success){
                    window.location.href = "/logout.do";
                }else{
                    $.messager.popup(data.msg)
                }
            })
        })
    })
</script>

重置密码

员工页面添加重置密码按钮,但是只有超级管理员才可见

8、优化

工具抽取 UserContext

每次判断权限都要查询数据库 , 效率太低

权限数据不会经常变动 , 不用每次去数据库查询权限数据, 可以在登录时就把用户的权限存入session中

@RequestMapping("/login")
@ResponseBody
public JsonResult login(String username,String password, HttpSession session){
    try {
        Employee employee = service.login(username, password);
        //把员工放到session中
        session.setAttribute("EMP_IN_SESSION",employee);

        //判断是否是超级管理员,如果不是就查询
        if(! employee.isAdmin()){
            //根据登录用户id查询员工拥有的权限表达式列表,存到session中
            List<String> expressions = 
                			permissionService.selectByEmpId(employee.getId());
            session.setAttribute("USER_EXPRESSIONS_IN_SESSION",expressions);
        }
        return new JsonResult();
    } catch (Exception e) {
        e.printStackTrace();
        return new JsonResult(false,"操作失败");
    }
}
// 6、获取当前登录用户拥有的权限表达式集合 List<String>
List<String> permissions  = 
    (List<String>) request.getSession().getAttribute("USER_EXPRESSIONS_IN_SESSION");

抽取UserContext工具 , 方便获取数据

可以在代码任意的地方获取请求对象,或者 HttpSessison 对象,也可以避免操作 session 时,key 的名称过长易导致写错

springmvc 提供 RequestContextHolder 工具类,里面有静态方法可以获取一些上下文对象(请求、响应、session等)。实现在任何地方都可以拿到 request、response

public class UserContext {
    public static final String EMP_IN_SESSION = "EMP_IN_SESSION";
    public static final String USER_EXPRESSIONS_IN_SESSION = "USER_EXPRESSIONS_IN_SESSION";

    //往session存入登录用户
    public static void setCurrentUser(Employee emp) {
        getSession().setAttribute(EMP_IN_SESSION, emp);
    }
    //从session获取当前登录用户
    public static Employee getCurrentUser() {
        return (Employee) getSession().getAttribute(EMP_IN_SESSION);
    }

    //获取session对象
    public static HttpSession getSession() {
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attrs.getRequest().getSession();
    }

    //往session存入登录用户的权限信息
    public static void setExpression(List<String> permissions) {
        getSession().setAttribute(USER_EXPRESSIONS_IN_SESSION,permissions);
    }
    //从session获取登录用户的权限信息
    public static List<String> getExpression() {
        return (List<String>) getSession().getAttribute(USER_EXPRESSIONS_IN_SESSION);
    }
}

使用示例:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ljjAdgGF-1649928393058)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220315141634315.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S98iV62j-1649928393059)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220315141647327.png)]

PageHelper分页插件

PageHelper 是一个 MyBatis 的分页插件, 负责将已经写好的 SQL 语句, 进行分页加工。

优点:无需你自己去封装以及关心 SQL 分页等问题,使用很方便。

https://pagehelper.github.io/

  • 添加依赖

    <dependency>
        <groupId>com.github.pagehelper</groupId>
        <artifactId>pagehelper</artifactId>
        <version>5.1.2</version>
    </dependency>
    
  • 注册分页拦截器 mybatis-config.xml

    <plugins>
        <!-- com.github.pagehelper 为 PageHelper 类所在包名 -->
        <plugin interceptor="com.github.pagehelper.PageInterceptor">
            <!-- 当 pageNum(当前页) <= 0 时,将 pageNum 设置为 1 -->
            <!-- 当 pageNum > pages(总页数) 时,将 pageNum 设置为 pages最后一页 -->
            <property name="reasonable" value="true"/>
        </plugin>
    </plugins>
    
  • 在 spring 配置文件关联 mybatis 主配置文件

    <!-- 配置 SqlSessionFactory 对象 -->
    <bean class="org.mybatis.spring.SqlSessionFactoryBean">
       <!-- 注入数据源 -->
       <property name="dataSource" ref="dataSource" />
       <!-- 关联MyBatis主配置文件,使用 MyBatis 分页插件时需要进行配置 -->
       <property name="configLocation" value="classpath:mybatis-config.xml" />
       <!-- 关联mapper映射文件 -->
       <property name="mapperLocations" value="classpath:mapper/*Mapper.xml" />
       <!-- 配置别名 -->
       <property name="typeAliasesPackage" value="com.domain" />
    </bean>
    
  • 修改 Service 中分页查询

    删除Mapper.xml文件中查询数量 selectForCournt 的所有代码

    删除Mapper.xml文件的分页查询selectLimitList的SQL中的 Limit

    删除 QueryObject 中也不需要提供start方法了

    删除 PageResult 类,注意修改引用此类属性的位置,因为现在是用 PageInfo 类,属性名变化

    注意: 调用了 PageHelper.startPage() 开始分页后,会对接下来的一行SQL进行分页

    (如果此句后面出现多行SQL,会对第一行分页)

    public PageInfo<Department> query(QueryObject qo) {
        //使用分页插件,传入当前页,每页显示数量
        PageHelper.startPage(qo.getCurrentPage(), qo.getPageSize());
        List<Department> departments = departmentMapper.selectForList(qo);
        return new PageInfo(departments);
    }
    
  • 修改页面获取分页信息的 属性取值

    因为现在我们使用 com.github.pagehelper.PageInfo 类来封装分页数据,里面提供的属性名跟之前我们自己写的不一样。

    $(function(){
        $("#pagination").twbsPagination({
            totalPages: ${pageInfo.pages}, //总页数
            startPage: ${pageInfo.pageNum}, //当前页
            visiblePages: 5,
            first: "首页",
            prev: "上页",
            next: "下页",
            last: "尾页",
            initiateStartPageClick: false,
            onPageClick: function(event,page){
                $("#currentPage").val(page);
                $("#searchForm").submit();
            }
        });
    });
    
  • 使用 Ctrl+H 快捷键,实现大范围的搜索、替换

jquery-form 表单异步提交插件

jQuery Form 能够让你简洁的将以 HTML 形式提交的表单升级成采用 AJAX 技术提交的表单。

插件里面主要的方法 ajaxForm 和 ajaxSubmit, 能够从 form 组件里采集信息确定如何处理表单的提交过程。

两个方法都支持众多的可选参数,能够让你对表单里数据的提交做到完全的控制。

这让采用 AJAX 方式提交一个表单的过程简单化很多了!

  • 引入插件
<script src="/js/plugins/jquery-form/jquery.form.js"></script>
  • 使用插件

    使用时最好根据按钮的类型来选择使用的方式

    • 方式一:使用 ajaxSubmit 方法

      一般用于按钮是button类型的时候比较方便

      <button type="button" class="btn btn-primary btn-submit">保存</button>
      

      页面加载完毕后,为按钮绑定点击事件,在事件处理函数中使用 ajaxSubmit直接可以提交异步表单

      表单里面必须有 action 提交地址、method 提交方式

      $(function(){
          $('.btn-submit').click(function () {
              // ajaxSubmit方法可以把表单转为异步的表单,并且马上执行提交
              // 页面加载完之后就会执行
              $('#editForm').ajaxSubmit(function (data) {  //回调函数
                  console.log(data);
              })
          })  
      })
      
    • 方式二:使用 ajaxForm 方法

      一般用于按钮是submit类型的时候比较方便,但是注意该按钮必须位于form表单内部

      <button type="submit" class="btn btn-primary btn-submit">保存</button>
      

      无须绑定按钮事件,只需要在页面加载完毕后,利用ajaxForm方法把表单转为异步的表单,当点击按钮时就可以提交一个异步的表单了,因为按钮是submit类型的,所以是点击后才提交的

      $(function(){
          //ajaxForm方法可以把表单转为异步的表单,但不会马上提交,点击后才提交
          $('#editForm').ajaxForm(function (data) { //回调函数
             if(data.success){
      			window.location.href = "/department/list.do";
      		}else{
                  $.messager.popup(data.msg)
      		}
          })
      })   
      

      后台 saveOrUpdate 方法改为返回 JsonResult

      @RequestMapping("/saveOrUpdate")
      @ResponseBody
      public JsonResult saveOrUpdate(Department department){
          try {
              if(department.getId() == null){ // 代表新增
                  departmentService.save(department);
              }else { 
                  departmentService.update(department);// 代表修改
              }
              return new JsonResult();
          } catch (Exception e) {
              e.printStackTrace();
              return new JsonResult(false , "操作失败");
          }
      }
      

CRM

项目准备:修改以下两个文件中的数据库名

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GyhY62pE-1649928393060)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220316103754813.png)]

1、项目准备

Freemarker

中文手册: http://freemarker.foofun.cn/

入门程序

  • 添加依赖
 <!-- freemarker -->
<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>2.3.23</version>
</dependency>
  • 准备模板

在 templates 目录下,建立 hello.ftl 模板文件,内容如下:

使用${}插值,freemarker将会输出真实的值来替换花括号内的表达式

hello ${name}
  • 添加测试类
public class App {
    public static void main(String[] args) throws Exception {
        Configuration cfg = new Configuration(Configuration.VERSION_2_3_23);
        // 指定模板文件从何处加载的数据源,这里设置文件目录位置。
        cfg.setDirectoryForTemplateLoading(new File("src/main/java/test/templates"));
        // 提供数据
        Map root = new HashMap();
        root.put("name", "Big Joe");
        // 获取模板文件
        Template temp = cfg.getTemplate("hello.ftl");
        // 设置输出为新的文件
        Writer out = new OutputStreamWriter(new FileOutputStream("src/test.html"));
        // 执行输出
        temp.process(root, out);
        out.flush();
        out.close();
    }
}

会自动生成一个文件 test.html ,内容为:

hello Big Joe

空值处理

FreeMarker 的变量必须赋值,否则就会抛出异常。 使用 ! 指定缺失变量的默认值,variable!

也可以用这种方式来指定默认值 variable!defaultValue,不要求默认值的类型和变量类型相同。

${name!} 只处理空值问题
${name!"abc"} 处理空值问题同时也指定了默认值

代码工具

使用代码工具动态生成 固定内容的 service、controller 等文件

就不需要之前那样复制,然后替换了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y1GEWuIg-1649928393061)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220316111126245.png)]

SpringMVC集成Freemarker

  • 添加依赖
<!-- 将freemarker等第三方库整合进Spring应用上下文的依赖-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context-support</artifactId>
    <version>${spring.version}</version>
</dependency>
  • 在 mvc.xml 配置 FreeMarker 的视图解析器
<!-- 注册 FreeMarker 配置类 -->
<bean class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer"> 
    <!-- 配置 FreeMarker 的文件编码 -->
    <property name="defaultEncoding" value="UTF-8" />
    <!-- 配置 FreeMarker 寻找模板的路径 -->
    <property name="templateLoaderPath" value="/WEB-INF/views/" />
</bean>

<!-- 注册 FreeMarker 视图解析器 -->
<bean class="org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver"> 
    <!-- 是否在 model 自动把 session 中的 attribute 导入进去 -->   
    <property name="exposeSessionAttributes" value="true" /> 
    <!-- 配置逻辑视图自动添加的后缀名 --> 
    <property name="suffix" value=".ftl" />   
    <!-- 配置响应头中 Content-Type 的指 -->  
    <property name="contentType" value="text/html;charset=UTF-8" />
</bean>

改造部门页面

使用freemarker模板

  1. 改文件的后缀名:手动把部门 list.jsp 和 input.jsp 改成 ftl文件

  2. 删除或替换里面的标签和指令:

  3. 用事先准备好的 common目录的模板文件替换掉 之前common 目录中的jsp文件

指令:其实就是指 ftl 的标签,这些标签一般以符号#开头

include指令

在当前模板文件中引入另一个模板文件

因为之前jsp的/表示工程路径,就是webapp的位置,但是 freemarker 没有servlet上下文的概念

<!--freemarker引入模板文件 使用相对路径来引入-->
<#include "../common/link.ftl" >

assign指令

使用该指令可以在当前模板中创建一个新的变量, 或者替换一个已经存在的变量

创建变量 currentMenu并赋值:

<#assign currentMenu="department"/>

替换jsp中的:
<#--<c:set var="currentMenu" value="department"/>-->

可使用${}获取该变量

${currentMenu}

list指令

用于循环遍历序列

<#list result.list as entity>
替换jsp中的:
<#--<c:forEach items="${result.list}" var="entity" varStatus="vs">-->
    
    <tr>
        <td>${entity_index+1}</td> 这个索引是从 0 开始的
        替换jsp中的:
        <#--<td>${vs.count}</td>-->

        <td>${entity.name!}</td>
    </tr>
</#list>               

注释:FreeMarker的注释和 HTML 的注释相似,但是它用 <#-- 和 --> 来分隔的。任何介于这两个分隔符(包含分隔符本身)之间内容会被 FreeMarker 忽略,不会显示到页面,一般用来注释有freemarker指令相关的代码。

<#--  <td>${department.name!}</td> -->

使用Bootstrap模态框

中文网 :https://v3.bootcss.com/javascript/#modals

因为部门保存修改的字段少,很适合使用模态框来改,看着更舒服些,也可以 少写一个input页面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-27crut7E-1649928393061)(F:/02-CRM/02-CRM/05_课件/image/image-20200514113705153.png)]

注意:务必将模态框的 HTML 代码放在文档的最高层级内(即尽量作为 body 标签的直接子元素),以避免其他组件影响模态框的展现和/或功能

同时修改 编辑、添加 的点击事件

  1. 删除原先按钮的链接跳转(现在不需要controller的input方法)

  2. 拷贝官网的 模态框 模板,位置放在 body 标签的直接子元素

    <!-- Modal模态框 -->
    <div class="modal fade" id="inputModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 class="modal-title" id="myModalLabel">Modal title</h4>
                </div>
                <div class="modal-body">
                    ...
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                    <button type="button" class="btn btn-primary">Save changes</button>
                </div>
            </div>
        </div>
    </div>
    
    </body>
    
  3. 可以更换模态框的标题、确认、取消的文本

  4. 给按钮绑定打开模态框的 点击事件

    <script>
        $(function () {
            //添加和编辑模态框
            $(".btn-input").click(function () {
                //打开模态框  .modal("show")
                //隐藏模态框  .modal("hide")
                $("#inputModal").modal("show")
            })
        })
    </script>
    
  5. 在 … 的位置,粘贴进 input.jsp 页面的表单

    注意,因为之前是在 input 方法里面使用 model 传递对象,来回显数据

    现在不使用此方法,自然就没法使用 value="${entity.name}"

    <!-- Modal模态框 -->
    <div class="modal fade" id="inputModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 class="modal-title" id="myModalLabel">新增/编辑部门</h4>
                </div>
                <form class="form-horizontal" action="/department/saveOrUpdate.do" 
                      method="post" id="editForm">
                    <div class="modal-body">
                        <input type="hidden" name="id">
                        <div class="form-group" style="margin-top: 10px;">
                            <label for="name" class="col-sm-3 control-label">
                                名称:</label>
                            <div class="col-sm-6">
                                <input type="text" class="form-control" 
                                       id="name" name="name"
                                       placeholder="请输入部门名">
                            </div>
                        </div>
                        <div class="form-group" style="margin-top: 10px;">
                            <label for="sn" class="col-sm-3 control-label">
                                编码:</label>
                            <div class="col-sm-6">
                                <input type="text" class="form-control" 
                                       id="sn" name="sn"
                                       placeholder="请输入部门编码">
                            </div>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-default" 
                                data-dismiss="modal">取消</button>
                        <button id="submitBtn" type="submit" 
                                class="btn btn-primary">保存</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
    
  6. 表单 新增/编辑部门 实现异步提交

    <script>
        //ajax的回调函数
        var handlerMessage = function (data) {
            if(data.success){ //用alert或者popup都可以
                $.messager.popup('操作成功, 2s后自动刷新')
                setTimeout(function () {
                    window.location.reload();   //重新加载当前页面(携带原本的参数)
                },2000)
            }else{
                $.messager.popup(data.msg)
            }
        }
        $(function () {
            //使用异步方式对新增/编辑部门的表单进行提交
            $("#editForm").ajaxForm(handlerMessage)  //ajaxForm不会马上提交
        })
    </script>
    

编辑回显

需要放在点击事件里面,点击之后才有回显数据

可以获取点击的事件源,然后获取前面兄弟节点的value值,来进行回显。但是最好是在页面显示的数据和数据库的数据一致的前提下使用(如性别,数据库可能是1 / 0)

可以获取点击的事件源,自定义属性,属性值为当前行数据对应的对象(类似之前删除的按钮)。但是最好是能拿到 JSON 格式的数据,这样可以直接 json.id 就可以拿到里面的数据

当用户点击编辑按钮的时候,在模态框中回显要编辑的数据。需要放在点击事件里面,点击之后才有回显数据

我们可以直接从页面上获取被编辑数据,回显到模态框中。

  • 在后端部门实体类中新增提供 json字符串转换 功能的属性

    @Data
    public class Department {
        private Long id;
        private String name;
        private String sn;
        //把部门转为json字符串
        public String getJson(){
            HashMap map = new HashMap();
            map.put("id",id);
            map.put("name",name);
            map.put("sn",sn);
            //不能直接传this进去,会出现死循环
            return JSON.toJSONString(map);
        }
    }
    
    • 在编辑按钮上使用 data-json 属性绑定当前的数据

      <#-- 使用data-*绑定自定义数据-->
      <#-- 此处只能使用单引号,因为json字符串中已经有双引号了 -->
      <a href="#" data-json='${entity.json}' class="btn btn-info btn-xs btn-input">
          <span class="glyphicon glyphicon-pencil"></span> 编辑
      </a>
      

前端f12可以查看a链接上的数据

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OzEoarho-1649928393062)(F:/02-CRM/02-CRM/05_课件/image/image-20200514114820984.png)]

  • 修改点击编辑按钮绑定的事件处理函数,获取到按钮中 data-json 属性中的数据,使用 DOM 操作回显在模态框中

    //新增/编辑按钮点击事件
    $(".btn-input").click(function () {
        //清除模态框的数据
        $("#editForm input").val('');
        //获取事件源绑定的数据 使用data方法可以很方便获取data-*开头的属性的数据
        var json = $(this).data("json");
        if(json){ //json有数据代表是编辑
            $("#editForm input[name=id]").val(json.id);
            $("#editForm input[name=name]").val(json.name);
            $("#editForm input[name=sn]").val(json.sn);
        }
        //打开模态框
        $("#editModal").modal('show');
    })
    

改造员工页面

页面改造为ftl模板

  • 手动把 list.jsp 和 input.jsp 改成 ftl 文件,一是改文件的后缀名,二是删除或替换里面的标签和指令
  • 修改service分页功能,使用分页插件实现
  • 可能为空的数据,需使用!解决空值问题

input页面是新增和编辑共用的,解决空值问题有下面两种方式

方式一:
<td>${(entity.dept.name)!}</td> //dept为空也不报错 
方式二:
<td>${(entity.dept.name!}</td> //dept为空就报错,id为空不报错

在#list指令中,也可以使用!进行空值处理

<select multiple class="form-control selfRoles" size="15" name="ids">
    <#list (entity.roles)! as r>
        <option value="${r.id}">${r.name}</option>
    </#list>
</select>

freemarker 判断 session 域值

<#if Session.EMP_IN_SESSION.admin></#if>  判断session中的对象的admin是否为true

freemarker 使用三元表达式

<td>${entity.admin ? string('是','否')}</td>

freemarker 中 if 判断为空

<#if entity??></#if>       判断对象不为空
<#if (entity.name)??></#if>  判断对象属性不为空

超级管理员的回显

<#if employee?? && employee.admin >
    <script>
        $("#admin").prop("checked", true);
    </script>
</#if>

改造角色页面

页面改造为ftl模板

  • 手动把 list.jsp 和 input.jsp 改成 ftl文件,一是改文件的后缀名,二是删除或替换里面的标签和指令

  • 修改service分页功能,使用分页插件实现

  • 可能为空的数据,需使用!解决空值问题,如

    input页面是新增和编辑共用的,新增时role一定会为空,所以应该选用以下的方式一

    方式一:
    <input type="hidden" value="${(role.id)!}" name="id"> //role为空也不报错 
    方式二:
    <input type="hidden" value="${role.id!}" name="id"> //role为空就报错,id为空不报错
    

    在#list指令中,也可以使用!进行空值处理

    <select multiple class="form-control selfPermissions" size="15"  name="ids">
        <#list (role.permissions)! as p>
            <option value="${p.id}">${p.name}</option>
        </#list>
    </select>
    

所有页面都进行更改,无需更改后端

禁用/恢复账号功能

当员工离职后,不允许再登录该系统,管理员需要禁用该账号

特殊情况:比如员工离职后又回来,或者管理员操作失误,需要提供恢复的功能

实现步骤:

  1. 为员工表添加状态 stats 字段,数据库类型bit,java类型boolean,true为正常,false为禁用

  2. 添加员工时 status 默认为 true

    private boolean status = true;
    
  3. 员工分页列表中把状态也显示出来,显示 正常、禁用

    <td>${entity.status ? string('正常','禁用')}</td>
    
  4. 该页面添加两个按钮,恢复、禁用,根据状态不同而显示不同的按钮

    <#if Session.EMP_IN_SESSION.admin>
        <a href="/employee/rest.do?id=${entity.id}" class="btn btn-xs btn_rest">
            <span class="glyphicon glyphicon-trash"></span> 重置密码
        </a>
        <#if entity.status>
        <a href="#" data-id="${entity.id}" 
           class="btn btn-success btn-xs btn_changeStatus">
            <span class="glyphicon glyphicon-trash"></span> 恢复
        </a>
        <#else>
        <a href="#" data-id="${entity.id}" 
           class="btn btn-danger btn-xs btn_changeStatus">
            <span class="glyphicon glyphicon-trash"></span> 禁用
        </a>
        </#if>
    </#if>
    
  5. 点击按钮后发送请求到后台给新员工的状态,更新完自动重新加载当前页面

    $(".btn_changeStatus").click(function () {
        //获取当前点击的部门id
        var id = $(this).data('id');
        //提示确认框
        $.messager.confirm("警告","是否确认进行?",function () {
            //点击确认后会执行的函数
            //发送ajax异步请求
            $.get('/employee/changeStatus.do',{id:id},handlerMessage)
        })
    })
    
    //账号恢复、禁用操作
    @RequestMapping("/changeStatus")
    @ResponseBody
    public JsonResult changeStatus(Long id){
        //获取当前登录用户信息
        if (!UserContext.getCurrentUser().isAdmin()){
            return new JsonResult(false, "您没有权限操作");
        }
        //获取当前点击的用户信息
        Employee employee = service.get(id);
        //判断此用户的状态
        if (employee.isStatus()){
            employee.setStatus(false);
            service.updateStatus(employee);
            return new JsonResult();
        }
        employee.setStatus(true);
        service.updateStatus(employee);
        return new JsonResult();
    }
    
  6. 登录时,如果是禁用的状态,则不允许登录,提示 ”账号已被禁用“

    @Override
    public Employee login(String username, String password) {
        
        ...
            
        //查询数据库中是否有匹配的数据
        Employee employee = mapper.selectByUsernameAndPassword(username, password);
        
        ...
            
        if(!employee.isStatus()){
            //通知调用者出现异常
            throw new LogicException("账号已被禁用");
        }
        return employee;
    }
    

2、客户关系管理系统

统一异常处理

如何解决

  • 手动try

    弊端是到处是重复代码,系统的代码耦合度高,工作量大且不好统一,维护的工作量也很大。

  • 利用 Spring MVC 的方式

    SpringMVC为Controller层的异常和数据校验的异常提供了全局统一处理

    mvc 配置文件中需要扫描到注解

    <context:component-scan base-package="com.web,com.exception"/>
    

    @ExceptionHandler:在方法的上面,表示可以处理某类型的异常

    @ControllerAdvice :在类的上面,表示当前类是异常的处理类

    针对不同异常进行不同处理,针对不同处理方法响应的内容, 进行不同处理,比如原来方法响应 HTML 依然响应 HTML,若原来方法响应 JSON 依然响应 JSON。

    @ControllerAdvice
    public class HandlerControllerException {
    
        //HandlerMethod method :代表出现异常的方法
        @ExceptionHandler(RuntimeException.class)
        public Object handlerException(RuntimeException e, HttpServletResponse response, HandlerMethod method){
            e.printStackTrace();  //方便开发找bug
            if (method.hasMethodAnnotation(ResponseBody.class)){  //说明页面需要JSON格式数据
                String errorMsg = "操作失败,请联系管理员";
                if (e instanceof LogicException){   // 说明是登录地方的异常,需要使用自定义异常信息
                    errorMsg = e.getMessage();
                }
                JsonResult json = new JsonResult(false,errorMsg);
                response.setContentType("application/json;charset=utf-8");
                try {
                    response.getWriter().print(JSON.toJSONString(json));
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
                return null;
            }else {
                return "common/error";
            }
        }
    }
    

注意,有一个问题:

在登录的业务方法中,有一些自定义的运行时类异常

如果在登录的后端控制器中,没有使用 try-catch ,即默认使用 HandlerControllerException 异常处理类来处理异常,就无法后台抛出自定义的异常

解决办法:

  1. 后端控制器中使用 try-catch ,这样就默认不使用 异常处理类。

  2. 使用自定义异常,不使用运行时类异常,不让异常处理类来处理

    1. 自定义异常类

      public class LogicException extends RuntimeException {
          public LogicException(String message) {
              super(message);
          }
      }
      
    2. 更改登录的业务方法

      throw new LogicException("用户名不可为空");
      
    3. 去掉业务类的 try-catch 结构

员工批量删除功能

前端处理

  • 页面添加批量删除的按钮

    <a href="#" class="btn btn-danger btn-batchDelete">
        <span class="glyphicon glyphicon-trash"></span> 批量删除
    </a>
    
  • 全选复选框点击事件

    用户点击全选,则列表中的所有复选框全部选中,反之,全反选

    $("#allCb").click(function () {
    	//获取当前复选框checked状态,设置到table列表中的所有复选框
        // prop() 取出或设置某个属性的值
        //对于循环遍历出来的表单内容,只能用class属性来锁定,如果用id锁定就只会锁定第一个
    	$(".cb").prop('checked',$(this).prop('checked'))
    })
    
  • 列表复选框点击事件

    只要当前列表每个复选框都被全选中,那么全选复选框则自动也要选中,若不满足,全选复选框也要取消选中

    $(".cb").click(function () {
        //获取列表中已经被勾选的复选框的数量 , 判断是否等于列表中的所有复选框的数量
        $("#allCb").prop('checked',$(".cb:checked").length == $(".cb").length)
    })
    
  • 在列表的复选框中绑定员工的id值

    <td><input type="checkbox" class="cb" data-id="${entity.id}"></td>
    
  • 批量删除按钮点击事件

    //批量删除按钮
    $(".btn-batchDelete").click(function () {
        //获取用户勾选的数据
        var checked = $(".cb:checked")
        if(checked.length == 0){
            $.messager.popup('警告','请选中要删除的数据')
            return;
        }
        //确认框
        $.messager.confirm("警告","是否确认删除?",function () {  //点击确认后会执行的函数
            //把选中的员工的id存到ids
            var ids = [];
            checked.each(function (index, ele) {
                ids.push($(ele).data('id'));
            })
            
            //发送ajax异步请求
            $.get('/employee/batchDelete.do',{ids:ids},handlerMessage)
        })
    })
    
  • 现在点击确认传递的数据如下

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ViKKbQwp-1649928393063)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220317095944129.png)]

    发现一个问题,这里传递的参数 是 ids[] ,多了中括号,而后台接收的参数是 ids ,会导致参数不一致

  • 处理使用 jQuery 发送ajax请求处理数组参数传递的问题

    $(function () {
        //禁用数组添加[]的功能
        jQuery.ajaxSettings.traditional = true;
    }
    

后端处理

  • 控制器中新增批量删除的处理方法

    @RequestMapping("/batchDelete")
    @ResponseBody
    public JsonResult batchDelete(Long[] ids){
        employeeService.batchDelete(ids);
        return new JsonResult();
    }
    
  • 使用 MyBatis forEach 标签批量删除员工

    <foreach collection="集合类型" open="开始的字符" close="结束的字符" item="集合中的成员"
             		separator="集合成员之间的分隔符">
        #{item}
    </foreach>
    如果接口使用 @Param("ids") 注解,则可写为 collection="ids"
    
    <delete id="batchDelete">
        DELETE FROM employee WHERE id IN
        <foreach collection="array" item="item" open="(" separator="," close=")">
            #{item}
        </foreach>
    </delete>
    

表单验证功能

Bootstrap-Validator插件

**注意:**插件验证是在输入内容之后、同步提交表格(submit()方法执行)之后,就会进行内容验证,但是验证完之后不会进行提交

所以需要在插件的位置再进行提交,并且只能是异步提交,如果是同步提交 $("#editForm").Submit() 就又会进行验证

在做web项目的时候,表单数据验证是再常见不过的需求了,友好的错误提示能增加用户体验,提高程序稳定性。

http://bootstrapvalidator.votintsev.ru/getting-started/

https://www.cnblogs.com/landeanfen/p/5035608.html

https://www.cnblogs.com/mzqworld/articles/9068430.html

  • 引入插件

    <!--引入验证插件的样式文件-->
    <link rel="stylesheet" href="/js/plugins/bootstrap-validator/css/bootstrapValidator.min.css"/>
    <!--引入验证插件的js文件-->
    <script type="text/javascript" src="/js/plugins/bootstrap-validator/js/bootstrapValidator.min.js"></script>
    <!--中文语言库-->
    <script type="text/javascript" src="/js/plugins/bootstrap-validator/js/language/zh_CN.js"></script>
    

普通验证

  • 使用插件

    参考 F:\02-CRM\02-CRM\04_常用插件\常用插件\bootstrap-validator\demo\index.html 案例文件

    查看网页源码,拷贝并进行修改

    <script>
        $(function () {
            //-----------------------表单验证--------------------------------
            $("#editForm").bootstrapValidator({   //以JSON格式配置
                feedbackIcons: { //图标 √ ×
                    valid: 'glyphicon glyphicon-ok',
                    invalid: 'glyphicon glyphicon-remove',
                    validating: 'glyphicon glyphicon-refresh'
                },
                fields:{   //需要配置规则的字段
                    name:{    //配置 name=name 的input标签的规则
                        validators: {   //可配置多个规则
                            notEmpty: {     //必填
                                message: '用户名必填'      //提示信息
                            },
                            stringLength: { //字符串的长度范围
                                min: 1,
                                max: 5
                            }
                        }
                    },
                    password:{    //配置 name=password 的input标签的规则
                        validators:{
                            notEmpty:{ //不能为空
                                message:"密码必填" //错误时的提示信息
                            },
                        }
                    },
                    repassword:{
                        validators:{
                            notEmpty:{ //不能为空
                                message:"密码必填" //错误时的提示信息
                            },
                            identical: {//两个字段的值必须相同
                                field: 'password',
                                message: '两次输入的密码必须相同'
                            },
                        }
                    },
                    email: {
                        validators: {
                            emailAddress: {} //邮箱格式
                        }
                    },
                    age:{
                        validators: {
                            between: { //数字的范围
                                min: 18,
                                max: 60
                            }
                        }
                    }
                }
            }).on('success.form.bv', function() { //表单所有数据验证通过后执行里面的代码
                //不可使用同步提交,此方法会触发验证,且验证完之后不会提交表单导致死循环
                //$("#editForm").submit();
    
                //提交异步表单(另外引入的插件,和验证无关)
                $("#editForm").ajaxSubmit(function (data) {
                    if(data.success){
                        $.messager.popup('操作成功')
                        window.location.href = "/employee/list.do"
                    }else{
                        $.messager.popup(data.msg)
                    }
                })
            });
    
        })
    </script>
    

    后端控制器

    //修改、保存员工
    @RequiredPermission(name = "员工保存或更新",expression = "employee:saveOrUpdate")
    @RequestMapping("/saveOrUpdate")
    @ResponseBody
    public JsonResult saveOrUpdate(Employee employee, Long[] ids){
        if (employee.getId() != null) {  //是编辑
            service.update(employee,ids);
        }else{   //是添加
            service.save(employee,ids);
        }
        return new JsonResult();
    }
    

远程验证:验证是否已存在

在此案例中 ,用户名不可重复,会导致登录不成功

  • 验证用户名是否存在:前端页面

    $("#editForm").bootstrapValidator({   //以JSON格式配置
        fields:{   //需要配置规则的字段
            name:{    //配置 name=name 的input标签的规则
                validators: {   //可配置多个规则
                    notEmpty: {     //必填
                        message: '用户名必填'      //提示信息
                    },
                    stringLength: { //字符串的长度范围
                        min: 1,
                        max: 5
                    },
                    // 这种情况下向url提交的参数名就是name
                    remote: { //远程验证
                        type: 'POST', //请求方式
                        url: '/employee/checkName.do', //请求地址
                        message: '用户名已经存在', //验证不通过时的提示信息
                        delay: 1000 //输入内容1秒后发请求
                    }
                }
            }
        }
    })
    
  • 后台检查用户名是否存在

    注意,之前写的方法都是返回自定义的类 JsonResult ,但是这里如果返回自定义类,插件无法识别是否验证成功,所以需要返回插件规定的类型

    插件要求返回结果需要为键值对形式 key 变量名必须为为 valid ,value为boolean类型

    valid : true 代表验证通过(该用户名不存在)

    valid:false 代表验证不通过(用户名已经存在)

    //验证用户名是否存在
    @RequestMapping("/checkName")
    @ResponseBody
    public HashMap checkName(String name){    // valid:true
        //valid 的key是插件规定的,不能更改;true表示验证通过、false表示不通过,已存在
        Employee employee = service.selectByName(name);
        HashMap<String, Boolean> map = new HashMap<>();
        map.put("valid",employee==null);
        return map;
    }
    

    如此,会导致一个问题:

    因为编辑和添加共用一个页面,并且编辑会回显用户名,就会提示用户名已存在

编辑时用户名的处理

因为编辑和添加共用一个页面,并且编辑会回显用户名,就会提示用户名已存在

编辑时用户名的处理,有两种做法:

  1. 方式一:编辑时用户名设置为不能修改,并且编辑时不需要对 name 进行验证

    readonly:表示自读,页面不可更改,但是点击提交的时候仍然会对输入框进行验证。

    disabled:表示禁用,相当于没有此输入框。但那会导致编辑保存时用户名为空。需要再修改SQL语句

    <input
    	<#if entity??>
    		<#--readonly-->
    		disabled
    	</#if>
    type="text" class="form-control" id="name" name="name" value="${(entity.name)!}" placeholder="请输入用户名">
    </div>
    
    # SQL 语句中去掉了用户名的修改
    <update id="updateByPrimaryKey" parameterType="com.domain.Employee">
      update employee
      set email = #{email,jdbcType=VARCHAR},
        age = #{age,jdbcType=INTEGER},
        admin = #{admin,jdbcType=BIT},
        dept_id = #{dept.id,jdbcType=BIGINT}
      where id = #{id,jdbcType=BIGINT}
    </update>
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Euq6AEIY-1649928393064)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220317122204640.png)]

  2. 方式二:编辑员工时,也可以修改用户名

    利用remote中的data多传一个id参数到后台,后台判断当前传过来需要验证的的name值 与 该id所对应的员工名字是否相同,相同则代表值没有被改变过,验证通过,可以保存,不相同则需要再进行验证

    ​ 先将 SQL 语句改回来

    ​ 修改前端,获得 id 及 name 参数

    $("#editForm").bootstrapValidator({   //以JSON格式配置
        fields:{   //需要配置规则的字段
            name:{    //配置 name=name 的input标签的规则
                validators: {   //可配置多个规则
                    notEmpty: {     //必填
                        message: '用户名必填'      //提示信息
                    },
                    stringLength: { //字符串的长度范围
                        min: 1,
                        max: 5
                    },
                    remote: { //远程验证
                        type: 'POST', //请求方式
                        url: '/employee/checkName.do', //请求地址
                        data: function() {  //自定义提交参数,默认只会提交当前用户名input的参数
                            return {
                                id: $('[name="id"]').val(),
                                name: $('[name="name"]').val()
                            };
                        },
                        message: '用户名已经存在', //验证不通过时的提示信息
                        delay: 1000 //输入内容1秒后发请求
                    }
                }
            }
        }
    })
    

    ​ 后端代码:

    @RequestMapping("/checkName")
    @ResponseBody
    public HashMap checkName(Long id, String name){    // valid:true
        HashMap<String, Boolean> map = new HashMap<>();
        if (id != null) {  //是编辑
            Employee empbyid = service.get(id);
            //判断用户名是否被更改
            if (empbyid.getName().equals(name)){  //未被更改
                map.put("valid",true);
            }else {  //用户名被更改,需要验证
                Employee empbyname = service.selectByName(name);
                map.put("valid",empbyname==null);
            }
            return map;
        }else{   //是添加
            Employee employee = service.selectByName(name);
            map.put("valid",employee==null);
            return map;
        }
    }
    

    修改时需要点击两次保存按钮的问题:若表单未更改,则第一次点击保存就是验证,第二次点击保存才是提交

    解决:

    点击按钮后,每次弹框弹起后进行一次验证,仅有一次

    $(function () {
        $('html').one('mouseover',function(){
            //每次弹框弹起后都会进行一次校验,而且只校验一次
            $('#editForm').data("bootstrapValidator").validate();
        })
    })
    

导入导出功能

导出,就是把数据库中表的数据导出到 excel文件中

导入,就是把 excel文件中的数据导入到数据库表中。

这功能类似数据库的导入导出功能,只是区别在于这个操作者是普通用户,是在浏览器操作的,使用excel更易于阅读。

Apache POI

Apache POI 是 Apache 软件基金会的开放源码函式库,POI 提供 API 给 Java 程序对 Microsoft Office 格式档案读和写的功能

关于 EXCEL 的参考文档:https://poi.apache.org/components/spreadsheet/quick-guide.html

  • poi关于excel的概念

    Workbook(对应为一个excel)

    Sheet(excel中的表)

    Row(表中的行)

    Column(表中的列)

    Cell(表中的单元格,由行号和列号组成)

  • 添加依赖

    <!-- 处理office文件:普通版本 -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi</artifactId>
        <version>4.1.2</version>
    </dependency>
     
    <!-- 处理office文件:增强版本 -->
    <!--<dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>4.1.2</version>
    </dependency>-->
    

    普通版和增强版需要创建的对象不一样

    Workbook wb = new HSSFWorkbook(); 普通版          // or new XSSFWorkbook(); 增强版
    

实现导出功能

用户点击员工列表页面的导出按钮,就下载 employee.xls 文件,里面包含员工的数据。

实现步骤:

  • 页面添加导出按钮,不需要异步提交

    <a href="/employee/exportXls.do" class="btn btn-warning" >
        <span class="glyphicon glyphicon-download"></span> 导出
    </a>
    
  • 后台实现导出的处理方法

    • controller

      //导出
      @RequestMapping("/exportXls")
      public void exportXls(HttpServletResponse response) throws IOException {
          //文件下载的响应头(让浏览器访问资源的的时候以下载的方式打开)
          response.setHeader("Content-Disposition","attachment;filename=employee.xls");
          //创建excel文件
          Workbook wb = service.exportXls();
          //把excel的数据输出给浏览器
          wb.write(response.getOutputStream());
      }
      
    • service

      @Override
      public Workbook exportXls() {
          //使用POI创建一个Excel
          Workbook wb = new HSSFWorkbook();
          //创建Excel中的sheet
          Sheet sheet1 = wb.createSheet("员工通讯录");
          //创建标题行数据,从0开始
          Row row = sheet1.createRow(0);
          row.createCell(0).setCellValue("姓名");
          row.createCell(1).setCellValue("邮箱");
          row.createCell(2).setCellValue("年龄");
          //查询所有员工
          List<Employee> list = mapper.selectAll();
          for (int i = 0; i < list.size(); i++) {
              Employee employee = list.get(i);
              //每个员工创建一行数据
              row = sheet1.createRow(i+1);
              //创建单元格,写入内容到单元格
              row.createCell(0).setCellValue(employee.getName());
              row.createCell(1).setCellValue(employee.getEmail()==null ? "邮箱为空" : employee.getEmail());
              row.createCell(2).setCellValue(employee.getAge()==null ? 0 : employee.getAge());
          }
          return wb;
      }
      

实现导入功能

如果觉得一条一条数据手动录入添加很麻烦,可以使用导入的方式,方便快捷

实现步骤:

前端页面
  • 页面添加导入按钮

    <a href="#" class="btn btn-warning btn-import">
        <span class="glyphicon glyphicon-upload"></span> 导入
    </a>
    
  • 给导入按钮绑定点击事件 , 事件中弹出带有文件上传表单的模态框

    按钮事件

    $(function () {
        //导入的模态框
        $(".btn-import").click(function () {
            $("#importModal").modal('show');
        })
    })
    
  • 模态框(注意上传的文件有固定格式,所以需要提供一个样板 excel 模板文件给用户下)

  • 要实现下载模板很简单,在链接 href 路径的位置准备好文件即可

    <div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 class="modal-title" id="myModalLabel">导入</h4>
                </div>
                <form class="form-horizontal" action="/employee/importXls.do" method="post" id="importForm">
                    <div class="modal-body">
                        <div class="form-group" style="margin-top: 10px;">
                            <label for="name" class="col-sm-3 control-label"></label>
                            <div class="col-sm-6">
                                <input type="file" name="file">
                            </div>
                        </div>
                        <div class="form-group" style="margin-top: 10px;">
                            <div class="col-sm-3"></div>
                            <div class="col-sm-6">
                                <#-- / 斜杠表示webapp目录 -->
                                <a href="/xlstemplates/employee_import.xls" class="btn btn-success" >
                                    <span class="glyphicon glyphicon-download"></span> 下载模板
                                </a>
                            </div>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-default" data-dismiss="modal">取消</button>
                        <button type="submit" class="btn btn-primary btn-submit">保存</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
    
    </body>
    
  • 上传按钮绑定点击事件

    $(function(){
        //导入的提交
    	//ajaxForm,按钮需要是type="submit",且按钮在表单<form>标签内
    	//使用 ajaxForm 不需要套在点击事件里面,只需要在页面加载函数里面即可
        // ajaxForm 作用是把表单转化为异步表单,没有提交。所以按钮必须是 type="submit"
    	$("#importForm").ajaxForm(handlerMessage);
    })
    
后端代码

后端实现文件上传,后端控制器参数接收需要使用到 SpringMVC 提供的 MultipartFile 类,使用此类就需要导入依赖、添加文件上传解析器

  • 添加文件上传依赖

    <!-- fileupload文件上传依赖 -->
    <dependency>
        <groupId>commons-fileupload</groupId>
        <artifactId>commons-fileupload</artifactId>
        <version>1.3.1</version>
    </dependency>
    
  • 添加文件上传解析器,在 MVC 的配置文件中配置

    <!--文件上传解析器 id必须是multipartResolver-->
    <bean id="multipartResolver" 
          class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
        <!--最大上传文件大小 10M-->
        <property name="maxUploadSize" value="#{1024*1024*10}"/>
    </bean>
    
  • 后台实现导入的处理方法

    获取上传的文件的输入流,转成 Workbook,之后使用 POI 的 API 读取文件中的员工数据,存入数据库中

    • controller

      //导入
      @RequestMapping("/importXls")
      @ResponseBody
      public JsonResult importXls(MultipartFile file) throws Exception {
          service.importXls(file);
          return new JsonResult();
      }
      
    • service

      @Override
      public void importXls(MultipartFile file) throws IOException {
          //读取上传的文件,并且封装为一个 Workbook
          Workbook wb = new HSSFWorkbook(file.getInputStream());
          //读取第0张sheet表
          Sheet sheet = wb.getSheetAt(0);
          //获取最后一行的索引,说明一共有 lastRowNum+1 条数据
          int lastRowNum = sheet.getLastRowNum();
          for (int i = 1; i < lastRowNum+1; i++) {  //注意,标题行不读取
              //获取行数据
              Row row = sheet.getRow(i);
              String name = row.getCell(0).getStringCellValue();
              // 判断如果用户名是空,就不再往下读
              //一是解决用户名必填的问题
              //二是可以防止文件中间有空行的情况
              if(!StringUtils.hasLength(name.trim())){
                  return;
              }
              Employee employee = new Employee();
              employee.setName(name);
              employee.setEmail(row.getCell(1).getStringCellValue());
              //对于年龄,需要判断单元格数据类型
              Cell cell = row.getCell(2);
              if (cell.getCellType() == CellType.NUMERIC){  //如果填的是数字
                  /*int age = (int) cell.getNumericCellValue();
                  if (age < 18 || age>60){
                      throw new LogicException("第"+ i +"行数据有问题,年龄不在范围内");
                  }*/
                  employee.setAge((int) cell.getNumericCellValue());
              }else {   //作为文本类型单元格读取
                  employee.setAge(Integer.valueOf(row.getCell(2).getStringCellValue()));
              }
              // 密码必填:设置默认密码 1
              employee.setPassword("1");
              //调用保存方法
              this.save(employee,null);
          }
      }
      
  • 其他常用第三方工具

    • 阿里easyexcel https://alibaba-easyexcel.github.io/quickstart/read.html

    • easypoi http://easypoi.mydoc.io/

3、Shiro

权限管理概述

权限管理,一般指根据系统设置的安全规则或者安全策略,用户可以访问而且只能访问自己被授权的资源,不多不少。权限管理几乎出现在任何系统里面,只要有用户和密码的系统。 很多人常将“用户身份认证”、“密码加密”、“系统管理”等概念与权限管理概念混淆。

在权限管理中使用最多的还是功能权限管理中的基于角色访问控制(RBAC,Role Based Access Control)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5p1NnDW9-1649928393065)(F:/02-CRM/02-CRM/05_课件/image/image-20200707151958178.png)]

当项目中需要使用权限管理的时候,我们可以选择自己去实现(前面的课程中所实现的 RBAC 系统),也可以选择使用第三方实现好的框架去实现,他们孰优孰劣这就需要看大家在项目中具体的需求了。

这里我们介绍两种常用的权限管理框架:

1. Apache Shiro

Apache Shiro 是一个强大且易用的 Java 安全框架,使用 Apache Shiro 的人越来越多,它可实现身份验证、授权、密码和会话管理等功能。

2. Spring Security

Spring Security 也是目前较为流行的一个安全权限管理框架,它与 Spring 紧密结合在一起。

ShiroSpring Security 比较

Shiro 比 Spring Security更容易上手使用和理解,Shiro 可以不跟任何的框架或者容器绑定,可独立运行,而Spring Security 则必须要有Spring环境, Shiro 可能没有 Spring Security 做的功能强大,但是在实际工作时可能并不需要那么复杂的东西,所以使用小而简单的 Shiro 就足够了。对于它俩到底哪个好,这个不必纠结,能更简单的解决项目问题就好了

Shiro 概述

Shiro 可以帮助我们完成:认证、授权、加密、会话管理、与 Web 集成、缓存等。

Shiro 主要组件包括:Subject,SecurityManager,Authenticator,Authorizer,SessionManager,CacheManager,Cryptography,Realms

  • Subject(用户): 访问系统的用户,主体可以是用户、程序等,进行认证的都称为主体; Subject 一词是一个专业术语,其基本意思是“当前的操作用户”。

    在程序任意位置可使用:Subject currentUser = SecurityUtils.getSubject() 获取到subject主体对象,类似 Employee user = UserContext.getUser()

  • SecurityManager(安全管理器):它是 shiro 功能实现的核心,负责与后边介绍的其他组件(认证器/授权器/缓存控制器)进行交互,实现 subject 委托的各种功能。有点类似于SpringMVC 中的 DispatcherServlet 前端控制器,负责进行分发调度。

  • Realms(数据源): Realm 充当了 Shiro 与应用安全数据间的“桥梁”或者“连接器”。可以把Realm 看成 DataSource,即安全数据源。执行认证(登录)和授权(访问控制)时,Shiro 会从应用配置的 Realm 中查找相关的比对数据。以确认用户是否合法,操作是否合理。

  • Authenticator(认证器): 用于认证,从 Realm 数据源取得数据之后进行执行认证流程处理。

  • Authorizer(授权器):用户访问控制授权,决定用户是否拥有执行指定操作的权限。

  • SessionManager (会话管理器):Shiro 与生俱来就支持会话管理,这在安全类框架中都是独一无二的功能。即便不存在 web 容器环境,shiro 都可以使用自己的会话管理机制,提供相同的会话 API。

  • CacheManager (缓存管理器):用于缓存认证授权信息等。

  • Cryptography(加密组件): Shiro 提供了一个用于加密解密的工具包。

Shiro 认证

认证的过程即为用户的身份确认过程,所实现的功能就是我们所熟悉的登录验证,用户输入账号和密码提交到后台,后台通过访问数据库执行账号密码的正确性校验

前面我们介绍过,Shiro 不仅在 web 环境中可以使用,在 JavaSE 中一样可以完美的实现相关的功能

下面我们先来看看在 JavaSES 环境中它是如何实现认证功能的:

基于 ini 的认证

准备工作:

  1. 导入基本的 jar 包

    <dependency>
        <groupId>commons-logging</groupId>
        <artifactId>commons-logging</artifactId>
        <version>1.1.3</version>
    </dependency>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-core</artifactId>
        <version>1.5.2</version>
    </dependency>
    
  2. 编写 ini 配置文件: shiro.ini

    shiro默认支持的是ini配置的方式,这里只是为了方便,项目中还是会选择xml

    #用户的身份、凭据
    [users]
    zhangsan=555 
    lisi=666
    
  3. 使用 Shiro 相关的 API 完成身份认证

    进行环境配置、获取主体对象、创建令牌、登录、登出

    @Test
    public void testLogin(){
        // 安全管理器,是shiro的核心
        DefaultSecurityManager securityManager = new DefaultSecurityManager();
        // 数据源,加载shiro.ini配置,得到配置中的用户信息(账号+密码)
        IniRealm iniRealm = new IniRealm("classpath:shiro.ini");
        // 设置数据源到安全管理器中
        securityManager.setRealm(iniRealm);
        // 把安全管理器注入到当前的环境中
        SecurityUtils.setSecurityManager(securityManager);
        
        // 无论有无登录都可以获取到subject主体对象,但是判断登录状态需要利用里面的属性来判断
        Subject subject = SecurityUtils.getSubject();
        System.out.println("认证状态:"+subject.isAuthenticated());   //false
        
        // 使用shiro进行认证
        //创建令牌 (来自登录页面的表单:携带登录用户的账号和密码)
        UsernamePasswordToken token = new UsernamePasswordToken("lisi","666");
        
        //执行登录操作(将用户的和 ini 配置中的账号密码做匹配)
        subject.login(token);
        System.out.println("认证状态:"+subject.isAuthenticated());   //true
        
        //登出
        subject.logout();
        System.out.println("认证状态:"+subject.isAuthenticated());   //false
    }
    

如果输入的身份和凭证和 ini 文件中配置的能够匹配,那么登录成功,登录状态为true,反之登录状态为false

登录失败一般存在两种情况:

  1. 账号错误 org.apache.shiro.authc.UnknownAccountException

  2. 密码错误 org.apache.shiro.authc.IncorrectCredentialsException

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yGgIu41V-1649928393066)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220318101620046.png)]

Shiro 认证流程源码分析

详细流程可自行debug,或参考源码分析图

  1. 调用 subject.login() 方法进行登录,其会自动委托给 securityManager.login() 方法进行登录
  2. securityManager 通过 Authenticator (认证器) 进行认证
  3. Authenticator 的实现类 ModularRealmAuthenticator 调用realm从ini配置文件取用户真实的账号和密码,这里使用的是IniRealm(shiro自带,相当于数据源)
  4. IniRealm先根据token中的账号去ini中找该账号,如果找不到则给ModularRealmAuthenticator返回null,如果找到则匹配密码,匹配密码成功则认证通过

自定义 Realm

自定义 Realm 在实际开发中使用非常多,应该我们需要使用的账户信息通常来自程序或者数据库中, 而不是前面使用到的 ini 文件的配置。需要使用真正的数据

在 AuthenticatingRealm 中调用 doGetAuthenticationInfo 方法来获取,如果返回的 info不等于空,说明账号存在,才会进行密码校验,如果不存在则直接抛出UnknownAccountException异常。

所以,如果我们要自定义 Realm,应该覆写 doGetAuthenticationInfo()方法,然后在该方法中实现账号的校验,并返回 AuthenticationInfo 对象给上层调用者 AuthenticatingRealm 做进一步的校验。

Realm 的继承体系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-37IrujCl-1649928393066)(F:/02-CRM/02-CRM/05_课件/image/image-20200707163237123.png)]

  1. 自定义 Realm

    在继承体系中的每个类所能够实现的功能不一样,在后面的开发中,我们通常需要使用到缓存,认证和授权所有的功能,所以选择继承 AuthorizingRealm

    public class CrmRealm extends AuthorizingRealm {
        //提供授权信息
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            // 1、获取当前登录的员工对象,获取员工id
            // 2、查询数据库,该员工拥有的角色和权限数据
    
            SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
            // 3、设置当前登录用户拥有的角色和权限数据
            info.addRoles(Arrays.asList("hr","admin"));
            info.addStringPermissions(Arrays.asList("employee:sava","employee:delete"));
            return info;
        }
    
        //提供认证信息,返回的 Info 相当于用户的信息,被拿去验证
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            //如果当前方法返回null,shiro会自动抛出 UnknownAccountException
            //如果返回不为null,shiro会自动从返回的对象中获取到真实的密码,再与token(登录页面的令牌)去对比
    
            // 1、获取令牌中的用户名(前端传来的)
            String username = (String) token.getPrincipal();
            /*UsernamePasswordToken token1 = (UsernamePasswordToken) token;
            String username = token1.getUsername();*/
    
            // 2、查询数据库中是否存在
            String name="zhangsan"; String password="555";  //模拟数据库中的数据
    
            // 3、若不存在,返回null
            // 4、若存在,返回 SimpleAuthenticationInfo
            if(username.equals(username)){
                //身份信息可以在任意地方获取到,用来跟 subject 绑定在一起的
                //在项目中就直接传入员工对象,跟subject绑定在一起,方便我们后续在任意地方获取当前登录的员工对象
                return new SimpleAuthenticationInfo(username,  //身份信息
                        //这里传入的是数据库中的真实密码,shiro会自动判断token中的密码是否一致
                        password,
                        this.getName()//当前Realm数据源的名称,暂时无用,一般是有多个数据源时做标志,表示数据从哪个数据源查出的
                );}
            return null;
        }
    }
    
  2. 将自定义的数据源设置到安全管理器

    在配置文件中注册自定义的Realm,并设置到SecurityManager

    shiro.ini

    #自定义的 Realm 信息
    crmRealm=cn.wolfcode.crm.shiro.CRMRealm  
    #将 crmRealm 设置到当前的环境中
    securityManager.realms=$crmRealm
    

运行之前的测试代码,现在使用的是我们自定义的 Realm 完成账号的校验。

在实际开发中,上面账户信息的假数据应该是从数据库中查询得到,下面我们将 Shiro 认证应用到CRM 项目中

CRM 中集成 Shiro 认证

之前设置的两个拦截器(登录拦截、权限拦截)可以删除,注意删除 SpringMVC 配置文件中的拦截器配置

添加依赖
<properties>
    <spring.version>5.2.5.RELEASE</spring.version>
    <shiro.version>1.5.2</shiro.version>
</properties>
<!--shiro 核心-->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>${shiro.version}</version>
</dependency>
<!--shiro 的 Web 模块-->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-web</artifactId>
    <version>${shiro.version}</version>
</dependency>
<!--shiro 和 Spring 集成-->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>${shiro.version}</version>
</dependency>
<!--shiro 底层使用的 ehcache 缓存-->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>${shiro.version}</version>
</dependency>
<!--shiro 依赖的日志包-->
<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
</dependency>
<!--shiro 依赖的工具包-->
<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version>
</dependency>
<!--Freemarker 的 shiro 标签库-->
<dependency>
    <groupId>net.mingsoft</groupId>
    <artifactId>shiro-freemarker-tags</artifactId>
    <version>1.0.1</version>
    <exclusions>
        <exclusion>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-all</artifactId>
        </exclusion>
    </exclusions>
</dependency>

首先思考下面几个问题

  1. Shiro 认证是在访问系统资源的时候,对用户的身份等进行检查。那么身份检查操作应该在哪里实现呢?

  2. 在 SE 环境中,我们使用的是 DefaultSecurityManager 来实现认证的控制,那么现在再 EE 环境中应该使用哪一个 SecurityManager 来实现认证控制呢?DefaultWebSecurityManager

  3. 前面我们在 ini 配置文件中指定自定义的 Realm,现在又是在哪里指定具体使用的 Realm?

配置代理过滤器 web.xml

在访问的时候,需要做一系列的预处理操作,最佳选择就是使用过滤器来实现了(shiro不与任何框架绑定)

web.xml

<!-- 代理过滤器,拦截到请求后,会自动去spring容器中找真正执行业务的过滤器
		filter-name 里面的值,作为bean的名字去找 
-->
<filter>
   <filter-name>shiroFilter</filter-name>
   <filter-class>
      org.springframework.web.filter.DelegatingFilterProxy
   </filter-class>
</filter>
<filter-mapping>
   <filter-name>shiroFilter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>

这里使用了一个代理过滤器 DelegatingFilterProxy

因为真正的 shiroFilter 需要注入很多复杂的对象属性,而 web.xml 中只能配置字符串或数字的参数,是不能满足的。因此我们会把 shiroFilter 交给 Spring 进行管理,通过spring的xml文件来配置

使用 DelegatingFilterProxy 代理过滤器后,但浏览器发送请求过来,被代理过滤器拦截到后,代理过滤器会自动从 spring 容器中找 filter-name 标签所配置相同名称的 bean ,来实现真正的业务。

创建 resources / shiro.xml

创建的是 spring文件

为了单独对 Shiro 相关的配置进行管理,我们分离出一个 shiro.xml 配置文件,并在 mvc.xml 中引入

mvc.xml

<!-- 引入 Spring 配置文件 -->
<import resource="classpath:applicationContext.xml"/>
<!-- 引入 shiro 配置文件 -->
<import resource="classpath:shiro.xml"/>
配置shiro过滤器

注意此处的 id 必须要与web.xml的代理过滤器的 filter-name 标签值一致

shiro.xml

  1. 配置安全管理器,并将其绑定自定义的数据源realm

  2. 然后在过滤器里面,引用指定的安全管理器、指定自己的登录页面,设置拦截路径

<!-- 在xml配置shiroFilter,可以方便设置不同类型的属性 -->
<bean id="shiroFilter"
      class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <!--引用指定的安全管理器-->
    <property name="securityManager" ref="securityManager"/>
    <!--shiro默认的登录地址是/login.jsp 现在要指定我们自己的登录页面地址-->
    <property name="loginUrl" value="/login.html"/>
    <!-- 拦截路径的规则 -->
    <property name="filterChainDefinitions">
        <!--有顺序要求,静态资源放行要放前面-->
        <value>
            /login.do=anon
            /js/**=anon
            /css/**=anon
            /**=authc
        </value>
    </property>
</bean>

<!-- web环境使用的安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <!-- 设置自定义的数据源realm -->
    <property name="realm" ref="crmRealm"/>  <!--realm中贴@Component注解-->
</bean>

CrmRealm 类:从数据库中查询数据

注意最后传入的身份信息,会跟subject绑定在一起

//提供认证信息,返回的 Info 相当于用户的信息,被拿去验证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    //如果当前方法返回null,shiro会自动抛出 UnknownAccountException
    //如果返回不为null,shiro会自动从返回的对象中获取到真实的密码,再与token(登录页面的令牌)去对比

    // 1、获取令牌中的用户名(前端传来的)
    String username = (String) token.getPrincipal();
    /*UsernamePasswordToken token1 = (UsernamePasswordToken) token;
    String username = token1.getUsername();*/

    // 2、查询数据库中是否存在
    Employee employee = employeeService.selectByName(username);

    // 3、若不存在,返回null
    // 4、若存在,返回 SimpleAuthenticationInfo
    if(employee != null){
        //判断该账户是否被禁用
        if (!employee.isStatus()){
            throw new DisabledAccountException("账号被禁用");
        }
        //身份信息可以在任意地方获取到,用来跟 subject 绑定在一起的
     //在项目中就直接传入员工对象,跟subject绑定在一起,方便我们后续在任意地方获取当前登录的员工对象
        return new SimpleAuthenticationInfo(employee,  //身份信息,会跟subject绑定在一起
                //这里传入的是数据库中的真实密码,shiro会自动判断token中的密码是否一致
                employee.getPassword(),
                this.getName()//当前Realm数据源的名称,暂时无用,一般是有多个数据源时做标志,表示数据从哪个数据源查出的
        );}
    return null;
}

Shiro 中定义了多个过滤器来完成不同的预处理操作:

过滤器的名称Java
anonorg.apache.shiro.web. lter.authc.AnonymousFilter
authcorg.apache.shiro.web. lter.authc.FormAuthenticationFilter
authcBasicorg.apache.shiro.web. lter.authc.BasicHttpAuthenticationFilter
rolesorg.apache.shiro.web. lter.authz.RolesAuthorizationFilter
permsorg.apache.shiro.web. lter.authz.PermissionsAuthorizationFilter
userorg.apache.shiro.web. lter.authc.UserFilter
logoutorg.apache.shiro.web. lter.authc.LogoutFilter
portorg.apache.shiro.web. lter.authz.PortFilter
restorg.apache.shiro.web. lter.authz.HttpMethodPermissionFilter
sslorg.apache.shiro.web. lter.authz.SslFilter

anon: 匿名过滤器,即不需要登录即可访问;一般用于静态资源过滤;

示例 /static/**=anon

authc: 表示**需要认证(登录)**才能使用;

示例 /**=authc

roles: 角色授权过滤器,验证用户是否拥有资源角色

示例 /admin/*=roles[admin]

perms: 权限授权过滤器,验证用户是否拥有资源权限

示例 /employee/input=perms[“user:update”]

logout: 注销过滤器

示例 /logout=logout

修改登录后端控制器

注意 shiro.xml 中配置拦截路径时候,要放行 登录的路径

<!-- 拦截路径的规则 -->
<property name="filterChainDefinitions">
    <!--有顺序要求,静态资源放行要放前面-->
    <value>
        /login.do=anon
        /js/**=anon
        /css/**=anon
        /**=authc
    </value>
</property>

LoginController

不需要注销功能,也不需要 EmployeeService 里面的 login() 方法

@Controller
public class LoginController {

    @RequestMapping("/login")
    @ResponseBody
    public JsonResult login(String username,String password){
        try {
            // 1、获取subject主体
            Subject subject = SecurityUtils.getSubject();
            // 2、将前端传过来的参数封装为令牌
            UsernamePasswordToken token 
                				= new UsernamePasswordToken(username, password);
            // 3、使用 shiro 提供的api来登录
            subject.login(token);
            return new JsonResult();
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            return new JsonResult(false,"用户名不存在");
        }catch (IncorrectCredentialsException e) {
            e.printStackTrace();
            return new JsonResult(false,"密码错误");
        }
    }
}
配置自定义Realm

在安全管理器中指定我们自定义的 Realm,并且需要保证已经将 Realm 交给了 Spring 容器

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="crmRealm"/>  <!--realm中贴@Component注解-->
</bean>
查询数据库真实数据
@Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //通过token获取用户名(用户登录的时候填的)
        Object username = token.getPrincipal();
        //判断是否存在数据库
        Employee employee = employeeMapper.selectByName((String) username);
        if(employee!=null){
            //身份信息,凭证信息,当前realm的名字
            return new SimpleAuthenticationInfo(employee,employee.getPassword(),
                    this.getName());
        }
        //返回值就是查询出来的数据,若查到这个账号,就应返回该账号正确的数据,如果查不到,就返回null
        return null;
    }

有了这一步,Shiro 就会通过自定义的 Realm 进行账号校验了,到此,Shiro 的认证功能已经成功的集成到了 CRM 项目中。目前可以很方便的完成登录验证,登录拦截,注销等功能。

注销功能

shiro.xml 中的路径规则加入 /logout.do=logout 即可交给shiro来处理,我们以前写的LoginController中的logout方法可以删掉啦。

<!-- 拦截路径的规则 -->
<property name="filterChainDefinitions">
    <!--有顺序要求,静态资源放行要放前面-->
    <value>
        /login.do=anon
        /logout.do=logout
        /js/**=anon
        /css/**=anon
        /**=authc
    </value>
</property>

对前面功能的影响

获取当前登录用户信息

UserContext 工具类中获取当前登录用户信息,不再是从 session 域中进行获取

因为在 CrmRealm 类重写的方法 doGetAuthenticationInfo() 里面,返回的SimpleAuthenticationInfo对象里面绑定的身份信息,会跟subject绑定在一起,那么可以通过 subject 获取当前登录用户信息

//从shiro获取当前登录用户
public static Employee getCurrentUser() {
    return (Employee)SecurityUtils.getSubject().getPrincipal();
}
判断账户是否禁用

因为删除了原本的登录、注销功能,以及登录、权限拦截器

在账户禁用 / 恢复 功能里面,要判断账户是否可用,需要在 CrmRealm 类重写的方法 doGetAuthenticationInfo() 里面进行判断,抛出DisabledAccountException异常,是shiro提供的异常

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    String username = (String) token.getPrincipal();
    Employee employee = employeeService.selectByName(username);
    if(employee != null){
        //判断该账户是否被禁用
        //如果抛出shiro提供的异常,不会被框架封装;
        //如果抛出自定义异常,就会被shiro封装为 AuthenticationException异常
        if (!employee.isStatus()){
            throw new DisabledAccountException("账号被禁用");
        }
        return new SimpleAuthenticationInfo(employee,  //身份信息,会跟subject绑定在一起
                employee.getPassword(),
                this.getName());
    }
    return null;
}

然后在 登录的后端控制器方法里面进行捕获异常

@RequestMapping("/login")
@ResponseBody
public JsonResult login(String username,String password){
    try {
        // 1、获取subject主体
        Subject subject = SecurityUtils.getSubject();
        // 2、将前端传过来的参数封装为令牌
        UsernamePasswordToken token = new UsernamePasswordToken(username, password);
        // 3、使用 shiro 提供的api来登录
        subject.login(token);
        return new JsonResult();
    }catch
    ...
    }catch (DisabledAccountException e) {
        e.printStackTrace();
        return new JsonResult(false,e.getMessage());
    }
}

Shiro 授权

系统中的授权功能就是为用户分配相关的权限,只有当用户拥有相应的权限后,才能访问对应的资源。

如果系统中无法管理用户的权限,那么将会出现客户信息泄露,数据被恶意篡改等问题,所以在绝大多数的应用中,我们都会有权限管理模块。

前面介绍过我们的权限管理系统是基于角色的权限管理,所以在系统中应该需要下面三个子模块:

  1. 用户管理 2. 角色管理 3. 权限管理

这三个模块我们已经完成,同时可以很好的完成他们之间的关系管理

那么目前我们所需要的就是将用户拥有的权限告知 Shiro,供其在权限校验的时候使用

Shiro 权限验证分为三种方式:

  1. 编程式 通过写 if/else 授权代码块完成

    Subject subject = SecurityUtils.getSubject();
    if(subject.hasRole("admin")) {
    	//有权限
    } else {
    	//无权限
    }
    
  2. 注解式 通过在controller的方法上放置相应的注解完成

    @RequiresRoles("admin") 
    @RequiresPermissions("user:create") 
    public void hello() {
        //有权限
    }
    
  3. JSP 标签(shiro 自带) 或 freemarker 的标签 ( 第三方) 在页面通过相应的标签完成

    <@shiro.hasRole name="admin">
        <!-- 有权限 -->
    </@shiro.hasRole>
    

基于 ini 的授权

在学习授权之前,我们需要先对权限表达式的格式做一个了解

权限表达式的作用主要是用来在权限校验的时候使用,表达式中包含有当前访问资源的相关信息,应该具有唯一性

权限表达式 在 ini 文件中用户、角色、权限的配置规则是:

​ “用户名=密码,角色 1,角色 2…”

​ “角色=权限 1,权限 2…”

首先根据用户名找角色,再根据角色找权限,角色是权限集合

权限字符串也可以使用 ***** 通配符

步骤:

  1. 在 ini 文件中进行用户角色权限的关系配置

    [users]
    zhangsan=555,role1,role2 
    lisi=666,role2
    
    [roles]
    role1=user:list,user:delete 
    role2=user:update
    admin=*:*
    
  2. 执行测试代码,验证当前登录的用户是否拥有指定的权限或者角色

    首先保证登录成功,然后再观察权限和角色相关的执行结果

    //判断用户是否有某个角色
    System.out.println("role1:"+subject.hasRole("role1"));
    System.out.println("role2:"+subject.hasRole("role2"));
    
    //是否同时拥有多个角色,返回 boolean[] 数组
    System.out.println("是否同时拥有role1和role2:"+subject.hasAllRoles(Arrays.asList("role1", "role2")));
    
    //check开头的是没有返回值的,当没有权限时就会抛出异常
    subject.checkRole("hr");
    
    //判断用户是否有某个权限
    System.out.println("user:delete:"+subject.isPermitted("user:delete"));
    subject.checkPermission("user:delete");     //check开头的是没有返回值的
    

    isPermitted(String permission):是否拥有当前指定的一个权限

    hasRole(String role):是否拥有当前指定的一个角色

    hasAllRoles(Collection roles):是否拥有指定的多个角色,只有当都有的时候才返回 true,反之 false

CRM 中集成 Shiro 授权

判断角色或权限的时候,shiro会自动调用 doGetAuthorizationInfo 方法,获取用户已经拥有的数据,进行比对

实现授权的方法:查询数据库真实数据

CrmRealm 中实现授权的方法,查询当前登录用户的角色和权限信息

//提供授权信息
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal) {
    // 1、获取当前登录的员工对象,获取员工id
    Employee employee = UserContext.getCurrentUser();
    //Employee employee = (Employee)SecurityUtils.getSubject().getPrincipal();
    //Employee employee = (Employee) principal.getPrimaryPrincipal();

    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();

    // 2、查询数据库,该员工拥有的角色和权限数据
    //因为本项目中超级管理员没有任何角色和权限数据,需要判断
    if (!employee.isAdmin()){
        List<String> expressions = permissionService.selectByEmpId(employee.getId());
        List<String> roleSnList = roleService.selectSnByEmpId(employee.getId());
        // 3、设置当前登录用户拥有的角色和权限数据
        info.addStringPermissions(expressions);
        info.addRoles(roleSnList);  //放入角色的编码
    }else {
        //如果是管理员,给予所有权限
        info.addStringPermission("*:*");
        info.addRole("admin");  //给出自定义角色
    }

    return info;
}

注意: *😗 通配符表示所有权限,即不做任何限制,可以访问系统中的任意资源(超级管理员)

编程式权限验证方式(了解)

随便找个能执行到的地方,测试shiro提供的权限api是否能结合realm完成权限判断功能

@RequestMapping("/list")
public String list(Model model, QueryObject qo){
    System.out.println("当前用户是否有admin角色:"
                       + SecurityUtils.getSubject().hasRole("admin"));
     System.out.println("当前登录用户是否有employee:delete权限:"
                + SecurityUtils.getSubject().isPermitted("employee:delete"));
    model.addAttribute("pageInfo", departmentService.query(qo));
    return "department/list"; 
}
注解式权限验证方式(主要)

在RBAC中,我们采用自定义权限注解贴在需要的方法上,然后再扫描对应类的方法,获取对应的注解生成权限表达式。其中的注解是我们自定义的,很明显,Shiro 权限框架并不认识这个注解,自然也无法完成权限的校验功能

所以我们需要使用 Shiro 自身提供的一套注解来完成

步骤:

  1. 开启 Shiro 注解扫描器,在 shiro.xml 文件 中配置

    注意,初始化AOP通知器所需要的组件,因为底层也是通过切面advice实现的

    当扫描到 Controller 中有使用 @RequiresPermissions 注解时,会使用 cglib动态代理 为当前 Controller 生成代理对象,增强对应方法,进行权限校验

    因为为 controller 生成了代理对象,就影响了后面权限加载时获取 Controller 类对象

    <!-- 开启shiro注解扫描器 
    	在类中扫描到shiro提供的注解后,会对该类进行cglib方式进行代理,利用代理方式实现权限拦截功能
    -->
    <bean class="org.apache.shiro.spring.security.interceptor.
                 	AuthorizationAttributeSourceAdvisor">
        <property name="securityManager" ref="securityManager"/>
    </bean>
    <!--初始化AOP通知器所依赖的组件,配置了Advisor才会生效-->
    <aop:config/>
    
  2. 在 Controller 的方法上贴上 Shiro 提供的权限 / 角色注解(@RequiresPermissions,@RequiresRoles

    判断角色或权限时,shiro会自动调用 Realm 的 doGetAuthenticationInfo 方法,获取用户数据进行匹配

    // 多个权限之间默认是与的关系,两个权限都必须要有
    //@RequiresPermissions({"department:list","department:delete"}) 
    // 角色验证
    //@RequiresRoles("admin")
    // 为了方便获取权限的名称,可以把权限间逻辑改为 or
    @RequiresPermissions(value = {"department:list","部门列表"},logical = Logical.OR)
    @RequestMapping("/list")
    public String list(Model model, QueryObject queryObject){
        PageInfo<Department> result = service.query(queryObject);
        model.addAttribute("result",result);
        return "department/list";
    }	
    

    value 属性:可以传递多个参数,就是可以同时设置权限表达式

    logical 属性:表示value中多个参数之间的逻辑关系

    ​ 默认是要求都必须存在( Logical.AND)

    ​ 另一种是拥有其中一个即可(Logical.OR)

  3. 修改原本的权限加载业务

    有两个地方要注意:

    1. 使用了 shiro 的权限验证注解之后,从 Spring 容器中获取到的 Controller 对象就会是代理对象,该对象的类继承自原始的 Controller

      而注解是贴在原始的 Controller 类方法上的,所以此时从代理对象中是获取不到注解的,所以需要获取到代理对象的父类(原始 Controller)的字节码对象,再获取方法

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lojIfLbc-1649928393067)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220318170917850.png)]

    2. 权限表达式是直接在注解中指定的,不需要执行拼接操作,所以获取到注解 value 属性值(数组),在获取对应的表达式和名称即可

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P2yGDkEm-1649928393068)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220318170946948.png)]

    3. 整体代码

      @Autowired
      private ApplicationContext ctx;  //spring容器对象
      //需求:将每个控制器中添加了注解的方法转化为权限数据,并插入数据库
      @Override
      public void load() {
          // 定义一个方法,可以获取到数据库中所有的权限表达式(方便后面去重)
          List<String> permissions = mapper.selectAllExpression();
      
          // 1、获取所有控制器(根据注解查询)利用spring容器对象,获取controller注解的所有bean
          // Map<String, Object> map 里面的 key 为bean标签的id,就是首字母小写的类名;value 就是容器中的对象
          Map<String, Object> map = ctx.getBeansWithAnnotation(Controller.class);
          Collection<Object> beans = map.values();
      
          // 2、遍历获取每个控制器中的方法
          for (Object controller : beans) {
              //判断实例是否是Cglib的代理对象,如果不是,说明没有使用shiro的权限验证注解
              if (!AopUtils.isCglibProxy(controller)) {
                  continue;//执行下一次循环
              }
              //获取controller的字节码对象(获取该类的父类)
              Class clazz = controller.getClass().getSuperclass();
              Method[] methods = clazz.getDeclaredMethods(); //通过反射拿到里面所有的方法
              // 3、遍历获取到的每个方法
              for (Method method : methods) {
                  // 4、判断方法上是否贴有shiro权限验证注解
                  RequiresPermissions annotation = method.getAnnotation(RequiresPermissions.class);
                  //如果有贴,封装成权限对象,并插入数据库
                  if(annotation != null){
                      // 5、从注解中获取权限相关数据,并封装为权限对象
                      String name = annotation.value()[1];      //中文 权限名称
                      // 方式一:从注解中获取权限表达式
                      String expression = annotation.value()[0];     //英文 权限表达式
                      // 6、判断数据库是否已存在,若无就插入
                      //这里没有将所有关系都删除再重新插入,因为会影响id列
                      // 就会导致 role_permission 第三方表数据错误
                      //下面 permissions、expression 类型要保持一致
                      if(!permissions.contains(expression)) {
                          //封装成权限对象
                          Permission permission = new Permission();
                          permission.setName(name);
                          permission.setExpression(expression);
                          //把权限对象保存到数据库
                          mapper.insert(permission);
                      }
                  }
              }
          }
      }
      
没有权限时的异常处理

如果访问了没有权限的资源,会抛出下面的异常:

org.apache.shiro.authz.UnauthorizedException:Subject does not have permission [department:list]

因为使用了 SpringMVC 的统一异常处理,此时若没有权限,会跳转到错误页面,如此用户体验不好

所以应该根据异常类型区分开来,如果是没有权限的异常,则应该跳转到没有权限的提示页面

//捕获没有权限异常
@ExceptionHandler(UnauthorizedException.class)
public Object handlerUnauthorized(RuntimeException e, HttpServletResponse response, HandlerMethod handlerMethod) throws IOException {
    //方便开发的时候找bug
    e.printStackTrace();
    //判断是否有ResponseBody注解,如果是ajax对应的方法,就返回JsonResult,
    if(handlerMethod.hasMethodAnnotation(ResponseBody.class)){
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().print(JSON.toJSONString(
            								new JsonResult(false,"您没有权限操作!")));
        return null;
    }else{
        //如果不是,就返回错误的视图页面
        return "common/nopermission";
    }
}

shiro标签

标签式权限验证方式(主要)

在前端页面上,我们通常可以根据用户拥有的权限来显示具体的页面,如:用户拥有删除员工的权限,页面上就把删除按钮显示出来,否则就不显示删除按钮,通过这种方式来细化权限控制。

要能够实现上面的控制,需要使用 Shiro 中提供的相关标签,标签的使用步骤如下:

  1. 拓展freemarker标签

    前端页面我们选择的是freemarker,而默认 freemarker 是不支持 shiro 标签的,所以需要对其功能做拓展,可以理解为注册 shiro 的标签,达到在freemarker 页面中使用的目的

    public class MyFreeMarkerConfig extends FreeMarkerConfigurer {
        @Override
        public void afterPropertiesSet() throws IOException, TemplateException {
            //继承之前的属性配置,这不不能省
            super.afterPropertiesSet();
            Configuration cfg = this.getConfiguration();
            cfg.setSharedVariable("shiro", new ShiroTags());//注册shiro 标签
        }
    }
    
  2. 在 mvc.xml 中将 MyFreeMarkerConfig 设置成当前环境中使用的freemarker配置对象

    <!-- 注册 FreeMarker 配置类 -->
    <bean class="com.shiro.MyFreeMarkerConfig">  自定义的配置类
        <!-- 配置 FreeMarker 的文件编码 -->
        <property name="defaultEncoding" value="UTF-8" />
        <!-- 配置 FreeMarker 寻找模板的路径 -->
        <property name="templateLoaderPath" value="/WEB-INF/views/" />
    </bean>
    

    有了上面的准备工作后,我们就可以在freemarker 页面中使用 shiro 相关的标签来对页面显示做控制了

  3. 使用shiro标签

常用标签:

authenticated 标签:已认证通过的用户。

<@shiro.authenticated> </@shiro.authenticated>

notAuthenticated 标签:未认证通过的用户。与 authenticated 标签相对。

<@shiro.notAuthenticated></@shiro.notAuthenticated>

principal 标签:输出当前用户信息,通常为登录帐号信息

后台是直接将整个员工对象作为身份信息的,所以这里可以直接访问他的 name 属性得到员工的姓名

<@shiro.principal property="name" />
也可以在 js 中使用
var name = <@shiro.principal property="name" />;

对应realm中返回的SimpleAuthenticationInfo对象的第一个参数

new SimpleAuthenticationInfo(employee,employee.getPassword(),this.getName());

hasRole 标签:验证当前用户是否拥有该角色

<@shiro.hasRole name="admin">Hello admin!</@shiro.hasRole>

hasAnyRoles 标签:验证当前用户是否拥有这些角色中的任何一个,角色之间逗号分隔

<@shiro.hasAnyRoles name="admin,user,operator">Hello admin</@shiro.hasAnyRoles>

hasPermission 标签:验证当前用户是否拥有该权限

<@shiro.hasPermission name="department:delete">删除</@shiro.hasPermission>
有此权限的用户才可以看到此按钮
<@shiro.hasPermission name="employee:delete">
    <a href="#" data-id="${entity.id}" class="btn btn-danger btn-xs btn_delete">
        <span class="glyphicon glyphicon-trash"></span> 删除
    </a>
</@shiro.hasPermission>

加密功能

加密的目的是从系统数据的安全考虑,如果不加密,只要有权限查看数据库的都能够得知用户的密码,这是非常不安全的

这里我们采用的是 MD5 加密

Shiro加密工具

在 Shiro 中实现了 MD5 的算法,所以可以直接使用它来对密码进行加密

@Test
public void testMD5() throws Exception{ 
    Md5Hash hash = new Md5Hash("1");
	System.out.println(hash);//c4ca4238a0b923820dcc509a6f75849b
}

MD5 加密的数据如果一样,那么无论在什么时候加密的结果都是一样的,所以,相对来说还是不够安全,但是我们可以对数据加 “盐”。同样的数据加不同的 “盐” 之后就是千变万化的,这样得到的结果相同率也就变低了

一般选择用户的唯一的数据来作为盐(用户名,手机号,邮箱,身份证等等)

@Test
public void testMD5() throws Exception{ 
    Md5Hash hash = new Md5Hash("1","admin");
	System.out.println(hash);//e00cf25ad42683b3df678c61f42c6bda
}

Md5Hash() 构造方法中的第二个参数就是对加密数据添加的 “盐”

如果还觉得不够安全,我们还可以通过加密次数来增加 MD5 加密的安全性

@Test
public void testMD5() throws Exception{ 
    Md5Hash hash = new Md5Hash("1","admin",3);
	System.out.println(hash);//f3559efea469bd6de83d27d4284b4a7a
}

上面指定对密码进行 3 次 MD5 加密,在开发中可以根据实际情况来选择。

实现密码加密

步骤:

  1. 在添加用户的时候,需要对用户的密码进行加密

    @Transactional
    @Service
    public class EmployeeServiceImpl implements EmployeeService {
        @Autowired
        private EmployeeMapper mapper;
    
        @Override
        public void save(Employee employee, Long[] roleIds) {
            //对密码进行加密(把用户名当做盐)
            Md5Hash md5Hash = new Md5Hash(employee.getPassword(), employee.getName());
            employee.setPassword(md5Hash.toString());
            //保存之后employee就会有id
            mapper.insert(employee);
            //...
    }
    
  2. 对于加密过的密码,后台如何实现登录?有两种方式实现

    1. 方式一:在封装token令牌之前,直接对用户输入的密码进行加密,再传给shiro,拿数据进行对比

      @RequestMapping("/login")
      @ResponseBody
      public JsonResult login(String username,String password){
          try {
              // 1、获取subject主体
              Subject subject = SecurityUtils.getSubject();
              // 2、将前端传过来的参数封装为令牌
              //方式一:登录时,按照指定的方式对密码加密然后再和数据库中的加密过的密码进行匹配
              Md5Hash md5Hash = new Md5Hash(password, username);
              UsernamePasswordToken token = new UsernamePasswordToken(username, md5Hash.toString());
              // 3、使用 shiro 提供的api来登录
              subject.login(token);
              //...
      }
      
    2. 方式二:登录时不需要自己加密对比,只需要告诉shiro登录时需要加密,并告诉shiro加密的算法类型

      1. 指定当前需要使用的凭证匹配器,告诉 Shiro 我们选择使用哪一种加密算法

        因为现在登录认证时,判断密码是否正确的工作是由shiro来做的,所以我们需要告诉 Shiro 选择使用哪一种加密算法,并且这个规则也应该和添加用户时的加密规则一致

        <!--指定当前需要使用的凭证匹配器-->
        <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <!-- 指定加密算法 -->
            <property name="hashAlgorithmName" value="MD5"/>
            <!--加密次数-->
            <property name="hashIterations" value="1"/>
        </bean>
        
      2. 将容器中的配置的凭证匹配器注入给当前自定义的 Realm 对象中

        @Component
        public class CrmRealm extends AuthorizingRealm {
        
           @Autowired  //自动注入,将配置的凭证匹配器注入进来
           @Override
           public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher){
              super.setCredentialsMatcher(credentialsMatcher);
           }
           //...
        
      3. 在提供认证信息的方法中,告诉shiro我们的盐是哪个值

        //提供认证信息,返回的 Info 相当于用户的信息,被拿去验证
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // 1、获取令牌中的用户名(前端传来的,用户登录的时候填的)
            String username = (String) token.getPrincipal();
            // 2、查询数据库中是否存在
            Employee employee = employeeService.selectByName(username);
            if(employee != null){
                //判断该账户是否被禁用
                if (!employee.isStatus()){
                    throw new DisabledAccountException("账户被禁用");
                }
                //身份信息,会跟subject绑定在一起,凭证信息(正确),盐,当前realm的名字
                return new SimpleAuthenticationInfo(employee,
                        employee.getPassword(),
                        ByteSource.Util.bytes(username), //指定加密时的盐
                        this.getName()  //当前Realm数据源的名称
                );}
            return null;
        }
        
      4. 测试效果之前先把数据库中的密码改为加密后的数据

        -- 使用 MD5 函数对密码进行加密,其中 name用户名 作为盐使用
        update employee set password = MD5(concat(name,password));
        

有了以上操作之后,密码加密的功能就已经实现好了

其实就两个地方用到了 MD5 加密:

  1. 添加用户的时候,对用户的密码加密

  2. 登录时,按照指定的方式对密码加密然后再和数据库中的加密过的数据进行匹配

缓存功能

在请求中一旦需要进行权限的控制,如:

@RequiresPermissions("employee:view") //注解
<shiro:hasPermission name="employee:input"> //标签
subject.hasRole("admin") //注解

都会去调用 CrmRealm 中的 doGetAuthorizationInfo 方法获取用户的权限信息,这个授权信息是要从数据库中查询的, 如果每次授权都要去查询数据库就太频繁了,性能不好, 而且用户登陆后,授权信息一般很少变动,所以我们可以在第一次授权后就把这些授权信息存到缓存中,下一次就直接从缓存中获取,避免频繁访问数据库。

Shiro 中没有实现自己的缓存机制,只提供了一个可以支持具体缓存实现(如:Hazelcast, Ehcache, OSCache, Terracotta, Coherence, GigaSpaces, JBossCache 等)的抽象 API 接口,这样就允许 Shiro 用户根据自己的需求灵活地选择具体的 CacheManager。这里我们选择使用 EhCache。

集成EhCache

步骤:

  1. 添加缓存配置文件 (普通xml文件)

    shiro-ehcache.xml

    <ehcache>
        <defaultCache 
                maxElementsInMemory="1000"
                eternal="false"
                timeToIdleSeconds="120"
                timeToLiveSeconds="120"
                memoryStoreEvictionPolicy="LRU">
        </defaultCache>
    </ehcache>
    
  2. 在 shiro.xml 里面配置缓存管理器

    <!-- 缓存管理器 -->
    <bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
        <!-- 设置配置文件 -->
        <property name="cacheManagerConfigFile" value="classpath:shiro-ehcache.xml"/>
    </bean>
    
  3. 在安全管理器里面 引用 指定的缓存管理器

    <!-- web环境使用的安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <!-- 注册自定义数据源 -->
        <property name="realm" ref="crmRealm"/>  <!--realm中贴@Component注解-->
        <!-- 指定缓存管理器 -->
        <property name="cacheManager" ref="cacheManager"/>
    </bean>
    

配置结束,登录之后,检查多次访问需要权限控制的代码时,是否不再反复查询权限数据(是否有多次进入Realm的doGetAuthorizationInfo 方法),如果只进入一次,则代表缓存已经生效

配置属性说明

maxElementsInMemory: 缓存对象最大个数。

eternal :对象是否永久有效,一但设置了,timeout 将不起作用。

timeToIdleSeconds: 对象空闲时间,指对象在多长时间没有被访问就会失效(单位:秒)。仅当 eternal=false 对象不是永久有效时使用,可选属性,默认值是 0,也就是可闲置时间无穷大。

timeToLiveSeconds:对象存活时间,指对象从创建到失效所需要的时间(单位:秒)。仅当 eternal=false 对象不是永久有效时使用,默认是 0,也就是对象存活时间无穷大。

memoryStoreEvictionPolicy:当达到 maxElementsInMemory 限制时,Ehcache 将会根据指定的策略去清理内存。

缓存策略一般有3种:

默认LRU(最近最少使用,距离现在最久没有使用的元素将被清出缓存)

FIFO(先进先出, 如果一个数据最先进入缓存中,则应该最早淘汰掉)

LFU(较少使用,意思是一直以来最少被使用的,缓存的元素有一个hit 属性(命中率),hit 值最小的将会被清出缓存)

总结:Shiro 为我们做了哪些事情?

  1. 过滤器拦截

  2. 登录认证

  3. 系统注销

  4. 权限校验

  5. 密码加密

  6. 数据缓存

4、客户关系管理系统

数据字典模块(下拉框)

很多表中都会有一些字段是想要 用来统计分析 的,用户不能随便填的,或者想要方便操作,需要使用下拉框来选择,但若是写死在下来框中的话,会导致以后随着需求变化之后,要不断地修改修改页面代码,那么怎么办呢,那我们把这些数据存到数据库表中,页面下拉框中的数据来源于数据库中的表,若以后需求变化,只需要往这表中存入新的数据即可

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q6TpSicM-1649928393069)(F:/02-CRM/02-CRM/05_课件/image/image-20200514165113741.png)]

表设计

这些数据字典的表如何设计呢?那就是来分析我们存的数据,根据数据特点来决定表设计成怎样。

需要使用的分类其实有很多,但不可以每一种类别都创建一张表,太浪费空间

我们可以设计这两张表来存这些数据:

  • 分类表(字典目录表)systemdictionary
    • id 主键

    • title 标题

    • sn 编码

    • intro 描述

  • 分类明细表(字典明细表)systemdictionaryitem
    • id 主键

    • parent_id 外键,关联的字典目录主键

    • title 标题

    • sequence 序列

字典目录crud

使用 MyBatis 逆向工程和拷贝代码完成字典目录 CRUD 及分页查询,完成之后测试效果

逆向工程可以同时生成一个库内多个表的数据

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vnMhAcAF-1649928393069)(F:/02-CRM/02-CRM/05_课件/image/image-20200416111417190.png)]

字典明细高级查询

打开页面默认不查询字典明细,因为字典明细的数据是由很多分类组合成的,显示出来太乱,没必要查

当我们点击左边菜单,才把对应的字典目录下的明细查询出来

跟以前的高级查询效果不一样,以前是使用下拉框来查询,现在是放在页面左侧点击后进行查询的

步骤:

  1. 使用 MyBatis 逆向工程和拷贝代码,拷贝部门的 list.ftl

  2. 栅格布局,将字典目录放在左侧

    <div class="row" style="margin:20px">
        <div class="col-xs-3">
            <div class="panel panel-default">
                <div class="panel-heading">字典目录</div>
                <div class="panel-body">
                    <div class="list-group" id="dic">
                        <a href="#" class="list-group-item">目录列表1</a>
                        <a href="#" class="list-group-item">目录列表2</a>
                        <a href="#" class="list-group-item">目录列表3</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="col-xs-9">
    		<table>
                .....
            </table>
        </div>
    </div>
    
  3. 字典目录列表

    后端查询数据(不需要分页,一次查所有)

    model.addAttribute("dics",systemDictionaryService.listAll());
    

    前端展示数据

    <#list dics as dic>
        <a href="#" class="list-group-item">${dic.title!}</a>
    </#list>
    
  4. 实现点击左边的目录时,就显示对应的明细

    页面点击传递目录id到后端,后端在明细的controller里根据id返回对应明细数据

    后台封装 qo ,注意与字段名一致

    @Data 
    public class SystemDictionaryItemQuery extends QueryObject {
        private Long parentId; //字典目录id
    }
    

    修改 SQL 语句,查该字典目录下对应的字典明细

    此处 test 里面parentId应该是参数对象里面的属性名
    <select id="selectForList" resultType="com.domain.SystemDictionaryItem">
      select id, parent_id, title, sequence
      from systemdictionaryitem
      <where>
        <if test="parentId > 0">
          and parent_id = #{parentId}
        </if>
      </where>
    </select>
    

    给目录列表添加链接,跳转至查询

    <a href="/systemDictionaryItem/list.do?parentId=${dic.id}" 
       class="list-group-item">${dic.title!}</a>
    
  5. 字典目录高亮效果

    页面展示的目录明细所属的目录,就需要被高亮

    给目录列表的标签添加自定义属性,方便筛选

    <a data-id="${dic.id}" href="/systemDictionaryItem/list.do?parentId=${dic.id}" 
       class="list-group-item">${dic.title!}</a>
    

    此时页面展示的明细所属的目录id,与目录列表里面的id进行匹配,添加高亮

    这里没有回传 qo代码,但是可以使用,是因为形参使用了此注解 @ModelAttribute(“qo”)

    <div class="list-group" id="dic">
        <#list dics as dic>
            <a href="/systemDictionaryItem/list.do?parentId=${dic.id}" 
                data-id="${dic.id}" class="list-group-item">${dic.title!}</a>
        </#list>
    </div>
    
    这里最好加一个判断,判断 qo.parentId 不为空时才进行赋值
    (首次进入页面未点击左侧目录标签,则parentId为空)
    <script>
        <#if qo.parentId??>
            /*给点击的目录添加高亮*/
            $("a[data-id=${qo.parentId}]").addClass('active');
        </#if>
    </script>
    
  6. 解决分页问题,明细点击下一页会跳转出所有的明细,说明 parentId 的查询条件缺失

    因为根据目录查询,是因为左边目录列表的链接自带了 ?parentId=${dic.id}参数

    这里分页没有这个参数,(分页的当前页参数是隐藏域提供的)那么可添加 parentId 参数的隐藏域

    在高级查询表单中添加字典目录的隐藏域,每次分页都需要带上该参数

    <!--高级查询--->
    <form class="form-inline" id="searchForm" action="/systemDictionaryItem/list.do" 
          method="post">
        <input type="hidden" name="currentPage" id="currentPage" value="1">
        <!-- 解决分页查询时丢失目录条件问题 -->
        <input type="hidden" name="parentId"  value="${qo.parentId}">
    </form>
    

字典明细新增和编辑

步骤:

  1. 新增和编辑的模态框,表单添加两个关于字典目录的 input,设置为只读

    字典目录设置为只读,用户不能手动录入,应在新增时自动获取当前查询条件的字典目录并赋值

    需要回显字典目录的内容 给用户看,并向后台传递 parentId 用于增加数据

    <div class="form-group" style="margin-top: 10px;">
        <label for="name" class="col-sm-3 control-label">字典目录:</label>
        <!-- 目录标题,给用户看得 -->
        <input type="text" class="form-control" name="parentTitle" readonly>
        <!-- 目录id,提交到后台,关联数据用的 -->
        <input type="hidden"name="parentId">
    </div>
    
  2. 新增/编辑按钮点击事件

    需要回显字典目录的数据

    不能像前面部门回显编辑使用 json 属性数据,因为如果json有数据,代表是编辑。但是现在添加也需要回显

    只有获取地址栏中的 parentId ,以及左侧字典目录 高亮标签的标签名

    $(function () {
        //添加和编辑模态框
        $(".btn-input").click(function () {
            //每次点击之后清空模态框的数据
            $("#editForm input").val('')
    
            //传递字典目录id
            $("#editForm input[name=parentId]").val(${qo.parentId!})
    
        	//回显字典目录的数据
            // 1、可以在后台查询出来目录,再回显到前台
            // 2、可以像设置高亮时一样,定位到当前显示的字典目录的标签,拿取标签内的内容
            <#-- $("#editForm input[name=parentTitle]").val($("a[data-id=${qo.parentId}]").html())-->
            // 3、可以使用 class 属性定位到左侧高亮的字典目录名称。因为高亮是 class=active
            $("#editForm input[name=parentTitle]").val($("#dic .active").html())
            
            //获取事件源
            var json = $(this).data('json');
            //回显数据,使用jQuery给标签设置value属性
            if (json){   //如果json有数据,代表是编辑
                $("#editForm input[name=id]").val(json.id)
                $("#editForm input[name=title]").val(json.title)
                $("#editForm input[name=sequence]").val(json.sequence)
            }
    
            //打开模态框  .modal("show")
            //隐藏模态框  .modal("hide")
            $("#inputModal").modal('show')
        })
    	
    	//使用异步方式对新增/编辑字典明细的表单进行提交
        $("#editForm").ajaxForm(handlerMessage)
    })
    
  3. 页面默认查询所有明细,即首次进入页面时,点击编辑无法回显目录名,因为此时左边没有高亮的目录

    解决:有三种方式

    1. 打开页面默认不查询所有明细,点击左边目录后,才会显示对应的明细

    2. 打开页面默认就查询第一个目录的明细,这样左边就一直会有高亮目录

      @Data
      public class SystemDictionaryItemQuery extends QueryObject {
          private Long parentId = 1L; //字典目录id
      }
      
    3. 编辑时根据当前的 parentId ,显示目录

      //获取事件源
      var json = $(this).data('json');
      //回显数据,使用jQuery给标签设置value属性
      if (json){   //如果json有数据,代表是编辑
          $("#editForm input[name=id]").val(json.id)
          $("#editForm input[name=title]").val(json.title)
          $("#editForm input[name=sequence]").val(json.sequence)
      
          $("#editForm input[name=parentId]").val(${qo.parentId!})
          $("#editForm input[name=parentTitle]")
              .val($("a[data-id="+ json.parentId +"]").html())
      }
      
  4. 注意点

    1. 当 html 页面中使用了 $ ,注销时要使用 <!-- --> ,不能是 //

    2. 首次进入明细页面,未点击左侧目录标签时,${qo.parentId}是没有值的,页面中使用到的地方都要进行判空。或者使用 ! ,表示允许空值

      <script>
          <#if qo.parentId??>
              /*给点击的目录添加高亮*/
              $("a[data-id=${qo.parentId!}]").addClass('active');
          </#if>
      </script>
      
    3. 注意,引入外部文件的标签要放在html文件最前面

客户模型分析

使用 MyBatis 逆向工程和拷贝代码,完成表 customer 数据的生成

/**
 * 客户
 */
@Setter
@Getter
public class Customer {
    public static final int STATUS_COMMON = 0 ;//潜在客户
    public static final int STATUS_NORMAL = 1 ;//正式客户
    public static final int STATUS_FAIL = 2 ;//开发失败
    public static final int STATUS_LOST = 3 ;//流失客户
    public static final int STATUS_POOL = 4 ;//客户池

    private Long id;
    //姓名
    private String name;
    //年龄
    private Integer age;
    //性别
    private Integer gender;
    //电话
    private String tel;
    //qq
    private String qq;
    //职业,字典明细的数据
    private SystemDictionaryItem job;
    //来源,字典明细的数据
    private SystemDictionaryItem source;
    //销售人员
    private Employee seller;
    //录入人
    private Employee inputUser;
    //录入时间
    private Date inputTime;
    //状态
    private Integer status = STATUS_COMMON;
}

销售人员(seller)和录入人员(inputUser)的区别


两个字段,都是在新增客户的时候设置进去,设置为当前登录的员工

录入人设置后是不能再进行修改的,但是销售人员可以修改,经过移交操作,客户的销售人员可以改为其他人

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J0w09Jfe-1649928393070)(F:\02-CRM\02-CRM\05_课件/image/image-20200416160711618.png)]

录入时间inputTime

代表录入客户的时间,在新增的时候设置的,不能再进行修改

客户状态status

  • 潜在客户0 (还没购买产品) 添加客户时,默认设置为潜在客户的状态

  • 正式客户1 (已经购买产品)

  • 开发失败客户2 (已经没有意向购买的客户)

  • 流失客户3 (已经购买产品,但是需要退货退款)

  • 客户池状态4

客户池也叫客户公海,相当于公司的公共资源,是CRM系统中独有的为了能够帮助企业将现有的客户流转起来,最大化提升客户价值,避免客户流失的一个重要的客户管理模块

对于处理客户池的客户有两种方法

吸纳:其实就是自行领取,如果你觉得你有能力跟进该客户,可吸纳到自己名下,变公有为私有,领取的客户将会出现在自己的客户列表里面,客户池里不会再显示这条数据

移交:其实就是分配功能,是管理员或者经理才能使用的功能,可直接安排分配给指定人员进行跟进,效果和吸纳一样

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1UajtY6I-1649928393071)(F:\02-CRM\02-CRM\05_课件/image/image-20200416155436399.png)]

潜在客户管理

需求分析:

  • 打开页面,默认查询客户 状态是潜在客户 的客户,并且按照录入时间降序排序
  • 如果是普通的销售员,只可看自己的客户;经理或管理员可以看到所有客户
  • 销售员登录时,不显示销售员的查询下拉框,只有经理或管理员可以看到
  • 销售员的查询下拉框,只显示带有销售员角色的员工名
  • 添加、编辑客户时,需要自动设置录入人、销售员是当前登录用户,并设置录入时间

潜在客户高级查询

仅看潜在客户数据

步骤:

  1. 使用 MyBatis 逆向工程和拷贝代码完成潜在客户页面分页查询,完成之后测试效果。

  2. 职业,来源,销售员,需要连表查询并封装,修改 mapper.xml 文件

    只需要在 baseMap 中加入以下语句,即可实现级联,多表联查

    <association property="job" column="job_id" 
    			select="com.mapper.SystemDictionaryItemMapper.selectByPrimaryKey"/>
    <association property="source" column="source_id" 
    			select="com.mapper.SystemDictionaryItemMapper.selectByPrimaryKey"/>
    <association property="seller" column="seller_id" 
    			select="com.mapper.EmployeeMapper.selectByPrimaryKey"/>
    <association property="inputUser" column="input_user_id" 
    			select="com.mapper.EmployeeMapper.selectByPrimaryKey"/>
    
  3. 录入时间处理

    使用freemarker提供的时间格式化功能

    <#-- freemarker不能直接渲染日期对象,需要转成字符串 -->
    <td>${(entity.inputTime?string('yyyy-MM-dd'))!}</td>
    
  4. 状态处理

    不能直接显示数字,要显示中文,见名知意

    提供自定义 get 方法即可

    public String getStatusName() {
        switch (status) {
            case STATUS_NORMAL:
                return "正式客户";
            case STATUS_FAIL:
                return "开发失败客户";
            case STATUS_LOST:
                return "流失客户";
            case STATUS_POOL:
                return "客户池";
            default:
                return "潜在客户";
        }
    }
    
  5. 潜在客户页面只查询状态为潜在客户的数据

    新增客户的分页参数类

    public class CustomerQueryObject extends QueryObject {
        private Integer status;
    }
    

    修改分页查询的 SQL 语句

    <select id="selectForList" resultMap="BaseResultMap">
      select id, name, age, gender, tel, qq, job_id, source_id, seller_id, input_user_id, input_time, status
      from customer
      <where>
        <if test="status >= 0">    # 这里是参数的属性名
          and status = #{status}   # 这里前面是字段名,后面是参数的属性名
        </if>
      </where>
    </select>
    

    设置只查询潜在客户状态的数据

    //潜在客户列表
    @RequiresPermissions(value = {"customer:potentialList","潜在客户页面"},logical = Logical.OR)
    @RequestMapping("/potentialList")
    public String potentialList(Model model, @ModelAttribute("qo") CustomerQueryObject qo){
        //设置只查询潜在客户状态的数据
        qo.setStatus(Customer.STATUS_COMMON);
        
        PageInfo<Customer> pageInfo = customerService.query(qo);
        model.addAttribute("result", pageInfo);
        return "customer/potentialList";
    }
    
  6. 注意,会有一个问题

    因为此时分页的查询语句中,对于客户状态的判断是:

    <if test="status >= 0">
      and status = #{status}
    </if>
    

    当进入总客户列表时,未对 CustomerQueryObject 参数类的 status 属性赋值,则默认是 null

    MyBatis 在> < 判断时底层会默认将 null 设置为 0 ,然后进入 SQL 语句判断, 0 = 0,就会查询出潜在客户

    所以需要给 CustomerQueryObject 参数类的 status 属性一个默认值,让其不会通过 SQL 语句的判断

    @Data
    public class CustomerQueryObject extends QueryObject {
        private Integer status = -1;
    }
    
  7. 按照录入时间倒序排序

    分页插件查询的第三个参数,根据字段名排序,会添加在 SQL 语句后面,所以是字段名

    @Override
    public PageInfo<Customer> query(QueryObject qo) {
        //对下一句sql进行自动分页
        PageHelper.startPage(qo.getCurrentPage(),qo.getPageSize(),"input_time desc"); 
        List<Customer> customers = customerMapper.selectForList(qo); 
        return new PageInfo<Customer>(customers);
    }
    
数据权限过滤

如果登录的员工是管理员或者经理(admin角色或者Market_Manager角色),就可以看所有的客户;

如果只是普通的销售人员,只能看自己的客户,不能看到别人的。

步骤:

  1. 修改 CustomerQueryObject 参数类,添加销售员id属性

    @Data
    public class CustomerQueryObject extends QueryObject {
        private Integer status = -1;
        private Integer sellerId;
    }
    
  2. 修改 SQL 语句,添加根据销售员 id 查询的条件

    <select id="selectForList" resultMap="BaseResultMap">
      select id, name, age, gender, tel, qq, job_id, source_id, seller_id, input_user_id, input_time, status
      from customer
      <where>
        <if test="status >= 0">
          and status = #{status}
        </if>
        <if test="sellerId > 0">
          and seller_id = #{sellerId}
        </if>
      </where>
    </select>
    
  3. 修改后端控制器方法代码

    //潜在客户列表
    @RequiresPermissions(value = {"customer:potentialList","潜在客户页面"},logical = Logical.OR)
    @RequestMapping("/potentialList")
    public String potentialList(Model model, @ModelAttribute("qo") CustomerQueryObject qo){
        //判断subject是否包含admin、Market_Manager 角色,如果有,可以查询所有的数据
        Subject subject = SecurityUtils.getSubject();
        if(!(subject.hasRole("admin")||subject.hasRole("Market_Manager"))){
            //如果没有,就只能看自己的数据。//根据当前员工id查询所跟进的客户
            qo.setSellerId(UserContext.getCurrentUser().getId());
        }
        //设置只查询潜在客户状态的数据
        qo.setStatus(Customer.STATUS_COMMON);
        PageInfo<Customer> pageInfo = customerService.query(qo);
        model.addAttribute("result", pageInfo);
        return "customer/potentialList";
    }
    
表格显示权限过滤

步骤:

  1. 修改模态框

    注意下拉框位置

    <#-- 注意这里 name="job.id" 因为后台是 job 对象接收参数作为 id-->
    <select class="form-control" name="job.id">
        <#list jobs as j>
            <#-- option标签提交的是value属性值,若无value,提交的就是标签文本内容 -->
            <option value="${j.id}">${j.title}</option>
        </#list>
    </select>
    
    <select class="form-control" name="source.id">
        <#list sources as s>
            <#-- option标签提交的是value属性值,若无value,提交的就是标签文本内容 -->
            <option value="${s.id}">${s.title}</option>
        </#list>
    </select>
    
  2. 后台回显数据

    添加查询 字典明细 的 SQL 语句

    <select id="selectByParentSn" resultMap="BaseResultMap">
      SELECT * FROM `systemdictionaryitem` i LEFT JOIN `systemdictionary` d 
      on i.`parent_id`=d.`id` WHERE d.`sn`=#{parentSn} 
      ORDER BY i.`sequence`
    </select>
    

    后端控制器方法代码

    //查询职业、来源下拉框的数据
    model.addAttribute("jobs", systemDictionaryItemService.selectByParentSn("job"));
    model.addAttribute("sources", systemDictionaryItemService.selectByParentSn("source"));
    
  3. 新增客户时,需要自动设置录入人、销售员是当前登录用户,并设置录入时间

    修改业务方法

    @Override
    public void save(Customer customer) {
        //新增客户时需要自动设置录入人、销售员为当前登录用户,设置录入时间为当前时间
        customer.setInputUser(UserContext.getCurrentUser());
        customer.setSeller(UserContext.getCurrentUser());
        customer.setInputTime(new Date());
        customerMapper.insert(customer);
    }
    
  4. 销售人员的查询下拉框,只查询有 Market 角色或者 Market_Manager 角色的员工

    新增查询 SQL 语句,查询出拥有该角色的员工

    <select id="selectByRoleSn" resultMap="BaseResultMap">
      select DISTINCT e.id, e.name from employee e
      join employee_role er on e.id = er.employee_id
      join role r on er.role_id = r.id
      where r.sn in
      <foreach collection="array" open="(" separator="," close=")" item="item">
        #{item}
      </foreach>
    </select>
    

    查询指定角色员工的下拉框的数据

    //根据角色编码查询拥有该角色的员工 Market Market_Manager
    List<Employee> employees=employeeService.selectByRoleSn("Market","Market_Manager");
    model.addAttribute("employees",employees);
    

    根据选中的销售人员查询客户

    前面已经修改 CustomerQueryObject 参数类,添加销售员id属性;并修改了 SQL 语句

    <where>
        <if test="sellerId > -1">
            and seller_id = #{sellerId}
        </if>
    </where>
    
  5. 销售人员的查询下拉框,是提供给管理员或者经理用的,不应该给普通人员看到

    <!--管理员和经理才能看到该下拉框-->
    <@shiro.hasAnyRoles name="admin,Market_Manager">
       <div>
           ....
        </div>
    </@shiro.hasAnyRoles>
    

潜在客户新增

  • 模态框,注意处理表单的 name 属性值

    <div class="modal fade" id="editModal">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 class="modal-title inputTitle">客户编辑</h4>
                </div>
                <div class="modal-body">
                    <form class="form-horizontal" action="/customer/saveOrUpdate.do" method="post" id="editForm">
                        <input type="hidden" name="id">
                        <div class="form-group" >
                            <label  class="col-sm-3 control-label">客户名称:</label>
                            <div class="col-sm-6">
                                <input type="text" class="form-control" name="name"
                                       placeholder="请输入客户姓名"/>
                            </div>
                        </div>
                        <div class="form-group">
                            <label  class="col-sm-3 control-label">客户年龄:</label>
                            <div class="col-sm-6">
                                <input type="number" class="form-control" name="age"
                                       placeholder="请输入客户年龄"/>
                            </div>
                        </div>
                        <div class="form-group" >
                            <label  class="col-sm-3 control-label">客户性别:</label>
                            <div class="col-sm-6">
                                <select class="form-control" name="gender">
                                    <option value="1"></option>
                                    <option value="0"></option>
                                </select>
                            </div>
                        </div>
                        <div class="form-group">
                            <label  class="col-sm-3 control-label">客户电话:</label>
                            <div class="col-sm-6">
                                <input type="text" class="form-control" name="tel"
                                       placeholder="请输入客户电话"/>
                            </div>
                        </div>
                        <div class="form-group" >
                            <label  class="col-sm-3 control-label">客户QQ:</label>
                            <div class="col-sm-6">
                                <input type="text" class="form-control" name="qq"
                                       placeholder="请输入客户QQ"/>
                            </div>
                        </div>
                        <div class="form-group">
                            <label  class="col-sm-3 control-label">客户工作:</label>
                            <div class="col-sm-6">
                                <select class="form-control" name="job.id">
                                    <#list jobs as j>
                                        <option value="${j.id}">${j.title}</option>
                                        </#list>
                                </select>
                            </div>
                        </div>
                        <div class="form-group">
                            <label  class="col-sm-3 control-label">客户来源:</label>
                            <div class="col-sm-6">
                                <select class="form-control" name="source.id">
                                    <#list sources as s>
                                        <option value="${s.id}">${s.title}</option>
                                    </#list>
                                </select>
                            </div>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary btn-submit" >保存</button>
                    <button type="button" class="btn btn-default" data-dismiss="modal" >取消</button>
                </div>
            </div>
        </div>
    </div>
    
  • 每个客户都会有对应的销售人员,在录入(新增)客户时,自动设置客户的销售人员,以及录入人为当前登录员工,并自动设置录入时间为当前时间

    service

    public void save(Customer customer) {
        //获取当前登录用户
        Employee employee = UserContext.getCurrentUser();
        //设置录入人
        customer.setInputuser(employee);
        //设置销售人员
        customer.setSeller(employee);
        //设置录入时间
        customer.setInputTime(new Date());
    	//保存客户
        customerMapper.insert(customer);
    }
    

潜在客户编辑


  • 编辑回显,domain添加getJson方法,页面通过data-json绑定数据到按钮上

    domain

    public String getJson(){
        HashMap map = new HashMap();
        map.put("id",id);
        map.put("name",name);
        map.put("age",age);
        map.put("gender",gender);
        map.put("tel",tel);
        map.put("qq",qq);
        if(job!=null){
            map.put("jobId",job.getId());
        }
        if(source!=null){
            map.put("sourceId",source.getId());
        }
        if(seller!=null){
            map.put("sellerId",seller.getId());
            map.put("sellerName",seller.getName());
        }
        return JSON.toJSONString(map);
    }
    
  • 按钮添加点击事件,回显表单数据

    $(".btn-input").click(function () {
        //清除模态框的数据
        $("#editForm input,#editForm select").val('');
        var json = $(this).data("json");
        if(json){ //json有数据代表是编辑
            $("#editForm input[name=id]").val(json.id);
            $("#editForm input[name=name]").val(json.name);
            $("#editForm input[name=age]").val(json.age);
            $("#editForm input[name=qq]").val(json.qq);
            $("#editForm input[name=tel]").val(json.tel);
            
            // 注意以下三个下拉框的写法
            // 左边看模态框,右边看 domain 的 getJson 方法
            $("#editForm select[name=gender]").val(json.gender);
            $("#editForm select[name='job.id']").val(json.jobId);
            $("#editForm select[name='source.id']").val(json.sourceId);
        }
        //打开模态框
        $("#editModal").modal('show');
    })
    
  • 更新时,不需要新录入人、录入时间、销售人员、状态字段,所以将 SQL 语句中相关字段删除,避免丢失

    <update id="updateByPrimaryKey" parameterType="com.domain.Customer">
      update customer
      set name = #{name,jdbcType=VARCHAR},
        age = #{age,jdbcType=INTEGER},
        gender = #{gender,jdbcType=INTEGER},
        tel = #{tel,jdbcType=VARCHAR},
        qq = #{qq,jdbcType=VARCHAR},
        job_id = #{job.id,jdbcType=BIGINT},
        source_id = #{source.id,jdbcType=BIGINT}
      where id = #{id,jdbcType=BIGINT}
    </update>
    

5、客户关系管理系统

客户跟进功能

客户跟进历史模型

@Setter
@Getter
public class CustomerTraceHistory {
    private Long id;
    //跟进时间
    private Date traceTime;
    //跟进内容
    private String traceDetails;
    //跟进方式
    private SystemDictionaryItem traceType;
    //跟进结果
    private Integer traceResult;
    //客户
    private Customer customer;
    //录入人
    private Employee inputUser;
}

为了增加客户的成交机会,销售人员需要不断跟进客户意向,挖掘需求、解决疑虑,销售人员通过拜访客户、跟进客户来完成这些工作,但每个销售人员跟进的客户有很多,如果不及时把跟进结果记录下来,可能会遗漏或者下次进行跟进时无法知道进度如何,导致重复跟进,交接困难等问题

这个客户太久没有联系了,我现在不知道我以前是怎么和他沟通的,再拿起电话来我都不知道说什么了。

我答应过客户太多的事情,也拒绝过一些事情,现在客户要移交给其他人,我想不起来要告诉他们应该要注意什么。

因此需要对客户的所有跟进情况都要记录下来,通过这些信息,我们可以得到清晰的客户跟进轨迹和及时的下次回访提醒。

跟进时间:销售人员真正跟进客户的那个时间(有可能不是当天,有可能是之前的,由用户自己填入)

新增客户跟进历史

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cbO07KuN-1649928393072)(F:/02-CRM/02-CRM/05_课件/image/image-20200514170731252.png)]

步骤:

  1. 在潜在客户页面添加跟进按钮

    <a href="#" class="btn btn-danger btn-xs btn-trace" data-json='${entity.json}'>
        <span class="glyphicon glyphicon-phone"></span> 跟进
    </a>
    
  2. 跟进的模态框

    <!-- 跟进 模态框 -->
    <div class="modal fade" id="traceModal" tabindex="-1" role="dialog" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
                    <h4 class="modal-title">跟进</h4>
                </div>
                <form class="form-horizontal" action="/customerTraceHistory/saveOrUpdate.do" method="post" id="traceForm">
                    <div class="modal-body">
                        <#-- 注意这里 name="customer.id" 因为后台是 customer 对象接收参数作为 id-->
                        <input type="hidden" name="customer.id"/>
                        <div class="form-group" >
                            <label class="col-lg-4 control-label">客户姓名:</label>
                            <div class="col-lg-6">
                                <#-- 注意这里 name="customer.name" 因为后台是 customer 对象接收参数作为 name-->
                                <input type="text" class="form-control"  name="customer.name" readonly/>
                            </div>
                        </div>
                        <div class="form-group" >
                            <label class="col-lg-4 control-label">跟进时间:</label>
                            <div class="col-lg-6 ">
                                <input type="text" class="form-control"  name="traceTime"  placeholder="请输入跟进时间">
                            </div>
                        </div>
    
                        <div class="form-group">
                            <label class="col-lg-4 control-label">交流方式:</label>
                            <div class="col-lg-6">
                                <#-- 注意这里 name="traceType.id" 因为后台是 traceType 对象接收参数作为 id-->
                                <select class="form-control" name="traceType.id">
                                    <#list ccts as c>
                                        <option value="${c.id}">${c.title}</option>
                                    </#list>
                                </select>
                            </div>
                        </div>
                        <div class="form-group">
                            <label class="col-lg-4 control-label">跟进结果:</label>
                            <div class="col-lg-6">
                                <select class="form-control" name="traceResult">
                                    <option value="3"></option>
                                    <option value="2"></option>
                                    <option value="1"></option>
                                </select>
                            </div>
                        </div>
                        <div class="form-group" >
                            <label class="col-lg-4 control-label">跟进记录:</label>
                            <div class="col-lg-6">
                                <textarea type="text" class="form-control" name="traceDetails"
                                          placeholder="请输入跟进记录" name="remark"></textarea>
                            </div>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="submit" class="btn btn-primary trace-submit">保存</button>
                        <button type="button" class="btn btn-default" data-dismiss="modal">关闭</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
    
  3. 新增跟进历史必须要有客户 ID,不然就不知道当前是对哪个客户的跟进

    <#-- 注意这里 name="customer.id" 因为后台是 customer 对象接收参数作为 id-->
    <input type="hidden" name="customer.id"/>
    <input type="text" class="form-control"  name="customer.name" readonly/>
    
  4. 后台共享交流方式下拉框数据到model

    //交流方式下拉框数据
    List<SystemDictionaryItem> ccts = 
        	systemDictionaryItemService.selectByDicSn("communicationMethod");
    model.addAttribute("ccts",ccts);
    
  5. 跟进按钮点击事件

    1. 按钮上先绑定json数据

      data-json='${customer.json}' 
      
    2. 事件中获取数据并回显到模态框。并提交模态框中的表单

      <script>
          //跟进模态框
          $(function () {
              $(".btn-trace").click(function () {
                  var json = $(this).data('json');
                  $("#traceForm input[name='customer.id']").val(json.id)
                  $("#traceForm input[name='customer.name']").val(json.name);
                  //打开模态框  .modal("show")
                  //隐藏模态框  .modal("hide")
                  $("#traceModal").modal('show')
              })
              //使用异步方式对新增/编辑潜在客户的表单进行提交
              $("#traceForm").ajaxForm(handlerMessage)
          })
      </script>
      
  6. 日期处理

    没贴注解时会出现400状态码参数问题

    原因是前端提交的日期字符串是可以随意的,比如 2020/01/01 或 2020-01-01 这种,但后端 springmvc 并不知道你提交的属于哪一种格式,所以需要使用 @DateTimeFormat 注解,并使用 pattern 声明前端提交的格式, 这样 springmvc 就会按照 pattern 规定的格式把前端传过来的数据解析成date类型的对象

    //跟进时间
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date traceTime;
    
  7. 保存跟进历史

    自动设置录入人为当前登录用户

    @Override
    public void save(CustomerTraceHistory customerTraceHistory) {
        //设置录入人
        customerTraceHistory.setInputUser(UserContext.getCurrentUser());
        customerTraceHistoryMapper.insert(customerTraceHistory);
    }
    

前端日期插件bootstrap-datepicker

https://uxsolutions.github.io/bootstrap-datepicker/?#sandbox
https://bootstrap-datepicker.readthedocs.io/en/latest/index.html

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Q2G8hkg7-1649928393072)(F:/02-CRM/02-CRM/05_课件/image/image-20200514170159257.png)]

  1. 引入插件

    <!--引入日期插件的样式文件-->
    <link rel="stylesheet" 
          href="/js/plugins/bootstrap-datepicker/css/bootstrap-datepicker.min.css"/>
    <!--引入日期插件的js文件-->
    <script type="text/javascript" 
            src="/js/plugins/bootstrap-datepicker/js/bootstrap-datepicker.min.js"/>
    <!--引入中文国际化文件-->
    <script type="text/javascript" 
     src="/js/plugins/bootstrap-datepicker/locales/bootstrap-datepicker.zh-CN.min.js"/>
    
  2. 使用插件

    $(function () {
        //跟进时间使用日期插件
        $("input[name=traceTime]").datepicker({
            language: "zh-CN", //语言
            autoclose: true, //选择日期后自动关闭
            todayHighlight: true, //高亮今日日期
            endDate:new Date() //
        });
    })
    

客户移交功能

客户移交主要用于将离职或调岗员工的客户移交给其他人员,避免客户源的流失,或者是一些特殊原因,需要重新安排销售员 ,移交后必须要保留移交记录 ,客户是最重要的资源 ,若是以后出了问题 ,可方便查找记录 ,一般是管理员或经理才可以使用的功能

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6MX7seXu-1649928393073)(F:/02-CRM/02-CRM/05_课件/image/image-20200416160711618.png)]

每次移交操作都会做两件事:

  1. 把客户的销售人员改为新的销售人员
  2. 保存一条移交历史记录(新增移交历史)

客户移交历史模型

@Setter
@Getter
public class CustomerTransfer {
    private Long id;
    //客户
    private Customer customer;
    //操作人
    private Employee operator;
    //操作时间
    private Date operateTime;
    //旧销售人员
    private Employee oldSeller;
    //新销售人员
    private Employee newSeller;
    //移交原因
    private String reason;
}

新增客户移交历史

步骤:

  1. 潜在客户页面添加移交按钮

    <!--管理员和经理才能看到该下拉框-->
    <@shiro.hasAnyRoles name="admin,Market_Manager">
        <a href="#"  data-json='${entity.json}'
           class="btn btn-danger btn-xs btn-transfer">
            <span class="glyphicon glyphicon-phone"></span> 移交
        </a>
    </@shiro.hasAnyRoles>
    
  2. 移交功能模态框

  3. 模态框中的新销售人员下拉框

    只查询有 Market 角色或者Market_Manager 角色的员工

    <label for="sn" class="col-sm-4 control-label">新营销人员:</label>
    <div class="col-sm-8">
        <select name="newSeller.id" class="form-control">
            <#list sellers as s>
                <option value="${s.id}">${s.title}</option>
            </#list>
        </select>
    </div>
    
  4. 移交按钮点击事件

    按钮上先绑定json数据

    data-json='${entity.json}'
    

    事件中获取数据并回显到模态框

    //移交模态框
    $(function () {
        $(".btn-transfer").click(function () {
            var json = $(this).data('json');
            $("#transferForm input[name='customer.id']").val(json.id)
            $("#transferForm input[name='customer.name']").val(json.name);
            $("#transferForm input[name='oldSeller.id']").val(json.sellerId);
            $("#transferForm input[name='oldSeller.name']").val(json.sellerName);
            //打开模态框  .modal("show")
            //隐藏模态框  .modal("hide")
            $("#transferModal").modal('show')
        })
        //使用异步方式对新增/编辑潜在客户的表单进行提交
        $("#transferForm").ajaxForm(handlerMessage)
    })
    
  5. 保存移交历史

    把客户的销售人员改为新的销售人员

    <update id="updateSeller" parameterType="java.lang.Long">
      update customer
      set seller_id = #{sellerId}
      where id = #{customerId}
    </update>
    

    自动设置操作人为当前登录用户,并设置操作时间为当前时间

    @Override
    public void save(CustomerTransfer customerTransfer) {
        //更改客户表中的销售员
        customerMapper.updateSeller(customerTransfer.getCustomer().getId(),
                                    customerTransfer.getNewSeller().getId());
        //设置操作人、操作时间
        customerTransfer.setOperateTime(new Date());
        customerTransfer.setOperator(UserContext.getCurrentUser());
        customerTransferMapper.insert(customerTransfer);
    }
    

修改客户状态

实现潜在客户页面的状态修改功能

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YfaXSWfA-1649928393074)(F:/02-CRM/02-CRM/05_课件/image/image-20200416192451626.png)]

步骤:

  1. 潜在客户页面添加修改状态按钮

    <a class="btn btn-danger btn-xs statusBtn" data-json='${entity.json}'>
        <span class="glyphicon glyphicon-pencil"></span>修改状态
    </a>
    
  2. 修改状态功能模态框

    按钮上先绑定json数据

    data-json='${entity.json}'
    

    事件中获取数据并回显到模态框

    //修改状态模态框
    $(function () {
        $(".statusBtn").click(function () {
            var json = $(this).data('json');
            $("#statusForm input[name=id]").val(json.id);
            $("#statusForm input[name=name]").val(json.name);
            //打开模态框  .modal("show")
            //隐藏模态框  .modal("hide")
            $("#statusModal").modal('show')
        })
        //使用异步方式对新增/编辑潜在客户的表单进行提交
        $("#statusForm").ajaxForm(handlerMessage)
    })
    
  3. 修改后端

    update id="updateStatus">
      update customer
      set status = #{status}
      where id = #{id}
    </update>
    

    注意,前端接收参数

    //修改状态
    @RequestMapping("/updateStatus")
    @RequiresPermissions(value = {"customer:updateStatus","客户修改状态"},logical = Logical.OR)
    @ResponseBody
    public JsonResult updateStatus(Customer customer){
        customerService.updateStatus(customer.getStatus(),customer.getId());
        return new JsonResult();
    }
    

实现客户池页面,该页面只显示状态为客户池状态的客户,参考效果图实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FrtjTa2r-1649928393074)(F:/02-CRM/02-CRM/05_课件/image/image-20200416192530440.png)]

6、客户关系管理系统

报表概念

向上级报告情况的表格。简单的说:报表就是用表格、图表等格式来动态显示数据

可以用公式表示为:“报表 = 多样的格式 + 动态的数据”

说白了就是对数据库某些表的数据进行统计,以供使用者分析,制定相应的措施,比如可以统计员工的工作业绩,方便计算绩效, 或者统计客户主要来源,来源多的地方可以多投放广告等用处。

那么这功能怎么实现的呢?举个例子,就那我们要做的潜在客户报表功能为例,我们需要统计每年,每月,每日,不同营销人的潜在客户的数量,那么要实现这个功能,首要要分析统计的数据来源于什么表(客户表),接着就是使用什么样的sql可以实现这样的查询效果。

分组统计sql

根据员工姓名进行分组 , 统计潜在客户数

SELECT e.name, COUNT(c.id) FROM customer c
LEFT JOIN employee e ON c.seller_id = e.id
WHERE c.status = 0 GROUP BY e.name

根据时间进行分组 , 统计潜在客户数

# 统计按每日的潜在客户数
SELECT DATE_FORMAT(c.input_time, '%Y-%m-%d'), COUNT(c.id) FROM customer c
LEFT JOIN employee e ON c.seller_id = e.id
WHERE c.status = 0 GROUP BY DATE_FORMAT(c.input_time, '%Y-%m-%d')

# 统计按每年的潜在客户数
SELECT DATE_FORMAT(c.input_time, '%Y'), COUNT(c.id) FROM customer c
LEFT JOIN employee e ON c.seller_id = e.id
WHERE c.status = 0 GROUP BY DATE_FORMAT(c.input_time, '%Y')

表格报表

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RoOaTbYT-1649928393075)(F:/02-CRM/02-CRM/05_课件/image/image-20200514170509091.png)]

分页查询

步骤:

  1. 创建CustomerReportMapper.xml

    改造为通用sql , 并设置别名 , 使用Map对每一条结果进行封装

    注意这里要使用 ${} ,因为 #{} 会给内容加上单引号,这里不是为了防止SQL注入问题,不需要#{}。如果加上了单引号,会导致查询的内容出问题

    <?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="com.mapper.CustomerReportMapper">
        <select id="selectCustomerReport" resultType="java.util.HashMap">
          select ${groupType} groupType,count(c.id) number from customer c
              left join employee e on c.seller_id = e.id
              where c.status = 0
             GROUP BY ${groupType}
      </select>
    </mapper>
    
  2. 创建Mapper接口

    public interface CustomerReportMapper {
        //查询结果使用 map 封装,字段名为 key,值为 value
        List<HashMap> selectCustomerReport(CustomerReportQuery qo);
    }
    
  3. 创建报表查询对象

    public class CustomerReportQuery extends QueryObject {
        private String groupType = "e.name";//默认按照员工姓名分组
    }
    
  4. 创建报表service

    @Transactional
    @Service
    public class CustomerReportServiceImpl implements CustomerReportService {
        @Autowired
        private CustomerReportMapper customerReportMapper;
    
        public PageInfo selectCustomerReport(CustomerReportQuery qo) {
            PageHelper.startPage(qo.getCurrentPage(),qo.getPageSize());
            List<HashMap> list = customerReportMapper.selectCustomerReport(qo);
            return new PageInfo(list);
        }
    }
    
  5. 创建报表controller

    @Controller
    @RequestMapping("/customerReport")
    public class CustomerReportController {
        @Autowired
        private CustomerReportService customerReportService;
    
        @RequestMapping("/list")
        public String list(Model model, @ModelAttribute("qo") CustomerReportQuery qo){
            model.addAttribute("result",customerReportService.selectCustomerReport(qo));
            return "customerReport/list";
        }
    }
    
  6. 客户报表页面

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>潜在客户报表查询</title>
        <#include "../common/link.ftl" >
    
    </head>
    <body class="hold-transition skin-blue sidebar-mini">
    <div class="wrapper">
        <#include "../common/navbar.ftl" >
        <#assign currentMenu="customerReport"/>
        <#include "../common/menu.ftl" >
        <div class="content-wrapper">
            <section class="content-header">
                <h1>潜在客户报表查询</h1>
            </section>
            <section class="content">
                <div class="box">
                    <div style="margin: 10px;">
                        <!--高级查询--->
                        <form class="form-inline" id="searchForm" action="/customerReport/list.do" method="post">
                            <input type="hidden" name="currentPage" id="currentPage" value="1">
                            <div class="form-group">
                                <label for="keyword">员工姓名:</label>
                                <input type="text" class="form-control" id="keyword" name="keyword" value="${qo.keyword}">
                            </div>
                            <div class="form-group">
                                <label>时间段查询:</label>
                                <div class="input-daterange input-group" id="datepicker">
                                    <input type="text" class="input-sm form-control" name="beginDate"
                                           value="${(qo.beginDate?string('yyyy-MM-dd'))!}" />
                                    <span class="input-group-addon">to</span>
                                    <input type="text" class="input-sm form-control" name="endDate"
                                           value="${(qo.endDate?string('yyyy-MM-dd'))!}" />
                                </div>
                                <script>
                                    $('.input-daterange').datepicker({
                                        language: "zh-CN",
                                        autoclose: true,
                                        todayHighlight: true,
                                        clearBtn: true
                                    });
                                </script>
                            </div>
                            <div class="form-group">
                                <label for="status">分组类型:</label>
                                <select class="form-control" id="groupType" name="groupType">
                                    <option value="e.name">员工</option>
                                    <option value="DATE_FORMAT(c.input_time, '%Y')"></option>
                                    <option value="DATE_FORMAT(c.input_time, '%Y-%m')"></option>
                                    <option value="DATE_FORMAT(c.input_time, '%Y-%m-%d')"></option>
                                </select>
                                <script>
                                    $("#groupType").val("${qo.groupType!}")
                                </script>
                            </div>
                            <button id="btn_query" class="btn btn-primary"><span class="glyphicon glyphicon-search"></span> 查询</button>
                        </form>
                    </div>
                    <div class="box-body table-responsive no-padding ">
                        <table class="table table-hover table-bordered">
                            <tr>
                                <th>分组类型</th>
                                <th>潜在客户新增数</th>
                            </tr>
                            <#list result.list as entity>
                                <tr>
                                    <#--根据map中的key取值,key是字段名-->
                                    <td>${entity.groupType}</td>
                                    <td>${(entity.number)!}</td>
                                </tr>
                            </#list>
                        </table>
                        <#include "../common/page.ftl">
                    </div>
                </div>
            </section>
        </div>
        <#include "../common/footer.ftl">
    </div>
    </body>
    </html>
    

高级查询

步骤:

  1. 日期范围选择

    https://uxsolutions.github.io/bootstrap-datepicker

    <div class="form-group">
        <label>时间段查询:</label>
        <div class="input-daterange input-group" id="datepicker">
            <input type="text" class="input-sm form-control" name="beginDate"
                   value="${(qo.beginDate?string('yyyy-MM-dd'))!}" />
            <span class="input-group-addon">to</span>
            <input type="text" class="input-sm form-control" name="endDate"
                   value="${(qo.endDate?string('yyyy-MM-dd'))!}" />
        </div>
        <script>
            $('.input-daterange').datepicker({
                language: "zh-CN",
                autoclose: true,
                todayHighlight: true,
                clearBtn: true
            });
        </script>
    </div>
    
  2. 分组类型回显

    注意,这里回显需要加上双引号。因为回显的 value 是 e.name,如果没有双引号,就会识别到e字符串,然后报错,因为获取不到 e.

    使用双引号而不是单引号是因为 年月日 里面使用了单引号

    <script>
        $("#groupType").val("${qo.groupType!}")
    </script>
    
  3. 报表查询对象

    **注意:**接收日期参数需要注解 @DateTimeFormat(pattern = “yyyy-MM-dd”)

    没贴注解时会出现400状态码参数问题

    原因是前端提交的日期字符串是可以随意的,比如 2020/01/01 或 2020-01-01 这种,但后端 springmvc 并不知道你提交的属于哪一种格式,所以需要使用 @DateTimeFormat 注解,并使用 pattern 声明前端提交的格式, 这样 springmvc 就会按照 pattern 规定的格式把前端传过来的数据解析成date类型的对象

    @Data
    public class CustomerReportQuery extends QueryObject {
        //分组类型
        private String groupType = "e.name";//默认按照员工姓名分组
        //查询关键字
        private String keyword;
        //开始时间
        @DateTimeFormat(pattern = "yyyy-MM-dd")
        private Date beginDate;
        //结束时间
        @DateTimeFormat(pattern = "yyyy-MM-dd")
        private Date endDate;
    
        public Date getEndDate() { // 获取结束时间当天最晚的时候 xxxx 23:59:59
            return DateUtil.getEndDate(endDate);
        }
    }
    
  4. 结束时间时分秒处理

    注意: 前端日期插件因为未指定时分秒,就会默认是 00:00 就会导致筛选不出结束时间当天的数据

    public abstract class DateUtil {
        //获取该日期的当天最晚的时候 xxxx 23:59:59
        public static Date getEndDate(Date date) {
            if (date == null) {
                return null;
            }
            Calendar c = Calendar.getInstance();
            c.setTime(date);
            c.set(Calendar.HOUR,23);
            c.set(Calendar.MINUTE,59);
            c.set(Calendar.SECOND,59);
            return c.getTime();
        }
    }
    
  5. sql 动态查询条件

    <select id="selectCustomerReport" resultType="java.util.HashMap">
        select ${groupType} groupType,count(c.id) number from customer c
            left join employee e on c.seller_id = e.id
              <where>
                  c.status = 0
                  <if test="keyword != null">
                      and e.name like concat('%', #{keyword}, '%')
                  </if>
                  <if test="beginDate!=null">
                      and c.input_time &gt;= #{beginDate}
                  </if>
                  <if test="endDate!=null">
                      and c.input_time &lt;= #{endDate}
                  </if>
              </where>
             GROUP BY ${groupType}
    </select>
    

图形报表

ECharts可视化图表插件

ECharts是一款由百度前端技术部开发的 , 开源免费的 , 基于Javascript, 可以流畅的运行在PC和移动设备上,兼容当前绝大部分浏览器,提供直观,生动,可交互,可高度个性化定制的数据可视化图表。

柱状图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j4n3knLX-1649928393075)(F:/02-CRM/02-CRM/05_课件/image/image-20200514170539439.png)]

步骤:

  1. 引入插件

    <#--引入echarts.js-->
    <script src="/js/plugins/echarts/echarts.common.min.js"></script>
    
  2. 图表的配置项和数据

    <body>
        <#--为ECharts准备一个具备宽高的 dom-->
        <div id="main" style="width: 600px;height: 400px;"></div>
        <script>
            //基于准备好的 都没,初始化echarts示例
            var myChart = echarts.init(document.getElementById('main'))
            //指定图标的配置项和数据
            var option = {
                title: {
                    text: '潜在客户报表',
                    subtext: "分组类型:${groupTypeName}"
                },
                tooltip: {
                    trigger: 'axis'
                },
                legend: {
                    data: ['潜在客户数']
                },
                toolbox: {
                    show: true,
                    feature: {
                        dataView: {show: true, readOnly: false},
                        magicType: {show: true, type: ['line', 'bar']},
                        restore: {show: true},
                        saveAsImage: {show: true}
                    }
                },
                calculable: true,
                xAxis: [
                    {
                        type: 'category',
                        data: ${xList}
                        /*['孙总','钱二明',"赵一明","王五"]*/
                    }
                ],
                yAxis: [
                    {
                        type: 'value'
                    }
                ],
                series: [
                    {
                        name: '潜在客户数',
                        type: 'bar',
                        data: ${yList},  //[8,15,30,3]
                        markPoint: {
                            data: [
                                {type: 'max', name: '最大值'},
                                {type: 'min', name: '最小值'}
                            ]
                        },
                        markLine: {
                            data: [
                                {type: 'average', name: '平均值'}
                            ]
                        }
                    }
                ]
            };
            //使用刚指定的配置项和数据显示图表
            myChart.setOption(option);
        </script>
    </body>
    
  3. 把分组类型转换为中文

    public abstract class MessageUtil {
        public static String changMsg(CustomerReportQuery qo) {
            String msg = null;
            switch (qo.getGroupType()) {
                case "DATE_FORMAT(c.input_time, '%Y')":
                    msg = "年份";
                    break;
                case "DATE_FORMAT(c.input_time, '%Y-%m')":
                    msg = "月份";
                    break;
                case "DATE_FORMAT(c.input_time, '%Y-%m-%d')":
                    msg = "日期";
                    break;
                default:
                    msg = "员工";
            }
            return msg;
        }
    }
    
  4. 后台处理

    对于图形的报表不需要分页,就是需要对所有的数据进行图形显示

    @Override
    public List<HashMap> listAll(QueryObject qo) {
        //不进行分页,但是还需要进行高级查询
        List<HashMap> list = customerReportMapper.selectCustomerReport(qo);
        return list;
    }
    

    将查询得到的数据,转化为前端需要的数据

    //柱状图形式
    @RequestMapping("/listByBar")
    public String listByBar(Model model, @ModelAttribute("qo") CustomerReportQuery qo){
        //获取所有数据(不分页)
        List<HashMap> list = customerReportService.listAll(qo);
        //提供一个集合存储x轴的数据
        ArrayList xList = new ArrayList();
        //提供一个集合存储y轴的数据
        ArrayList yList = new ArrayList();
        //需要把数据转换为echart需要结构 x和y轴的数据要分开
        for (Map map : list) {
            xList.add(map.get("groupType"));
            yList.add(map.get("number"));
        }
        //共享到页面(freemarker不能直接显示非字符串的数据(集合,时间))
        System.out.println(JSON.toJSONString(xList));
        model.addAttribute("xList", JSON.toJSONString(xList));
        model.addAttribute("yList",JSON.toJSONString(yList));
        //分组类型转为文字显示
        model.addAttribute("groupTypeName", MessageUtil.changMsg(qo));
        return "customerReport/listByBar";
    }
    

饼状图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Evciuj05-1649928393076)(F:/02-CRM/02-CRM/05_课件/image/image-20200514170559112.png)]

步骤:

  1. 前端页面

    图表的配置项和数据

    <body>
        <#--为ECharts准备一个具备宽高的 dom-->
        <div id="main" style="width: 600px;height: 400px;"></div>
        <script>
            //基于准备好的 都没,初始化echarts示例
            var myChart = echarts.init(document.getElementById('main'))
            //指定图标的配置项和数据
            var option = {
                title: {
                    text: '潜在客户报表',
                    subtext: '分组类型:${groupTypeName}',
                    left: 'center'
                },
                tooltip: {
                    trigger: 'item',
                    formatter: '{a} <br/>{b} : {c} ({d}%)'
                },
                legend: {
                    orient: 'vertical',
                    left: 'left',
                    data: ${groupTypeValues} //['孙总','钱二明',"赵一明","王五"]
                },
                series: [
                    {
                        name: '潜在客户数',
                        type: 'pie',
                        radius: '55%',
                        center: ['50%', '60%'],
                        data: ${data},//[{value: 335, name: '孙总'}]
                        emphasis: {
                            itemStyle: {
                                shadowBlur: 10,
                                shadowOffsetX: 0,
                                shadowColor: 'rgba(0, 0, 0, 0.5)'
                            }
                        }
                    }
                ]
            };
            //使用刚指定的配置项和数据显示图表
            myChart.setOption(option);
        </script>
    </body>
    
  2. 后台处理

    //饼状图显示
    @RequestMapping("/listByPie")
    public String listByPie(Model model, @ModelAttribute("qo") CustomerReportQuery qo){
        //获取所有数据(不分页)
        List<HashMap> list = customerReportService.listAll(qo);
        //提供一个集合存储分组类型的数据
        ArrayList groupTypeValues = new ArrayList();
        //提供一个集合存储饼图的数据
        ArrayList data = new ArrayList();
        //需要把数据转换为echart需要结构
        for (Map map : list) {
            groupTypeValues.add(map.get("groupType"));
            HashMap<String, Object> temp = new HashMap<>();
            temp.put("name",map.get("groupType"));
            temp.put("value",map.get("number"));
            data.add(temp); // {value: 335, name: '孙总'}
        }
        //共享到页面(freemarker不能直接显示非字符串的数据(集合,时间))
        model.addAttribute("groupTypeValues", JSON.toJSONString(groupTypeValues));
        model.addAttribute("data",JSON.toJSONString(data));
        //分组类型转为文字显示
        model.addAttribute("groupTypeName", MessageUtil.changMsg(qo));
        return "customerReport/listByPie";
    }
    

模态框展示

步骤:

  1. 准备按钮

    <button type="button" class="btn btn-info btn-chart" 
            data-url="/customerReport/listByBar.do">
        <span class="glyphicon glyphicon-stats"></span> 柱状图
    </button>
    <button type="button" class="btn btn-warning btn-chart"  
            data-url="/customerReport/listByPie.do">
        <span class="glyphicon glyphicon-dashboard"></span> 饼状图
    </button>
    
  2. 准备模态框

    模态框不需要任何内容,内容从其他页面获取

    <!-- Modal模态框 -->
    <div class="modal fade" id="chartModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
            </div>
        </div>
    </div>
    
  3. 点击事件

    注意: 因为有缓存,所以需要先清空模态框的缓存

    <script>
        $(".btn-chart").click(function () {
            //清空模态框的缓存
            $('#chartModal').removeData('bs.modal');
            //获取url地址
            var url = $(this).data('url');
            //告诉模态框图形报表url是哪个,加载内容并且放到模态框
            $('#chartModal').modal({
                //remote属性:如果提供的是url,将利用load方法从此地址加载要展示的内容并插入
                //注意,只加载一次。即是会有缓存
                remote : url + "?" + $("#searchForm").serialize() //加上高级查询的条件
            })
            $("#chartModal").modal('show');
        })
    </script>
    

7、mybatis日志

# Global logging configuration
log4j.rootLogger=ERROR, stdout
# MyBatis logging configuration...
# 对mapper接口所在的包开启日志功能
log4j.logger.com.mapper=TRACE
# Console output...
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%5p [%t] - %m%n
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
f.write('stride=1\n') f.write('pad=1\n') f.write('activation=leaky\n') f.write('\n') f.write('[connected]\n') f.write('size=13\n') f.write('activation=linear\n基于JavaEE+SSM综合案例选题意义: 1. 实践性强:JavaEE+SSM') f.write('\n') f.write('[region]\n') f.write('anchors = 0.5,0.5是目前流行的Web开发技术框架,通过实践综合案例,可以加深对这些技术的理解和应用。 2. 综合性强:JavaEE+SSM涵盖了前后端开发的, 1.0,1.0, 2.0,2.0, 3.0,3.0, 4.0,4.0\n') f.write('bias_match=1\n') f.write('classes={方方面面,例如前端页面设计、后端逻辑编写、数据存储和管理等,综合性极}\n'.format(num_classes)) f.write('coords=4\n') f.write('num=5\n') f.write('强。 3. 实用性强:综合案例往往是针对实际应用场景而设计的,因softmax=1\n') f.write('jitter=.2\n') f.write('rescore=1\n') f.write('此可以更好地贴近实际开发需求,提高学习者的实际应用能力。 4.object_scale=5\n') f.write('noobject_scale=1\n') f.write('class_scale=1\n') f 培养团队协作能力:JavaEE+SSM综合案例通常需要多人协作完成,可以培.write('coord_scale=1\n') f.write('thresh=.6\n') f.write('random=1\n') f.write('\n') f.write('[yolo]\n') f.write('mask = 0,1,2\n') f.write养学习者的团队协作能力和项目管理能力。 5. 提高就业竞争力:JavaEE('anchors = 0.5,0.5, 1.0,1.0, 2.0,2+SSM是目前市场上比较流行的技术框架,通过完成综合案例可以提升学习者的就业竞争力。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值