核心模块
核心模块
功能权限设计
权限相关的表结构,如下图所示

- sys_user[用户]表,保存用户相关数据,通过sys_user_role[用户与角色关联]表,与sys_role[角色]表关联;sys_menu[菜单]表通过sys_role_menu[菜单与角色关联]表,与sys_role[角色]表关联
- sys_menu表,保存菜单相关数据,并在perms字段里,保存了shiro的权限标识,也就是说拥有此菜单,就拥有perms字段里的所有权限,比如,某用户拥有的菜单权限标识 sys:menu:list,就可以访问下面的方法
- sys_dept表,保存部门相关数据,数据权限也是根据部门进行过滤的,下一小节具体讲解
- 在配置文件里,anon表示不经过shiro处理,authc表示经过shiro处理,这样就保证没有权限的请求有效的拒绝。
- 接下来,我们看看shiro框架,具体是怎么效验功能权限的,系统登录代码如下
@Controller
public class SysLoginController {
@Autowired
private Producer producer;
@RequestMapping("captcha.jpg")
public void captcha(HttpServletResponse response) throws ServletException, IOException {
response.setHeader("Cache-Control", "no-store, no-cache");
response.setContentType("image/jpeg");
//生成文字验证码
String text = producer.createText();
//生成图片验证码
BufferedImage image = producer.createImage(text);
//保存到shiro session
ShiroUtils.setSessionAttribute(Constants.KAPTCHA_SESSION_KEY, text);
ServletOutputStream out = response.getOutputStream();
ImageIO.write(image, "jpg", out);
}
/**
* 登录
*/
@SysLog("登录")
@ResponseBody
@RequestMapping(value = "/sys/login", method = RequestMethod.POST)
public R login(String username, String password, String captcha) throws IOException {
String kaptcha = ShiroUtils.getKaptcha(Constants.KAPTCHA_SESSION_KEY);
if(null == kaptcha){
return R.error("验证码已失效");
}
if (!captcha.equalsIgnoreCase(kaptcha)) {
return R.error("验证码不正确");
}
try {
Subject subject = ShiroUtils.getSubject();
//sha256加密
password = new Sha256Hash(password).toHex();
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
subject.login(token);
} catch (UnknownAccountException e) {
return R.error(e.getMessage());
} catch (IncorrectCredentialsException e) {
return R.error(e.getMessage());
} catch (LockedAccountException e) {
return R.error(e.getMessage());
} catch (AuthenticationException e) {
return R.error("账户验证失败");
}
return R.ok();
}
/**
* 退出
*/
@RequestMapping(value = "logout", method = RequestMethod.GET)
public String logout() {
ShiroUtils.logout();
return "redirect:/";
}
}
- 登录的时候subject.login(token)调用自定义的Realm的doGetAuthorizationInfo方法,方法如下
public class UserRealm extends AuthorizingRealm {
@Autowired
private SysUserDao sysUserDao;
@Autowired
private SysMenuDao sysMenuDao;
/**
* 授权(验证权限时调用)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SysUserEntity user = (SysUserEntity) principals.getPrimaryPrincipal();
Long userId = user.getUserId();
List<String> permsList = (List<String>) J2CacheUtils.get(Constant.PERMS_LIST + userId);
//用户权限列表
Set<String> permsSet = new HashSet<String>();
if (permsList != null && permsList.size() != 0) {
for (String perms : permsList) {
if (StringUtils.isBlank(perms)) {
continue;
}
permsSet.addAll(Arrays.asList(perms.trim().split(",")));
}
}
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setStringPermissions(permsSet);
return info;
}
/**
* 认证(登录时调用)
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(
AuthenticationToken token) throws AuthenticationException {
String username = (String) token.getPrincipal();
String password = new String((char[]) token.getCredentials());
//查询用户信息
SysUserEntity user = sysUserDao.queryByUserName(username);
//账号不存在
if (user == null) {
throw new UnknownAccountException("账号或密码不正确");
}
//密码错误
if (!password.equals(user.getPassword())) {
throw new IncorrectCredentialsException("账号或密码不正确");
}
//账号锁定
if (user.getStatus() == 0) {
throw new LockedAccountException("账号已被锁定,请联系管理员");
}
// 把当前用户放入到session中
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession(true);
session.setAttribute(Global.CURRENT_USER, user);
List<String> permsList;
//系统管理员,拥有最高权限
if (Constant.SUPER_ADMIN == user.getUserId()) {
List<SysMenuEntity> menuList = sysMenuDao.queryList(new HashMap<String, Object>());
permsList = new ArrayList<>(menuList.size());
for (SysMenuEntity menu : menuList) {
permsList.add(menu.getPerms());
}
} else {
permsList = sysUserDao.queryAllPerms(user.getUserId());
}
J2CacheUtils.put(Constant.PERMS_LIST + user.getUserId(), permsList);
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());
return info;
}
}
- 在doGetAuthorizationInfo方法里,会通过用户名,查询用户信息,如果用户不存在则直接抛出UnknownAccountException异常,如果查询到用户信息,则封装成SimpleAuthenticationInfo对象并返回,为了减少对数据库的压力,将用户权限存入缓存中。
- 登录验证通过后,每次向后台请求调用有@RequiresPermissions注解的请求时shiro会调用自定义Realm的doGetAuthorizationInfo方法验证当前登录用户是否拥有该请求的权限。
数据权限设计
使用注解的方式实现数据权限的功能。
通过@DataFilter注解实现
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataFilter {
/**
* sql中数据创建用户(通常传入CREATE_USER_ID)的别名
*/
String userAlias() default "";
/**
* sql中数据deptId的别名
*/
String deptAlias() default "";
/**
* true:没有部门数据权限,也能查询本人数据
*/
boolean self() default true;
}
具体实现
@Aspect
@Component
public class DataFilterAspect {
@Autowired
private SysRoleDeptService sysRoleDeptService;
/**
* 切点
*/
@Pointcut("@annotation(com.platform.annotation.DataFilter)")
public void dataFilterCut() {
}
/**
* 前置通知
*
* @param point 连接点
*/
@Before("dataFilterCut()")
public void dataFilter(JoinPoint point) {
//获取参数
Object params = point.getArgs()[0];
if (params != null && params instanceof Map) {
SysUserEntity user = ShiroUtils.getUserEntity();
//如果不是超级管理员,则只能查询本部门及子部门数据
if (user.getUserId() != Constant.SUPER_ADMIN) {
Map map = (Map) params;
map.put("filterSql", getFilterSQL(user, point));
}
return;
}
throw new RRException("数据权限接口的参数必须为Map类型,且不能为NULL");
}
/**
* 获取数据过滤的SQL
*
* @param user 登录用户
* @param point 连接点
* @return sql
*/
private String getFilterSQL(SysUserEntity user, JoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
DataFilter dataFilter = signature.getMethod().getAnnotation(DataFilter.class);
String userAlias = dataFilter.userAlias();
String deptAlias = dataFilter.deptAlias();
StringBuilder filterSql = new StringBuilder();
filterSql.append(" and ( ");
if (StringUtils.isNotEmpty(deptAlias) || StringUtils.isNotBlank(userAlias)) {
if (StringUtils.isNotEmpty(deptAlias)) {
//取出登录用户部门权限
String alias = getAliasByUser(user.getUserId());
filterSql.append(deptAlias);
filterSql.append(" in ");
filterSql.append(" ( ");
filterSql.append(alias);
filterSql.append(" ) ");
if (StringUtils.isNotBlank(userAlias)) {
if (dataFilter.self()) {
filterSql.append(" or ");
} else {
filterSql.append(" and ");
}
}
}
if (StringUtils.isNotBlank(userAlias)) {
//没有部门数据权限,也能查询本人数据
filterSql.append(userAlias);
filterSql.append(" = ");
filterSql.append(user.getUserId());
filterSql.append(" ");
}
} else {
return "";
}
filterSql.append(" ) ");
return filterSql.toString();
}
/**
* 取出用户权限
*
* @param userId 登录用户Id
* @return 权限
*/
private String getAliasByUser(Long userId) {
@SuppressWarnings("unchecked")
List<Long> roleOrglist = sysRoleDeptService.queryDeptIdListByUserId(userId);
StringBuilder roleStr = new StringBuilder();
String alias = "";
if (roleOrglist != null && !roleOrglist.isEmpty()) {
for (Long roleId : roleOrglist) {
roleStr.append(",");
roleStr.append("'");
roleStr.append(roleId);
roleStr.append("'");
}
alias = roleStr.toString().substring(1, roleStr.length());
}
return alias;
}
}
说明
该实现类中,定义了一个切入点,只要方法上加@DataFilter注解,执行添加注解的方法执行之前会进入dataFilter方法。可以看到上面代码map.put("filterSql", getFilterSQL(user, point)); 把查询的过滤条件存入方法的map参数中,key值filterSql,所以使用此注解的方法第一个参数必须是Map类型。

生成过滤条件的SQL
/**
* 获取数据过滤的SQL
*
* @param user 登录用户
* @param point 连接点
* @return sql
*/
private String getFilterSQL(SysUserEntity user, JoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
DataFilter dataFilter = signature.getMethod().getAnnotation(DataFilter.class);
String userAlias = dataFilter.userAlias();
String deptAlias = dataFilter.deptAlias();
StringBuilder filterSql = new StringBuilder();
filterSql.append(" and ( ");
if (StringUtils.isNotEmpty(deptAlias) || StringUtils.isNotBlank(userAlias)) {
if (StringUtils.isNotEmpty(deptAlias)) {
//取出登录用户部门权限
String alias = getAliasByUser(user.getUserId());
filterSql.append(deptAlias);
filterSql.append(" in ");
filterSql.append(" ( ");
filterSql.append(alias);
filterSql.append(" ) ");
if (StringUtils.isNotBlank(userAlias)) {
if (dataFilter.self()) {
filterSql.append(" or ");
} else {
filterSql.append(" and ");
}
}
}
if (StringUtils.isNotBlank(userAlias)) {
//没有部门数据权限,也能查询本人数据
filterSql.append(userAlias);
filterSql.append(" = ");
filterSql.append(user.getUserId());
filterSql.append(" ");
}
} else {
return "";
}
filterSql.append(" ) ");
return filterSql.toString();
}
数据权限实现案例
- 对商品列表的查询,代码如下
GoodsServiceImpl.java
@Override
@DataFilter(userAlias = "nideshop_goods.create_user_id", deptAlias = "nideshop_goods.create_user_dept_id")
public List<GoodsEntity> queryList(Map<String, Object> map) {
return goodsDao.queryList(map);
}
GoodsDao.xml
<select id="queryList" resultType="com.platform.entity.GoodsEntity">
select
nideshop_goods.id,
nideshop_goods.category_id,
nideshop_goods.goods_sn,
nideshop_goods.name,
nideshop_goods.brand_id,
nideshop_goods.goods_number,
nideshop_goods.keywords,
nideshop_goods.goods_brief,
nideshop_goods.goods_desc,
nideshop_goods.is_on_sale,
nideshop_goods.add_time,
nideshop_goods.update_time,
nideshop_goods.sort_order,
nideshop_goods.is_delete,
nideshop_goods.attribute_category,
nideshop_goods.counter_price,
nideshop_goods.extra_price,
nideshop_goods.is_new,
nideshop_goods.goods_unit,
nideshop_goods.primary_pic_url,
nideshop_goods.list_pic_url,
nideshop_goods.retail_price,
nideshop_goods.sell_volume,
nideshop_goods.primary_product_id,
nideshop_goods.unit_price,
nideshop_goods.promotion_desc,
nideshop_goods.promotion_tag,
nideshop_goods.app_exclusive_price,
nideshop_goods.is_app_exclusive,
nideshop_goods.is_limited,
nideshop_goods.is_hot,
nideshop_goods.market_price,
nideshop_goods.create_user_id,
nideshop_goods.create_user_dept_id,
nideshop_goods.update_user_id,
nideshop_category.name category_name,
nideshop_attribute_category.name attribute_category_name,
nideshop_brand.name brand_name
from nideshop_goods
LEFT JOIN nideshop_category
ON nideshop_goods.category_id = nideshop_category.id
LEFT JOIN nideshop_attribute_category ON nideshop_goods.attribute_category = nideshop_attribute_category.id
LEFT JOIN nideshop_brand ON nideshop_brand.id = nideshop_goods.brand_id
WHERE 1=1
<!-- 数据过滤 -->
${filterSql}
<if test="name != null and name != ''">
AND nideshop_goods.name LIKE concat('%',#{name},'%')
</if>
AND nideshop_goods.is_Delete = #{isDelete}
<choose>
<when test="sidx != null and sidx.trim() != ''">
order by ${sidx} ${order}
</when>
<otherwise>
order by nideshop_goods.id desc
</otherwise>
</choose>
<if test="offset != null and limit != null">
limit #{offset}, #{limit}
</if>
</select>
- 在sql语句的where条件中,加入${filterSql},这样就完成了数据权限的代码实现,最后在角色管理页面配置数据权限。
XSS脚本过滤
XSS攻击全称跨站脚本攻击,是为不和层叠样式表(Cascading Style Sheets, CSS)的缩写混淆,故将跨站脚本攻击缩写为XSS,XSS是一种在web应用中的计算机安全漏洞,它允许恶意web用户将代码植入到提供给其它用户使用的页面中。本系统针对XSS攻击,提供了过滤功能,可以有效防止XSS攻击,代码请参照XssFilter.java
富文本数据处理
在web.xml配置处理带有富文本参数的请求,代码如下
<filter>
<filter-name>xssFilter</filter-name>
<filter-class>com.platform.xss.XssFilter</filter-class>
<init-param>
<!--凡是提交包含html内容的请求都要写在这里,若有多个以逗号隔开-->
<param-name>excludedPages</param-name>
<param-value>/topic/update,/topic/save,/goods/save,/goods/update</param-value>
</init-param>
</filter>
具体实现代码
public class XssFilter implements Filter {
//需要排除过滤的url
private String excludedPages;
private String[] excludedPageArray;
@Override
public void init(FilterConfig config) throws ServletException {
excludedPages = config.getInitParameter("excludedPages");
if (StringUtils.isNotEmpty(excludedPages)) {
excludedPageArray = excludedPages.split(",");
}
}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
boolean isExcludedPage = false;
for (String page : excludedPageArray) {//判断是否在过滤url之外
if (((HttpServletRequest) request).getServletPath().equals(page)) {
isExcludedPage = true;
break;
}
}
if (isExcludedPage) {//排除过滤url
chain.doFilter(request, response);
} else {
chain.doFilter(xssRequest, response);
}
}
@Override
public void destroy() {
}
}
SQL注入
本系统使用的是Mybatis,如果使用${}拼接SQL,则存在SQL注入风险,可以对参数进行过滤,避免SQL注入,具体代码实现请参照SQLFilter.java
处理SQL注入风险

日志拦截器
为了方便开发调试需要日志拦截器。(登录拦截和权限拦截已在shiro实现,日志拦截器只做控制台输出日志,不做任何拦截处理。)输出打印除/statics/**、.html、.js以外的所有请求。
<mvc:interceptors>
<!-- 使用bean定义一个Interceptor,直接定义在mvc:interceptors根下面的Interceptor将拦截所有的请求 -->
<!--<bean class="com.platform.interceptor.LogInterceptor"/>-->
<mvc:interceptor>
<mvc:mapping path="/**"/>
<mvc:exclude-mapping path="/statics/**"/>
<mvc:exclude-mapping path="/**/**.html"/>
<mvc:exclude-mapping path="/**/**.js"/>
<bean class="com.platform.interceptor.LogInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
实现类
在preHandle记录本次请求的时间,在afterCompletion中取出,然后对比当前时间,即可计算出本次请求的耗时。
public class LogInterceptor extends HandlerInterceptorAdapter {
private static final Log log = LogFactory.getLog(LogInterceptor.class);
/*
* (non-Javadoc)
* @see org.springframework.web.servlet.handler.HandlerInterceptorAdapter#
* preHandle(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse,
* java.lang.Object)
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
request.setAttribute("REQUEST_START_TIME", new Date());
return true;
}
/*
* (non-Javadoc)
* @see org.springframework.web.servlet.handler.HandlerInterceptorAdapter#
* postHandle(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse,
* java.lang.Object, org.springframework.web.servlet.ModelAndView)
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
}
/*
* (non-Javadoc)
* @see org.springframework.web.servlet.handler.HandlerInterceptorAdapter#
* afterCompletion(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse, java.lang.Object, java.lang.Exception)
*/
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler,
Exception ex)
throws Exception {
Date start = (Date) request.getAttribute("REQUEST_START_TIME");
Date end = new Date();
log.info("本次请求耗时:" + (end.getTime() - start.getTime()) + "毫秒;" + getRequestInfo(request).toString());
}
@Override
public void afterConcurrentHandlingStarted(HttpServletRequest request,
HttpServletResponse response,
Object handler)
throws Exception {
super.afterConcurrentHandlingStarted(request, response, handler);
}
/**
* 主要功能:获取请求详细信息
* 注意事项:无
*
* @param request 请求
* @return 请求信息
*/
private StringBuilder getRequestInfo(HttpServletRequest request) {
StringBuilder reqInfo = new StringBuilder();
UrlPathHelper urlPathHelper = new UrlPathHelper();
String urlPath = urlPathHelper.getLookupPathForRequest(request);
reqInfo.append(" 请求路径=" + urlPath);
reqInfo.append(" 来源IP=" + RequestUtil.getIpAddrByRequest(request));
String userName = "";
try {
SysUserEntity sysUser = (SysUserEntity) SecurityUtils.getSubject().getSession().getAttribute(Global.CURRENT_USER);
if (sysUser != null) {
userName = (sysUser.getUsername());
}
} catch (Exception e) {
}
reqInfo.append(" 操作人=" + (userName));
reqInfo.append(" 请求参数=" + RequestUtil.getParameters(request).toString());
return reqInfo;
}
}
J2cache使用
J2Cache 是 OSChina 目前正在使用的两级缓存框架(要求至少 Java 8)。第一级缓存使用内存(同时支持 Ehcache 2.x、Ehcache 3.x 和 Caffeine),第二级缓存使用 Redis 。 由于大量的缓存读取会导致 L2 的网络成为整个系统的瓶颈,因此 L1 的目标是降低对 L2 的读取次数。 该缓存框架主要用于集群环境中。单机也可使用,用于避免应用重启导致的缓存冷启动后对后端业务的冲击。
J2Cache 的两级缓存结构
- L1: 进程内缓存(caffeine\ehcache)
- L2: Redis 集中式缓存
数据读取
- 读取顺序 -> L1 -> L2 -> DB
- 数据更新
- 从数据库中读取最新数据,依次更新 L1 -> L2 ,发送广播清除某个缓存信息
- 接收到广播(手工清除缓存 & 一级缓存自动失效),从 L1 中清除指定的缓存信息
J2cache配置
配置文件位于platform-admin模块的resources目录下,包含三个文件,配置方法请参考文件中的注释内容。
- j2cache.properties:主配置文件,
- ehcache.xml:ehcache配置文件
- caffeine.properties:caffeine配置文件
使用说明
本系统默认使用ehcache+redis,如果想使用caffeine,请修改j2cache.properties文件,如下图

不使用redis作为缓存
使用 JGroups 组播方式,修改j2cache.properties文件,如下图

分布式session处理
支持分布式部署,将session存入j2cache中。 Shiro中的session默认是在容器内(Tomcat),我们只需继承EnterpriseCacheSessionDAO类,重写doCreate、doReadSession、doUpdate、doDelete,将session存入j2cache中即可。实现代码如下:
<bean id="cluterShiroSessionDao" class="com.platform.shiro.CluterShiroSessionDao"/>
<bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
<!-- 设置session过期时间为1小时(单位:毫秒),默认为30分钟 -->
<property name="globalSessionTimeout" value="3600000"></property>
<property name="sessionValidationSchedulerEnabled" value="true"></property>
<property name="sessionIdUrlRewritingEnabled" value="false"></property>
<property name="sessionDAO" ref="cluterShiroSessionDao"/>
</bean>
public class CluterShiroSessionDao extends EnterpriseCacheSessionDAO {
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = super.doCreate(session);
final String key = Constant.SESSION_KEY + sessionId.toString();
setShiroSession(key, session);
return sessionId;
}
@Override
protected Session doReadSession(Serializable sessionId) {
Session session = super.doReadSession(sessionId);
if (null == session) {
final String key = Constant.SESSION_KEY + sessionId.toString();
session = getShiroSession(key);
}
return session;
}
@Override
protected void doUpdate(Session session) {
super.doUpdate(session);
final String key = Constant.SESSION_KEY + session.getId().toString();
setShiroSession(key, session);
}
@Override
protected void doDelete(Session session) {
super.doDelete(session);
final String key = Constant.SESSION_KEY + session.getId().toString();
J2CacheUtils.remove(key);
}
private Session getShiroSession(String key) {
return (Session) J2CacheUtils.get(key);
}
private void setShiroSession(String key, Session session) {
J2CacheUtils.put(key, session);
}
}
# sentinel -> master-slaves servers
# cluster -> cluster servers (数据库配置无效,使用 database = 0)
# sharded -> sharded servers (密码、数据库必须在 hosts 中指定,且连接池配置无效 ; redis://user:password@127.0.0.1:6379/0)
redis.mode=cluster
统一异常处理
后台异常处理
本项目通过RRException异常类,抛出自定义异常,RRException继承RuntimeException,不能继承Exception,如果继承Exception,则Spring事务不会回滚。
public class RRException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private int code = 500;
public RRException(String msg) {
super(msg);
this.msg = msg;
}
public RRException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}
public RRException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}
public RRException(String msg, int code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
}
我们定义了RRExceptionHandler类,并加上注解@RestControllerAdvice,就可以处理所有抛出的异常,并返回JSON数据。
@RestControllerAdvice(value = {"com.platform"})
public class RRExceptionHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 自定义异常
*/
@ExceptionHandler(RRException.class)
public R handleRRException(RRException e) {
R r = new R();
r.put("code", e.getCode());
r.put("msg", e.getMessage());
return r;
}
@ExceptionHandler(DuplicateKeyException.class)
public R handleDuplicateKeyException(DuplicateKeyException e) {
logger.error(e.getMessage(), e);
return R.error("数据库中已存在该记录");
}
@ExceptionHandler(AuthorizationException.class)
public R handleAuthorizationException(AuthorizationException e) {
logger.error(e.getMessage(), e);
return R.error("没有权限,请联系管理员授权");
}
@ExceptionHandler(Exception.class)
public R handleException(Exception e) {
logger.error(e.getMessage(), e);
return R.error();
}
@ExceptionHandler(ApiRRException.class)
public Object handleApiRRException(ApiRRException e) {
HashMap result = new HashMap();
result.put("errno", e.getErrno());
result.put("errmsg", e.getErrmsg());
return result;
}
}
前端统一异常处理
前端请求统一调用Ajax.request
/**
*
Ajax.request({
url: '', //访问路径
dataType: 'json', //访问类型 'json','html'等
params: getJson(form),
resultMsg: true, false, //是否需要提示信息
type: 'GET',//,'get','post'
beforeSubmit: function (data) {},//提交前处理
successCallback: function (data) {} //提交后处理
});
*/
Ajax = function () {
//var opt = { type:'GET',dataType:'json',resultMsg:true };
function request(opt) {
//添加遮罩层
dialogLoading(true);
if (typeof opt.cache == 'undefined') {
opt.cache = false;
}
if (typeof opt == 'undefined') {
return;
}
//opt = $.extend(opt, p);
if (typeof(opt.type) == 'undefined') {
opt.type = 'GET'
}
if (typeof(opt.async) == 'undefined') {
opt.async = false;
}
if (typeof(opt.dataType) == 'undefined') {
opt.dataType = 'json'
}
if (typeof(opt.contentType) == 'undefined') {
opt.contentType = 'application/x-www-form-urlencoded;chartset=UTF-8'
}
if (typeof(opt.params) == 'undefined' || opt.params == null) {
opt.params = {};
}
opt.params.date = new Date();
if (typeof(opt.beforeSubmit) != 'undefined') {
var flag = opt.beforeSubmit(opt);
if (!flag) {
return;
}
}
if (typeof(opt.resultMsg) == 'undefined') {
opt.resultMsg = true;
}
$.ajax({
async: opt.async,
url: opt.url,
dataType: opt.dataType,
contentType: opt.contentType,
data: opt.params,
crossDomain: opt.crossDomain || false,
type: opt.type,
cache: opt.cache,
success: function (data) {
//关闭遮罩
dialogLoading(false);
//后台抛出自定义异常处理
if (typeof data == 'string' && data.indexOf("exception") > 0 || typeof data.code != 'undefined' && data.code != '0') {
var result = {code: null};
if (typeof data == 'string') {
result = eval('(' + data + ')')
} else if (typeof data == 'object') {
result = data;
}
if (opt.resultMsg && result.msg) {
layer.alert(result.msg, {icon: 5});
}
return;
}
if (opt.resultMsg && data.msg) {
layer.alert(data.msg, {icon: 6}, function () {
if (typeof(opt.successCallback) != 'undefined') {
opt.successCallback(data);
}
});
return;
}
if (typeof(opt.successCallback) != 'undefined') {
opt.successCallback(data);
}
},
error: function () {
//关闭遮罩
dialogLoading(false);
layer.alert("此页面发生未知异常,请联系管理员", {icon: 5});
}
});
}
return {
/**
* Ajax调用request
*/
request: request
};
}();
后台抛出未知异常,进入error,提示此页面发生未知异常,请联系管理员。当后台抛出自定义异常,由RRExceptionHandler类捕获,返回异常信息Json数据。
系统日志
系统日志是通过Spring AOP实现的,我们自定义了注解@SysLog,此注解只能用于方法上。
定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
String value() default "操作日志";
}
具体实现
@Aspect
@Component
public class SysLogAspect {
@Autowired
private SysLogService sysLogService;
/**
* 切点
*/
@Pointcut("@annotation(com.platform.annotation.SysLog)")
public void logPointCut() {}
/**
* 前置通知
*
* @param joinPoint 连接点
*/
@Before("logPointCut()")
public void saveSysLog(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
SysLogEntity sysLog = new SysLogEntity();
SysLog syslog = method.getAnnotation(SysLog.class);
if (syslog != null) {
//注解上的描述
sysLog.setOperation(syslog.value());
}
//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
sysLog.setMethod(className + "." + methodName + "()");
//请求的参数
Object[] args = joinPoint.getArgs();
String params = JSON.toJSONString(args[0]);
sysLog.setParams(params);
//获取request
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
//设置IP地址
sysLog.setIp(IPUtils.getIpAddr(request));
//用户名
SysUserEntity sysUserEntity = ShiroUtils.getUserEntity();
String username = "";
if ("login".equals(methodName)) {
username = params;
}
if (null != sysUserEntity) {
username = ShiroUtils.getUserEntity().getUsername();
}
sysLog.setUsername(username);
sysLog.setCreateDate(new Date());
//保存系统日志
sysLogService.save(sysLog);
}
}
使用方式
/**
* 保存
*/
@SysLog("保存菜单")
@RequestMapping("/save")
@RequiresPermissions("sys:menu:save")
public R save(@RequestBody SysMenuEntity menu) {
//数据校验
verifyForm(menu);
sysMenuService.save(menu);
return R.ok();
}
添加菜单
菜单管理,主要是对【目录、菜单、按钮】进行动态的新增、修改、删除等操作,方便开发者管理菜单。
上图是拿现有的菜单进行讲解。其中,授权标识与shiro中的注解@RequiresPermissions,定义的授权标识是一一对应的。

添加管理员
本系统默认就创建了admin账号,无需分配任何角色,就拥有最高权限。一个管理员是可以拥有多个角色的。创建的管理员默认密码是888888

定时任务模块
本系统使用开源框架Quartz,实现的定时任务,已实现分布式定时任务,可部署多台服务器,不重复执行,以及动态增加、修改、删除、暂停、恢复、立即执行定时任务。
新增定时任务
新增一个定时任务,其实很简单,只要定义一个普通的Spring Bean,然后在页面添加定时任务
@Component("testTask")
public class TestTask {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private SysUserService sysUserService;
public void test(String params) {
logger.info("我是带参数的test方法,正在被执行,参数为:" + params);
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
SysUserEntity user = sysUserService.queryObject(1L);
System.out.println(ToStringBuilder.reflectionToString(user));
}
public void test2() {
logger.info("我是不带参数的test2方法,正在被执行");
}
}

云存储模块
图片、文件上传,使用的是七牛、阿里云、腾讯云的存储服务,不能上传到本地服务器。上传到本地服务器,不利于维护,访问速度慢、占用服务器带宽等缺点,所以推荐使用云存储服务。
阿里云配置
登录阿里云控制台,获取配置信息
域名(Bucket域名)、EndPoint:
路径前缀:自己定义(如upload)
Bucket:

AccessKeyId、AccessKeySecret:
腾讯云配置

登录腾讯云控制台创建存储桶
域名、Bucket所属地区、BucketName
AppId、SecretId、SecretKey

七牛云配置
登录七牛云控制台,创建公开空间
空间名、域名:
AccessKey、SecretKey

服务器配置
存储路径:服务器存放图片的路径。egg:
/home/data/platform/upload
代理服务器:该配置是代理服务器的地址。egg:http://fly2you.cn/upload 该例使用nginx代理,下面贴上nginx配置:
如果使用tomcat代理,请确保代理路径为tomcat的访问路径。
文件上传示例
/**
* 上传文件
* @param file 文件
* @return R
* @throws Exception 异常
*/
@RequestMapping("/upload")
public R upload(@RequestParam("file") MultipartFile file) throws Exception {
if (file.isEmpty()) {
throw new RRException("上传文件不能为空");
}
//上传文件
String url = OSSFactory.build().upload(file);
//保存文件信息
SysOssEntity ossEntity = new SysOssEntity();
ossEntity.setUrl(url);
ossEntity.setCreateDate(new Date());
sysOssService.save(ossEntity);
R r = new R();
r.put("url", url);
r.put("link", url);
return r;
}
短信平台
本系统已集成腾讯云SMS功能。 https://cloud.tencent.com/document/product/382/13613
API模块
为微信小程序商城提供接口服务,platform-api实现了微信登录、接口权限验证、商城所有接口。
API的使用
小程序端通过微信授权登录,系统会生成与登录用户对应的token用户调用需要登录的接口时,只需把token传过来,服务端就知道是谁在访问接口,token如果过期,则拒绝访问,从而保证系统的安全性。 主要使用两个自定义注解实现,@LoginUser注解是获取当前登录用户的信息@IgnoreAuth是忽略用户登录,是可以直接访问的请求。
不用登录也能访问

Swagger接口文档
支持Swagger注解的方式,生成在线接口文档,使用方法:

Swagger配置
@Configuration
@EnableWebMvc
@EnableSwagger2
@ComponentScan(basePackages="com.platform.api")
public class SwaggerConfig {
@Bean
public Docket api(){
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(this.apiInfo())
.select()
//需要生成接口文档的包
.apis(RequestHandlerSelectors.basePackage("com.platform.api"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo(){
@SuppressWarnings("deprecation")
ApiInfo info=new ApiInfo(
"pwm接口文档",
"pwm接口文档",
"1.0",
"urn:tos",
"platform",
"Apache 2.0",
"http://www.apache.org/licenses/LICENSE-2.0");
return info;
}
}

日志分级输出
log4j默认是判断优先级,如果设置info,会打印info及优先级小于info的的日志,这样所有类型的日志都输出到一个文件,不便于分析。经过分析源码得知,只需更改DailyRollingFileAppender的isAsSevereAsThreshold方法即可实现日志分类打印,代码实现如下:
public class GradeLogDailyRollingFileAppender extends DailyRollingFileAppender {
@Override
public boolean isAsSevereAsThreshold(Priority priority) {
//只判断是否相等,而不判断优先级
return this.getThreshold().equals(priority);
}
}
log4j.rootLogger=INFO,stdout,info,warn,error,file
#控制台输出
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target=System.out
log4j.appender.stdout.Threshold=INFO
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss SSS}|%5p|%F.%M:%L|%m%n
#INFO所有日志
log4j.logger.file=info
log4j.appender.file=org.apache.log4j.DailyRollingFileAppender
log4j.appender.file.File=../logs/info.log
log4j.appender.file.datePattern='.'yyyy-MM-dd'.log'
log4j.appender.file.append=true
log4j.appender.file.Threshold=INFO
log4j.appender.file.encoding=UTF-8
log4j.appender.file.ImmediateFlush=true
log4j.appender.file.layout=org.apache.log4j.PatternLayout
log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss SSS}|%5p|%F.%M:%L|%m%n
#INFO日志
log4j.logger.info=info
log4j.appender.info=com.platform.log4j.GradeLogDailyRollingFileAppender
log4j.appender.info.File=../logs/info/info.log
log4j.appender.info.datePattern='.'yyyy-MM-dd'.log'
log4j.appender.info.append=true
log4j.appender.info.Threshold=INFO
log4j.appender.info.encoding=UTF-8
log4j.appender.info.ImmediateFlush=true
log4j.appender.info.layout=org.apache.log4j.PatternLayout
log4j.appender.info.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss SSS}|%5p|%F.%M:%L|%m%n
#WARN日志
log4j.appender.warn=com.platform.log4j.GradeLogDailyRollingFileAppender
log4j.appender.warn.File=../logs/warn/warn.log
log4j.appender.warn.datePattern='.'yyyy-MM-dd'.log'
log4j.appender.warn.append=true
log4j.appender.warn.Threshold=WARN
log4j.appender.warn.encoding=UTF-8
log4j.appender.warn.ImmediateFlush=true
log4j.appender.warn.layout=org.apache.log4j.PatternLayout
log4j.appender.warn.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss SSS}|%5p|%F.%M:%L|%m%n
#ERROR日志
log4j.appender.error=com.platform.log4j.GradeLogDailyRollingFileAppender
log4j.appender.error.File=../logs/error/error.log
log4j.appender.error.datePattern='.'yyyy-MM-dd'.log'
log4j.appender.error.append=true
log4j.appender.error.Threshold=ERROR
log4j.appender.error.encoding=UTF-8
log4j.appender.error.ImmediateFlush=true
log4j.appender.error.layout=org.apache.log4j.PatternLayout
log4j.appender.error.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss SSS}|%5p|%F.%M:%L|%m%n