跳至主要內容

核心模块

李鹏军2024/4/17大约 18 分钟开源教程文档

核心模块

功能权限设计

权限相关的表结构,如下图所示

/img/start/05/img.png
/img/start/05/img.png
  • sys_user[用户]表,保存用户相关数据,通过sys_user_role[用户与角色关联]表,与sys_role[角色]表关联;sys_menu[菜单]表通过sys_role_menu[菜单与角色关联]表,与sys_role[角色]表关联
  • sys_menu表,保存菜单相关数据,并在perms字段里,保存了shiro的权限标识,也就是说拥有此菜单,就拥有perms字段里的所有权限,比如,某用户拥有的菜单权限标识 sys:menu:list,就可以访问下面的方法 /img/start/05/img_1.png
  • sys_dept表,保存部门相关数据,数据权限也是根据部门进行过滤的,下一小节具体讲解
  • 在配置文件里,anon表示不经过shiro处理,authc表示经过shiro处理,这样就保证没有权限的请求有效的拒绝。 /img/start/05/img_2.png
  • 接下来,我们看看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类型。

/img/start/05/img_3.png
/img/start/05/img_3.png

生成过滤条件的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},这样就完成了数据权限的代码实现,最后在角色管理页面配置数据权限。 /img/start/05/img_4.png

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注入风险

/img/start/05/img_5.png
/img/start/05/img_5.png

日志拦截器

为了方便开发调试需要日志拦截器。(登录拦截和权限拦截已在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文件,如下图

/img/start/05/img_6.png
/img/start/05/img_6.png

不使用redis作为缓存

使用 JGroups 组播方式,修改j2cache.properties文件,如下图

/img/start/05/img_7.png
/img/start/05/img_7.png

分布式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();
}

添加菜单

菜单管理,主要是对【目录、菜单、按钮】进行动态的新增、修改、删除等操作,方便开发者管理菜单。

/img/start/05/img_8.png 上图是拿现有的菜单进行讲解。其中,授权标识与shiro中的注解@RequiresPermissions,定义的授权标识是一一对应的。

/img/start/05/img_9.png
/img/start/05/img_9.png

添加管理员

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

/img/start/05/img_10.png
/img/start/05/img_10.png

定时任务模块

本系统使用开源框架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方法,正在被执行");
    }
}
/img/start/05/img_11.png
/img/start/05/img_11.png

云存储模块

图片、文件上传,使用的是七牛、阿里云、腾讯云的存储服务,不能上传到本地服务器。上传到本地服务器,不利于维护,访问速度慢、占用服务器带宽等缺点,所以推荐使用云存储服务。

阿里云配置

/img/start/05/img_12.png 登录阿里云控制台,获取配置信息

域名(Bucket域名)、EndPoint:

/img/start/05/img_13.png 路径前缀:自己定义(如upload)

Bucket:

/img/start/05/img_14.png
/img/start/05/img_14.png

AccessKeyId、AccessKeySecret:

/img/start/05/img_15.png/img/start/05/img_16.png

腾讯云配置

/img/start/05/img_17.png
/img/start/05/img_17.png

登录腾讯云控制台创建存储桶

/img/start/05/img_18.png 域名、Bucket所属地区、BucketName

/img/start/05/img_19.png AppId、SecretId、SecretKey

/img/start/05/img_20.png
/img/start/05/img_20.png

七牛云配置

/img/start/05/img_21.png 登录七牛云控制台,创建公开空间

空间名、域名:

/img/start/05/img_22.png AccessKey、SecretKey

/img/start/05/img_23.png
/img/start/05/img_23.png

服务器配置

/img/start/05/img_24.png 存储路径:服务器存放图片的路径。egg:/home/data/platform/upload 代理服务器:该配置是代理服务器的地址。egg:http://fly2you.cn/upload 该例使用nginx代理,下面贴上nginx配置:

/img/start/05/img_25.png 如果使用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是忽略用户登录,是可以直接访问的请求。

/img/start/05/img_26.png 不用登录也能访问

/img/start/05/img_27.png
/img/start/05/img_27.png

Swagger接口文档

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

/img/start/05/img_28.png
/img/start/05/img_28.png

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;
    }
}
/img/start/05/img_29.png
/img/start/05/img_29.png

日志分级输出

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