介绍
本项目仓库地址hr-back
对应的前端总结——微型人事管理项目前端总结
本项目参考本文参考自江南一点雨的Spring Boot+Vue系列视频教程第 16 章,详情参加【Spring Boot+Vue系列视频教程】,在教程基础上加入了操作日志管理模块,添加了缓存更新功能,修改了用户头像的存储策略,下面是对本项目后端技术点的总结。
一、后端技术点
SpringBoot
本项目基于SpringBoot构建,使用SpringBoot能够快速构建项目,轻松集成主流开发框架
MyBatis
在hrserver/hr-mapper/pom.xml
中引入依赖
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
MyBatis在本项目中的常见操作罗列
1、增加
以EmployeeMapper
中的insert(Employee record)
和insertSelective(Employee record)
为例,对应的xml配置如下:
insert:
<insert id="insert" parameterType="org.tgh.hr.model.Employee">
insert into employee (
<include refid="Base_Column_List"/>
)
values (#{id,jdbcType=INTEGER}, #{name,jdbcType=VARCHAR}, #{gender,jdbcType=CHAR},
#{birthday,jdbcType=DATE}, #{idCard,jdbcType=CHAR}, #{wedlock,jdbcType=CHAR}, #{nationId,jdbcType=INTEGER},
#{nativePlace,jdbcType=VARCHAR}, #{politicId,jdbcType=INTEGER}, #{email,jdbcType=VARCHAR},
#{phone,jdbcType=VARCHAR}, #{address,jdbcType=VARCHAR}, #{departmentId,jdbcType=INTEGER},
#{jobLevelId,jdbcType=INTEGER}, #{posId,jdbcType=INTEGER}, #{engageForm,jdbcType=VARCHAR},
#{tiptopDegree,jdbcType=CHAR}, #{specialty,jdbcType=VARCHAR}, #{school,jdbcType=VARCHAR},
#{beginDate,jdbcType=DATE}, #{workState,jdbcType=CHAR}, #{workID,jdbcType=CHAR},
#{contractTerm,jdbcType=DOUBLE}, #{conversionTime,jdbcType=DATE}, #{notworkDate,jdbcType=DATE},
#{beginContract,jdbcType=DATE}, #{endContract,jdbcType=DATE}, #{workAge,jdbcType=INTEGER}
)
</insert>
insertSelective:
<insert id="insertSelective" parameterType="org.tgh.hr.model.Employee" useGeneratedKeys="true"
keyProperty="id">
insert into employee
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="id != null">
id,
</if>
<if test="name != null">
name,
</if>
...略
<if test="workAge != null">
work_age,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="id != null">
#{id,jdbcType=INTEGER},
</if>
<if test="name != null">
#{name,jdbcType=VARCHAR},
</if>
...略
<if test="workAge != null">
#{workAge,jdbcType=INTEGER},
</if>
</trim>
</insert>
这两个方法是由逆向工程生成,关于逆向工程,可以访问 三:使用MyBatis Generator自动生成 ,它们的区别:对于insert,不论设置了几个字段,所有的字段都要添加一遍;对于insertSelective,则只添加有值的字段,基于动态sql
中的if
和trim
实现,实际效果没有差别,在本项目中使用insertSelective
。
2、删除
以系统设置/基础信息设置/职位管理页面为例,在设计上提供了单个删除与批量删除功能。
在Dao层对应PositionMapper
中的deleteByPrimaryKey
以及deletePositionByIds
方法。
2.1 删除单个元素
对应的xml配置:
<delete id="deleteByPrimaryKey" parameterType="java.lang.Integer">
delete from position
where id = #{id,jdbcType=INTEGER}
</delete>
通过主键id来进行删除,没有太多可说明的地方。
2.2 删除多个元素
对应的mapper:
Integer deletePositionByIds(@Param("ids") Integer[] ids);
对应的xml配置:
<delete id="deletePositionByIds">
delete from position where id in
<foreach collection="ids" item="id" separator="," open="(" close=")">
#{id}
</foreach>
</delete>
其中foreach
标签用来遍历数组或集合或者批量操作语句:
属性 | 描述 |
---|---|
collection | 表示遍历集合的名称,此处用@Param注解指定为ids |
item | 表示本次遍历获取到元素 |
separator | 设置分隔符,sql语句拼接用 |
open | 表示该语句以什么开始,此处用’(’ |
close | 表示该语句以什么结束,此处用’)’ |
以删除测试职位③与测试职位②为例,对应的sql语句执行如下:
JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl@69e1e11b] will not be managed by Spring
==> Preparing: delete from position where id in ( ? , ? )
==> Parameters: 37(Integer), 38(Integer)
<== Updates: 2
3、修改
以修改职位管理中的元素为例,PositionMapper中提供updateByPrimaryKeySelective
和updateByPrimaryKey
两种方式修改,本项目中使用updateByPrimaryKeySelective
,该方法只有当属性不为null
时才会覆盖旧属性(有时候咱们只需要更新部分字段,若使用updateByPrimaryKey,则未更新的字段会被设置为null)
<update id="updateByPrimaryKeySelective" parameterType="org.tgh.hr.model.Position">
update position
<set>
<if test="name != null">
name = #{name,jdbcType=VARCHAR},
</if>
<if test="createDate != null">
create_date = #{createDate,jdbcType=TIMESTAMP},
</if>
<if test="enabled != null">
enabled = #{enabled,jdbcType=BIT},
</if>
</set>
where id = #{id,jdbcType=INTEGER}
</update>
有选择性地更新基于set
和if
标签来实现
4、查询
4.1 一对一查询
在EmployeeMapper中定义的getEmployeeById方法,作用是根据员工id,查询员工信息,员工表与多个表存在一对一关系,对应的xml配置如下:
<resultMap id="AllEmployeeInfo" type="org.tgh.hr.model.Employee" extends="BaseResultMap">
<!-- 有比较多的一对一关系-->
<association property="nation" javaType="org.tgh.hr.model.Nation">
<id column="n_id" property="id"/>
<result column="n_name" property="name"/>
</association>
<association property="politicsstatus" javaType="org.tgh.hr.model.Politicsstatus">
<id column="p_id" property="id"/>
<result column="p_name" property="name"/>
</association>
<association property="department" javaType="org.tgh.hr.model.Department">
<id column="d_id" property="id"/>
<result column="d_name" property="name"/>
</association>
<association property="jobLevel" javaType="org.tgh.hr.model.JobLevel">
<id column="j_id" property="id"/>
<result column="j_name" property="name"/>
</association>
<association property="position" javaType="org.tgh.hr.model.Position">
<id column="pos_id" property="id"/>
<result column="pos_name" property="name"/>
</association>
</resultMap>
<select id="getEmployeeById" resultMap="AllEmployeeInfo">
SELECT e.*,p.id as p_id,p.`name`as p_name,
n.id as n_id,n.name as n_name,
d.id as d_id,d.`name` as d_name,
j.id as j_id,j.name as j_name,
pos.id as pos_id,pos.name as pos_name FROM employee e,nation n,politics_status p,department d,job_level j,position
pos
where e.nation_id = n.id
and e.politic_id = p.id
and e.department_id = d.id
and e.job_level_id = j.id
and e.pos_id = pos.id
and e.id = #{id}
</select>
定义resultMap
,id设置为AllEmployeeInfo
,其中BaseResultMap
包含employee表与实体类Employee的的映射信息,定义在EmployeeMapper.xml
开始位置。可以用extends继承过来,一对一对映射关系即可,
resultMap中用association
节点用来描述一对一的关系,JavaType
是用来指定实体类中属性的类型,property
指定实体类中属性的对象名称。
4.2 一对多查询
在MenuMapper中定义的getAllMenusByRole方法,作用是查询访问资源与角色的关系,查询结果是一个资源对应多个角色的的一对多形式,对应的xml配置如下:
<resultMap id="MenuWithRole" type="org.tgh.hr.model.Menu" extends="BaseResultMap">
<collection property="roles" ofType="org.tgh.hr.model.Role">
<id column="rid" property="id"/>
<result column="r_name" property="name"/>
<result column="r_name_zh" property="nameZh"/>
</collection>
</resultMap>
<select id="getAllMenusByRole" resultMap="MenuWithRole">
select m.*,r.id as r_id, r.name as r_name, r.name_zh as r_name_zh from menu m, menu_role mr,role r where m.id = mr.mid and mr.rid = r.id
order by m.id;
</select>
定义resultMap
,id设置为MenuWithRole
,其中BaseResultMap
包含menu表与实体类Menu的的映射信息,定义在MenuMapper.xml
开始位置。可以用extends继承过来,此处添加role表的映射关系即可,resultMap
中,通过collection
节点来描述集合的映射关系,ofType
指定的是映射到集合属性中实体类的类型,property
指定实体类中属性的对象名称。
定义测试类,测试该方法的部分返回结果如下:
SpringSecurity
在本项目中,主要用SpringSecurity框架实现认证、授权功能。
1、用户权限管理数据库
涉及到五张表:
五张表的说明如下:
一、menu
(资源表),该表中的url是后续做路径匹配用到,由于本项目前端基于vue构建,vue中使用Vue-Router管理路由,path、component、iconCls、keepAlive、requireAuth等字段是为了与Vue-Router向匹配而设置。
二、menu_role
(资源角色表),是一张中间表,通过外键mid
与rid
分别关联menu
和role
的id
。
三、role
(角色表),name字段表示角色英文名,name_zh字段表示角色英文名,其中name字段都是以ROLE_
开始是为了遵循SpringSecurity规范。
四、hr_role
(用户角色表),同样是一张中间表,通过外键rid
与hrid
分别与关联role
和hr
的id
。
五、hr
(用户表),存放用户基本信息。
2、SpringSecurity配置
2.1 配置Hr和HrService
- 创建用户表
hr
对应的实体类,将它实现UserDetails
接口
public class Hr implements UserDetails{
...
}
UserDetails接口内部有七个方法需要实现,分别是:
public interface UserDetails extends Serializable {
//返回当前用户权限
Collection<? extends GrantedAuthority> getAuthorities();
//返回数据库中当前用户对应的密码
String getPassword();
//返回数据库中当前用户用于验证的用户名
String getUsername();
//表示当前用户是否过期 返回true表示没有过期 返回false表示过期了
boolean isAccountNonExpired();
//表示当前用户是否被锁定 返回true表示没有被锁定 返回false表示被锁定了
boolean isAccountNonLocked();
//表示当前用户是否过期 返回true表示没有过期 返回false表示过期了
boolean isCredentialsNonExpired();
//表示当前用户是否被禁用 返回ture表示没有被禁用 返回false表示被禁用了
boolean isEnabled();
}
本项目业务逻辑只涉及到账户是否被禁用,于是Hr中重写的isAccountNonExpired、isAccountNonLocked、isCredentialsNonExpired等方法默认返回true,isEnabled返回咱们定义的enabled。此外Hr重写getAuthorities()方法,该方法返回当前用户所具备的角色,注意返回的是一个List集合,因为一个用户可能具备多个角色。
- 创建HrService,实现
UserDetailsService
接口中的loadUserByUsername
方法,用于在登录过程中从数据库中根据用户名查找出用户,若该用户存在,则根据他的id查找该用户具有的角色,给用户设置上,返回用户。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Hr hr = hrMapper.loadUserByUsername(username);
if (hr == null) {
throw new UsernameNotFoundException("用户名不存在");
}
hr.setRoles(hrMapper.getHrRolesById(hr.getId()));
return hr;
}
2.2 自定义CustomFilterInvocationData
该类继承自FilterInvocationSecurityMetadataSource
,重写了里面的getAttributes
方法,该方法的作用是根据当前的请求地址,获取访问该地址需要的用户角色,注意返回的是一个角色集合(一个页面可以被多种角色访问)。
@Autowired
MenuService menuService;
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
String requestUrl = ((FilterInvocation) object).getRequestUrl();
List<Menu> menus = menuService.getAllMenusByRole();
for (Menu menu : menus) {
if(antPathMatcher.match(menu.getUrl(),requestUrl)) {
List<Role> roles = menu.getRoles();
String[] str = new String[roles.size()];
for (int i = 0; i < roles.size(); i++) {
str[i] = roles.get(i).getName();
}
return SecurityConfig.createList(str);
}
}
//当前访问路径与menu的url没有匹配上,则设置登录之后访问,给一个标签 ROLE_LOGIN
return SecurityConfig.createList("ROLE_LOGIN");
}
2.3 自定义CustomUrlDesicionManager
该类继承自AccessDecisionManager
,重写了里面的decide
方法。
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection)
throws AccessDeniedException, InsufficientAuthenticationException {
for (ConfigAttribute attribute : collection) {
String needRole = attribute.getAttribute();
if("ROLE_LOGIN".equals(needRole)) {
//如果当前用户是匿名用户的一个实例,说明没有登录
if(authentication instanceof AnonymousAuthenticationToken) {
throw new AccessDeniedException("尚未登录,请登录!");
} else {
return;
}
}
//获取当前登录用户角色 authorities 是否包含 collection的任意一项
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
//遍历当前用户的所有角色,如果有角色等于访问当前资源需要的角色,就通过
for (GrantedAuthority authority : authorities) {
if(authority.getAuthority().equals(needRole)) {
return;
}
}
}
throw new AccessDeniedException("权限不足,请联系管理员!");
}
decide
方法中,参数一保存当前登录用户角色信息(可以从中获取他所具有的角色),参数三表示访问当前资源需要的角色(从CustomFilterInvocationData的getAttributes方法传过来)。
2.4 配置SecurityConfig
定义SecurityConfig
继承自WebSecurityConfigurerAdapter
常用配置说明:
① 设置加密格式为BCryptPasswordEncoder
:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
② 配置身份验证方式,此处是基于数据库的验证方式,会调用HrService里面的loadUserByUsername
方法。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(hrService);
}
③ 配置需要放行的资源,默认情况下,登录页面、验证码页面不需要拦截。后期如果将前端打包资源拷贝到后端统一运行,相关的资源文件也不需要拦截。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/login","/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode");
}
④ 请求授权配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setAccessDecisionManager(customUrlDesicionManager);
o.setSecurityMetadataSource(customFilterInvocationData);
return o;
}
})
...
}
通过withObjectPostProcessor
将之前创建的customUrlDesicionManager与customFilterInvocationData注入进来,使请求通过这些过滤器(放行的资源除外)。接下来是表单登录相关的配置:具体代码可从仓库获取
.formLogin()
.loginProcessingUrl("/doLogin") //设置登录url路径
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
返回登录成功的json
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
返回登录失败的json
}
})
.and()
.logout()
.logoutSuccessHandler((req, resp, authentication) -> {
返回注销成功后的json
})
.permitAll()
.and()
.csrf().disable().exceptionHandling()
//没有认证时,在这里处理结果,不要重定向 重定向会有跨域问题
.authenticationEntryPoint((httpServletRequest, resp, e) -> {
返回未认证json
})
.and()
.sessionManagement()
.maximumSessions(1); //设置session的并发个数为1 实现登录踢掉前一个用户
RabbitMQ
本项目中使用RabbitMQ来发送邮件。
rabbitmq相关使用,可访问:消息中间件笔记
邮件发送笔记,可访问:SpringBoot邮件服务
1、前期准备工作
①、引入消息中间件依赖
在hrserver/hr-service/pom.xml以及mailserver/pom.xml文件中分别引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
②、从qq邮箱获取授权码
登录qq邮箱,点击设置
在账户选项中的POP3/SMTP选项中开启POP3/SMTP服务(图中已开启)
开启完毕会得到一个授权码,保存该授权码用于后期配置。
③、安装rabbitmq
此处使用docker安装rabbitmq,不做详细讲述。
④、配置yml文件
(注意这两个地方的rabbitmq配置略有不同)
hrserver/hr-web/src/main/resources/application.yml
配置:
rabbitmq:
username: guest
password: guest
host: 192.168.42.155
port: 5672
publisher-confirm-type: correlated #开启confirms回掉 旧方法失效
publisher-returns: true # 开启returnMessage回调
mailserver/src/main/resources/application.yml
配置:
根据自己的需求填写邮箱授权码
和邮件发送者
的邮箱地址,rabbitmq的host根据本机情况配置:
server:
port: 9989
spring:
mail:
host: smtp.qq.com
protocol: smtp
default-encoding: UTF-8
password: # 填写邮箱授权码
username: # 填写邮件的发送者
port: 587
properties:
mail:
stmp:
socketFactory:
class:javax.net.ssl.SSLSocketFactory
debug: true
rabbitmq:
username: guest
password: guest
host: 192.168.42.155
port: 5672
listener:
simple:
acknowledge-mode: manual # 手动确认消息
prefetch: 100 # 预加载数量
2、SpringBoot搭建邮件发送
2.1、服务流程图
2.2、配置rabbitmq
在hrservice模块的org.tgh.hr.config.RabbitConfig
中配置:
@Configuration
@Slf4j
public class RabbitConfig {
//spring框架提供的连接工厂
@Autowired
CachingConnectionFactory cachingConnectionFactory;
//日志服务
@Autowired
MailSendLogService mailSendLogService;
//自定义的rabbitTemplate
@Bean
RabbitTemplate rabbitTemplate() {
RabbitTemplate rabbitTemplate = new RabbitTemplate(cachingConnectionFactory);
//确认rabbitmq是否接收成功
rabbitTemplate.setConfirmCallback((data,ack,cause)->{
//消息的唯一id
String msgId = data.getId();
//ack为true表示发送成功
if (ack) {
log.info("ack状态为:"+ack);
log.info(msgId+":消息发送成功");
mailSendLogService.updateMailSendLogStatus(msgId, 1);//修改数据库中的记录,1表示消息投递成功
}else{
log.info(msgId+":消息发送失败");
}
});
//TODO
rabbitTemplate.setReturnCallback((msg,repCode,repText,exchange,routingkey)->{
log.info("交换机到队列失败");
});
return rabbitTemplate;
}
@Bean
Queue mailQueue() {
return new Queue(MailConstants.MAIL_QUEUE_NAME, true);
}
@Bean
DirectExchange mailExchange() {
return new DirectExchange(MailConstants.MAIL_EXCHANGE_NAME, true,false);
}
@Bean
Binding mailBinding() {
return BindingBuilder.bind(mailQueue()).to(mailExchange()).with(MailConstants.MAIL_ROUTING_KEY_NAME);
}
}
在该类中自定义了rabbitTemplate,将自动确认消息发送该为手动确认消息发送:rabbitTemplate.setConfirmCallback
的回掉值ack为true表示生产者P发送的消息到达了交换机X中。
注意:交换机名称、队列名称、路由密钥通过org.tgh.hr.model.MailConstants
的静态常量统一设置(下同)
2.3、配置rabbitmq生产者
在hrservice模块的org.tgh.hr.service.EmployeeService#addEmp
方法中配置:
public Integer addEmp(Employee employee) {
Date beginContract = employee.getBeginContract();
Date endContract = employee.getEndContract();
double month = (Double.parseDouble(yearFormat.format(endContract)) - Double.parseDouble(yearFormat.format(beginContract))) * 12 +
(Double.parseDouble(monthFormat.format(endContract)) - Double.parseDouble(monthFormat.format(beginContract)));
employee.setContractTerm(Double.parseDouble(decimalFormat.format(month / 12)));
logger.info("执行之前:");
int result = employeeMapper.insertSelective(employee);
logger.info("result:"+result);
//把员工完整信息查出来
if (result == 1) {
Employee emp = employeeMapper.getEmployeeById(employee.getId());
logger.info("员工信息为:"+emp.toString());
//生成消息id
String msgId = UUID.randomUUID().toString();
logger.info("msgId:"+msgId);
MailSendLog mailSendLog = new MailSendLog();
mailSendLog.setMsgId(msgId);
mailSendLog.setCreateTime(new Date());
mailSendLog.setExchange(MailConstants.MAIL_EXCHANGE_NAME);
mailSendLog.setRouteKey(MailConstants.MAIL_ROUTING_KEY_NAME);
mailSendLog.setEmpId(emp.getId());
mailSendLog.setTryTime(new Date(System.currentTimeMillis()+1000*60*MailConstants.MSG_TIMEOUT));//重试时间 一分钟后重试
mailSendLogService.insert(mailSendLog);
//为了处理消息发送中可能遇到的问题,需要自己定义rabbitTemplate 参数四 该消息的唯一标识符
//发送两条消息,而两条消息的msgId是一样的,测试是否会重复消费
logger.info("mailSendLog:"+mailSendLog);
rabbitTemplate.convertAndSend(MailConstants.MAIL_EXCHANGE_NAME, MailConstants.MAIL_ROUTING_KEY_NAME,emp, new CorrelationData(msgId));
rabbitTemplate.convertAndSend(MailConstants.MAIL_EXCHANGE_NAME, MailConstants.MAIL_ROUTING_KEY_NAME,emp, new CorrelationData(msgId));
}
return result;
}
该方法主要负责消息发送,在发送之前设置了mailSendLog属性,包含本条消息的详细信息,其中msgId
用来避免消息重复消费。MailSendLog
实体类对应的表mail_send_log
中的status
用来记录消息的发送状态:初次创建时为0。当消息成功发送到交换机上面后会设置为1。
`status` int(11) DEFAULT '0' COMMENT '0发送中,1发送成功,2发送失败',
2.4、配置邮件重发
在hrservice模块中的org.tgh.hr.task.MailSendTask
进行配置,将mailResendTask
方法设计成一个定时任务,Scheduled(cron = "0/10 * * * * ?")
表示每隔10秒触发一次,关于定时任务的使用可以参考:Spring Boot2 系列教程(十六)定时任务的两种实现方式,此外还需在启动类中加入@EnableScheduling
注解表示开启定时任务。
@Component
public class MailSendTask {
@Autowired
MailSendLogService mailSendLogService;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
EmployeeService employeeService;
@Scheduled(cron = "0/10 * * * * ?")
public void mailResendTask() {
//查询需要处理的信息
List<MailSendLog> logs = mailSendLogService.getMailSendLogsByStatus();
if (logs == null || logs.size() == 0) {
return;
}
logs.forEach(mailSendLog -> {
if (mailSendLog.getCount() >= 3) {
mailSendLogService.updateMailSendLogStatus(mailSendLog.getMsgId(), 2);//直接设置该条消息发送失败
}else{
mailSendLogService.updateCount(mailSendLog.getMsgId(),new Date());
Employee emp = employeeService.getEmployeeById(mailSendLog.getEmpId());
//重试发邮件
rabbitTemplate.convertAndSend(MailConstants.MAIL_EXCHANGE_NAME, MailConstants.MAIL_ROUTING_KEY_NAME, emp, new CorrelationData(mailSendLog.getMsgId()));
}
});
}
}
2.5、配置rabbitmq消费者
在mailserver模块中的org/tgh/mailserver/receiver/MailReceiver.java
进行配置
注入类:
JavaMailSender
:Spring框架提供的邮件发送接口
MailProperties
:用它来获取邮件发送者的用户名
TemplateEngine
:Thymeleaf提供的模板类,方便咱们配置
StringRedisTemplate
Spring提供的字符串redis模板,key-value保存为String类型
@Component
public class MailReceiver {
public static final Logger logger = LoggerFactory.getLogger(MailReceiver.class);
@Autowired
JavaMailSender javaMailSender;
@Autowired
MailProperties mailProperties;
@Autowired
TemplateEngine templateEngine;
@Autowired
StringRedisTemplate redisTemplate;
//发送失败给日志
@RabbitListener(queues = MailConstants.MAIL_QUEUE_NAME)
// channel手动确认消息处理成功还是处理失败
// 加入了手动确认消息成功还是失败 得在配置文件里面开启手动确认
public void handler(Message message, Channel channel) throws IOException {
Employee employee = (Employee) message.getPayload();
logger.info("message:"+message);
logger.info("employee:"+employee);
MessageHeaders headers = message.getHeaders();
logger.info("headers:"+headers);
//消息标记
Long tag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
logger.info("tag:"+tag);
//messgid
String msgId = (String) headers.get("spring_returned_message_correlation");
logger.info("msgId:"+msgId);
if(redisTemplate.opsForHash().entries("mail_log").containsKey(msgId)) {
// redis中包含该key,说明该消息已经被消费过
logger.info(msgId + "消息已经被消费了");
channel.basicAck(tag,false); //确认消息已消费
return;
}
logger.info(employee.toString());
//收到消息,发送邮件
MimeMessage msg = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(msg);
try {
helper.setTo(employee.getEmail());
helper.setFrom(mailProperties.getUsername());
helper.setSubject("入职欢迎");
helper.setSentDate(new Date());
Context context = new Context();
context.setVariable("name",employee.getName());
context.setVariable("posName",employee.getPosition().getName());
context.setVariable("jobLevelName",employee.getJobLevel().getName());
context.setVariable("departmentName",employee.getDepartment().getName());
String mail = templateEngine.process("mail", context);
helper.setText(mail, true);
javaMailSender.send(msg);
redisTemplate.opsForHash().put("mail_log",msgId,"t2");
channel.basicAck(tag,false);
logger.info(msgId + ":邮件发送成功");
} catch (MessagingException e) {
//参数三,回到队列里面去
channel.basicNack(tag,false,true);
e.printStackTrace();
logger.error("邮件发送失败:"+e.getMessage());
}
}
}
该方法分两处作说明:
一、try catch 外部:
获取消息队列推送过来的信息,判断信息是否被消费:
Message
:spring框架提供的通用消息表示,包含消息头和消息体,可以使用getPayload()
从消息体中获取之前设置的员工对象,使用getHeaders()
等方法从消息头中获取消息标记tag
和之前设置的消息唯一标识msgId
。
Channel
:网络信道,消息读写通道,此处获取channel对象用来手动确认消息消费状况。当检测到redis的mail_log
条目内包含msgId
则判断为消息已经被消费:
if(redisTemplate.opsForHash().entries("mail_log").containsKey(msgId)) {
// redis中包含该key,说明该消息已经被消费过
logger.info(msgId + "消息已经被消费了");
channel.basicAck(tag,false); //确认消息已消费
return;
}
二、try catch 内部:
设置邮件模板,发送邮件、手动确认消费成功,若在邮件发送过程中发生异常,则进入到catch内部,拒绝签收该消息channel.basicNack(tag,false,true);
,接着输出异常信息。
参考文章:Spring Boot 整合 RabbitMQ,消息重复消费怎么办?
WebSocket
引入WebSocket用于聊天模块,负责消息接收和发送
hr-web模块中引入websocket依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
配置类WebSocketConfig
:
@Configuration
@EnableWebSocketMessageBroker //开启消息代理
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
//注册stomp端点 将"/ws/ep"路径添加为服务端点,接收客户端的连接 允许前端域访问,本项目前端运行在 http://localhost:8080
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/ep").setAllowedOrigins("http://localhost:8080").withSockJS();
}
//配置消息代理
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//定义客户端接受服务端发送消息的前缀信息
registry.enableSimpleBroker("/queue");
}
}
控制类:
@Slf4j
@UserAccess("聊天控制模块")
@Controller
public class WsController {
@Autowired
SimpMessagingTemplate simpMessagingTemplate;
// Principal 获取当前登录的用户
@UserAccess("发送聊天信息")
@MessageMapping("/ws/chat")
public void handleMsg(Authentication authentication, ChatMsg chatMsg) {
Hr hr = (Hr) authentication.getPrincipal();
System.out.println("当前hr是:"+hr);
chatMsg.setFrom(hr.getUsername());
chatMsg.setDate(new Date());
chatMsg.setNickName(hr.getName());
log.info("chatMsg为:"+chatMsg);
simpMessagingTemplate.convertAndSendToUser(chatMsg.getTo(),"/queue/chat",chatMsg);
}
}
@MessageMapping中填写前台发送消息的地址
前端发送WebSocket信息核心代码(位于usertext.vue中):
//参数一消息接受地址(后端接口) 参数二:消息优先级信息,此处忽略 参数三:消息内容
this.$store.state.stomp.send('/ws/chat',{},JSON.stringify(msgObj));
前端接收WebSocket信息核心代码
actions:{
//该方法做websocket连接 (收消息)
connect(context) {
//初始化 stomp 对应后台registerStompEndpoints中的...paths
context.state.stomp = Stomp.over(new SockJS('/ws/ep'));
//对成功和失败进行处理
context.state.stomp.connect({},success=>{
// /user为服务端添加的默认前缀
context.state.stomp.subscribe('/user/queue/chat',msg=>{
//...详细代码从仓库中获取
},error=>{
console.log("发送失败!");
})//发起连接 ,连接成功的回掉
}
具体代码见前端仓库。
默认生成的key带后缀,保存为十六进制格式:
Spring Cache
在CustomFilterInvocationData
的menuService.getAllMenusByRole()
返回资源表menu
与角色表role
之间的对应关系,用于后续判断访问当前资源需要的角色,用户每次访问接口都会调用该方法,访问很频繁,并且该方法的返回结果不经常变动,所以计划给该方法加上缓存。此处使用Spring Cache + redis实现。
在hr-service中引入相关依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
hrserver/hr-web/src/main/resources/application.yml
关于redis的配置:
spring:
redis:
host: 192.168.42.155
database: 0
port: 6379
password: root
保存缓存:
@Cacheable(value = "menu",key = "#root.methodName")
public List<Menu> getAllMenusByRole() {
return menuMapper.getAllMenusByRole();
}
value为缓存名称(必填),key为缓存内部的key名称,默认情况下key缓存的是方法的参数,若方法没有参数则指定为:SimpleKey []
,此处用#root.methodName
指定key为当前方法名。
查看生成的key:"menu::getAllMenusByRole"
可视化工具下载地址:AnotherRedisDesktopManager
更新缓存:
在设计上没有提供接口给用户操作menu
表,不过当role
或menu_role
表发生变化时,menu
与role
对应的关系可能会变化,需要更新redis中缓存的值。
在org.tgh.hr.service.MenuService
中定义更新方法resetMenuRole
:删除指定key对应的缓存,再次查询生成key实现更新操作。
public void resetMenuRole() {
redisTemplate.delete("menu::getAllMenusByRole");
getAllMenusByRole();
}
调用策略:在org.tgh.hr.controller.system.basic.PermissController
中,若用户成功修改了role
或menu_role
表,则调用更新方法:
@UserAccess("更新菜单角色")
@PutMapping("/")
public Result updateMenuRole(Integer rid, Integer[] mids) {
if(menuService.updateMenuRole(rid, mids)) {
menuService.resetMenuRole();
return Result.ok("更新成功!");
} else {
return Result.error("更新失败!");
}
}
@UserAccess("添加角色")
@PostMapping("/role")
public Result addRole(@RequestBody Role role) {
if(roleService.addRole(role) == 1) {
menuService.resetMenuRole();
return Result.ok("添加成功!");
} else {
return Result.error("添加失败!");
}
}
@UserAccess("删除角色")
@DeleteMapping("/role/{rid}")
public Result deleteRoleById(@PathVariable Integer rid) {
if(roleService.deleteRoleById(rid) == 1) {
menuService.resetMenuRole();
return Result.ok("删除成功!");
} else {
return Result.error("删除失败!");
}
}
二、模块修改
本项目在设计上提供显示用户头像功能,参考:FastDFS环境搭建、Spring Boot+Vue+FastDFS 实现前后端分离文件上传等教程中使用FastDFS搭建本地文件管理系统,过程比较繁琐,我在做的时候成功实现了图片保存,却无法从FastDFS中获取保存到图片,耗了不少时间,于是没采用这种方式。查询相关资料发现目前用OOS较为主流,本项目主要用来练手,保存的仅是几张用户图片,于是采用一种野路子方法——用码云仓库来保存用户图片。
首先需要新建一个码云仓库,可以参考新建仓库
其次需要获得一个令牌,可以参考 申请 Gitee 的私人令牌
参考Gitee提供的新建文件api
说明:
①、访问路径中的{xxx}
为用户自定义内容。
②、Response Class为调用这个接口返回的消息实体,我们关注content
中的download_url
,为新文件的下载路径,我们需要获取这个路径用于更新hr
表。
③、Parameters 中右上角带红色星号的为必填参数,此外还需要带上access_token
(先前获取的令牌)
在hr-web中引入Hutool工具类:(使用http请求发送和json转换工具类)
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.10</version>
</dependency>
核心代码:
//图片保存至码云
@UserAccess("用户头像上传至码云")
@PostMapping("/userface/gitee")
public Result uploadUserFace(MultipartFile file, Integer id,Authentication authentication) throws IOException {
String path = MEMBERS_UPLOAD_PATH;
String JSONResult = GiteeUtils.upload(path, file);
JSONObject jsonObject = JSONUtil.parseObj(JSONResult);
JSONObject content = JSONUtil.parseObj(jsonObject.getObj(GiteeUtils.RESULT_BODY_CONTENT));
log.info("content:"+content);
Object url = content.getObj(GiteeUtils.RESULT_BODY_DOWNLOAD_URL);
log.info("url:"+url);
//更新数据库中的hr图片地址
if(jsonObject != null && hrService.updateUserface(url.toString(),id) == 1) {
//更新内存中的hr图片地址
Hr hr = (Hr) authentication.getPrincipal();
hr.setUserface(url.toString());
return Result.ok("更新成功!",url);
} else {
log.info("头像更新失败");
return Result.error("更新失败!");
}
}
JSONUtil.parseObj()
:将json字符串转化为json对象(hutool提供)
GiteeUtils
:Gitee工具类,接受保存路径和文件,结合代码中的配置,拼接出
https://gitee.com/api/v5/repos/{owner}/{repo}/contents/{path}
,完整代码从仓库中获取。
注意access_token
、owner
、repo
填写自己的!
private static final String ACCESS_TOKEN = "";
private static final String OWNER = "";
private static final String REPO = "";
public static final String PATH = "userFace1/";
当前数据库中只有系统管理员头像是码云仓库里边的,其余为网络图片链接
三、模块新增
操作日志管理模块
基于注解+AOP+反射实现,思路:在需要记录的类和方法上方定义注解,通过AOP在程序运行期间得到户调用执行方法,在执行方法前获取定义的注解信息,对信息进行处理,存储至数据库。
注解:自定义注解UserAccess
,后期利用反射获取注解中的值:
@Retention(RetentionPolicy.RUNTIME) //运行期间保留,能够通过反射方式获取
@Target({ ElementType.TYPE,ElementType.METHOD}) //能够标注在类和方法上
public @interface UserAccess {
String value() default "";
}
关于java注解详细介绍可以访问:Java 注解(Annotation)。
AOP:Aspect Oriented Programming(面向切面编程),通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。
- 预编译方式:在编译之前做代码文本的替换工作
- 运行期间动态代理:在程序运行期,创建目标对象的代理对象,并对目标对象中的方法进行功能性增强的一种技术
AOP中常用术语:
- Target(目标对象):代理的目标对象
- Proxy (代理):一个类被 AOP 织入增强后,就产生一个结果代理类
- Joinpoint(连接点):所谓连接点是指那些被拦截到的点。在spring中,这些点指的是方法,因为spring只支持方法类型的连接点
- Pointcut(切点):所谓切点是指我们要对哪些 Joinpoint 进行拦截的定义
- Advice(通知/ 增强):所谓通知是指拦截到 Joinpoint 之后所要做的事情就是通知
- Aspect(切面):是切点和通知(引介)的结合
- Weaving(织入):是指把增强应用到目标对象来创建新的代理对象的过程。spring采用动态代理织入,而AspectJ采用编译期织入和类装载期织入
开发明确事项:
谁是切点(切点表达式配置)
谁是通知(切面类中的增强方法)
将切点和通知进行织入配置
配置切面类:
@Aspect
@Component
@Slf4j
public class LogAspect {
@Autowired
OpLogService opLogService;
//配置切点表达式
@Pointcut("execution(* org.tgh.hr.controller.*Controller.*(..)) || " +
"execution(* org.tgh.hr.controller.emp.*Controller.*(..)) || " +
"execution(* org.tgh.hr.controller.salary.*Controller.*(..)) || " +
"execution(* org.tgh.hr.controller.system..*(..))")
public void beforeController() {}
//配置前置通知。指定增强的方法在切入点方法之前执行
@Before("beforeController()")
public void accessLog(JoinPoint joinPoint) throws ParseException {
Signature signature = joinPoint.getSignature();
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
try {
// springsecurity中获取当前用户信息
Hr hr = (Hr) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
StringBuilder builder = new StringBuilder();
if (hr == null) {
hr = new Hr(-1);
}
builder.append("操作:");
builder.append(getAnnotationInfo(signature));
builder.append("\r\n");
//对获取参数进行处理
Object[] args = joinPoint.getArgs();
if(args.length > 0) {
List<Object> list = new ArrayList<>();
for (Object arg : args) {
if(arg instanceof MultipartFile)
continue;
list.add(arg);
}
//拼接类、方法、参数
builder.append(JSON.toJSONString(list));
}
OpLog opLog = new OpLog(new Date(), builder.toString(), hr.getId(), hr.getName());
log.info("当前的访问状态是:"+ opLog);
//对较长日志截取 存数据库
opLogService.insert(new OpLog(new Date(),builder.toString().substring(0,Math.min(255,builder.length())),hr.getId(),hr.getName()));
} catch (Exception e) {
e.printStackTrace();
log.info("当前是webSocket请求");
}
}
//获取自定义注解中的值,拼接,返回结果
private String getAnnotationInfo(Signature signature) {
Class aClass = signature.getDeclaringType();
log.info("类为:"+aClass);
//获得类上面的注解参数
UserAccess ua = (UserAccess) aClass.getDeclaredAnnotation(UserAccess.class);
log.info("ua:"+ua);
String str = "";
str = ua.value();
//获得方法上面的注解参数
str = str + "_" + (((MethodSignature)signature).getMethod().getDeclaredAnnotation(UserAccess.class).value());
return str;
}
}
注意事项:
①、不是控制类中所有方法都需要被记录,有一些方法是前端视图加载需要调用,非用户直接调用,把这些方法抽离出来,放到api包下面:
②、在做这里时遇到一个问题,若是webSocket请求(接口定义在org.tgh.hr.controller.WsController
),则获取不到用户信息,目前未解决,暂时捕获这个异常,有更好的思路欢迎在评论区点评。