SpringBoot项目实战总结

目录

 

1 项目信息

1.1 项目模板地址

2.特殊功能与实

2.1 日志记录功能

2.2 单点登录问题

2.3 整合Redis

2.4 消息管理模块

2.5 意见反馈模块

2.6 登录失败锁定功能

2.7 初始化时接口定义问题

2.8 验证码登录

3.0 安装部署

3.1 maven打包

3.2 脚本启动

3.3 项目开机自启动配置


1 项目信息

1.1 项目模板地址

 

2.特殊功能与实

2.1 日志记录功能

看过网上很多的SpringBoot项目记录日志的功能,使用SysLog注解,同时将日志写入数据库,写入数据库字段名如下:

即是记录什么用户,什么时间,什么ip,操作了什么接口,耗时多长,请求的什么参数,其实我也是这样记录的,但是这里存在一个问题,请求的参数多数情况下是一个 主键id,如删除用户接口是一个userId

   
    @ApiOperation("删除用户")
    @DeleteMapping(value = "/delete")
    @SysLog(msg = "删除用户")
    public ResponseEntity<ResponseMessage> deleteUser(Integer userId) {
        ResponseMessage response = sysUserService.deleteUser(userId);
        return ResponseEntity.status(response.getStatus()).body(response);
    }

最后记录参数时记录的是userId,这样导致前端使用时不知道对应的用户名是谁,不方便展示:

 

在将改进前,我们先回顾一下原始日志代码实现流程

步骤一:新建SysLog注解类,用于Controller的接口上

package com.swt.agy.annotation;

import com.swt.agy.aspect.ServiceEnum;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 系统日志注解,从方法上中提取参数,用于查、增、改操作
 *
 * @author Bleeth
 * @version 1.0
 * @date 2020-01-08 16:12
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {

    String msg() default "";
}

步骤二:Controller接口上添加SysLog注解

 
    @ApiOperation("用户登陆")
    @PostMapping(value = "/login")
    @SysLog(msg = "用户登陆")
    public ResponseEntity<ResponseMessage> login(@RequestBody SysLoginForm form) {
        ResponseMessage response = sysLoginService.login(form);
        return ResponseEntity.status(response.getStatus()).body(response);
    }

步骤三:AOP进行处理SysLog的注解(此代码仅供参考,有修改)

package com.swt.agy.aspect;

import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.extension.service.IService;
import com.swt.agy.annotation.SysLog;
import com.swt.agy.config.ResponseBean;
import com.swt.agy.config.ResponseMessage;
import com.swt.agy.module.sys.entity.SysLogEntity;
import com.swt.agy.module.sys.service.SysLogService;
import com.swt.agy.module.sys.service.SysMsgService;
import com.swt.agy.module.sys.service.SysUserService;
import com.swt.agy.shiro.ShiroUtils;
import com.swt.agy.util.IpUtil;
import com.swt.agy.util.RedisUtil;
import com.swt.agy.util.SpringContextUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * 系统日志,切面处理类
 *
 * @author Bleeth
 * @version 1.0
 * @date 2020-01-08 16:12
 */
@Slf4j
@Aspect
@Component
public class SysLogAspect {


    @Autowired
    private SysLogService sysLogService;


    @Autowired
    private SysMsgService sysMsgService;

    @Autowired
    private SysUserService sysUserService;


    @Pointcut("@annotation(com.swt.agy.annotation.SysLog)")
    public void logPointCut() {

    }

    @Around("logPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        String methodParam = null;
        String methodMsg = null;

        DateTime current = DateUtil.date();
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Method method = getMethod(point);
        String methodName = method.getName();

        //想办法获取参数
        SysLog sysLog = method.getAnnotation(SysLog.class);
      
    
        methodParam = parseKey(paramField, method, point.getArgs());
        methodMsg = sysLog.msg();
      

        long beginTime = System.currentTimeMillis();
        //执行方法
        Object result = point.proceed();
        //执行时长(毫秒)
        long time = System.currentTimeMillis() - beginTime;

        Integer userId = ShiroUtils.getUserId();
        if (userId == null) {
            return result;
        }

        SysLogEntity entity = new SysLogEntity();
        entity.setUserId(userId);
        entity.setParams(methodParam);
        entity.setCreatedTime(current);
        entity.setIp(IpUtil.getIpAddr(request));
        entity.setMethod(methodName);
        entity.setTime(time);
        entity.setOperation(methodMsg);
        entity.setRemark("");
        if (result instanceof ResponseEntity) {
            ResponseEntity responseEntity = (ResponseEntity) result;
            Object body = responseEntity.getBody();
            if (body instanceof ResponseMessage) {
                ResponseMessage responseMessage = (ResponseMessage) body;
                entity.setRemark(responseMessage.getMessage());
            }
            if (body instanceof ResponseBean) {
                ResponseBean responseBean = (ResponseBean) body;
                entity.setRemark(responseBean.getMessage());
            }
        }

        sysLogService.save(entity);
        return result;
    }


    /**
     * 获取被拦截方法对象
     * <p>
     * MethodSignature.getMethod() 获取的是顶层接口或者父类的方法对象
     * 而缓存的注解在实现类的方法上
     * 所以应该使用反射获取当前对象的方法对象
     */
    public Method getMethod(ProceedingJoinPoint point) {
        //获取参数的类型
        Object[] args = point.getArgs();
        Class[] argTypes = new Class[point.getArgs().length];
        for (int i = 0; i < args.length; i++) {
            argTypes[i] = args[i].getClass();
        }
        Method method = null;
        try {
            method = point.getTarget().getClass().getMethod(point.getSignature().getName(), argTypes);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (SecurityException e) {
            e.printStackTrace();
        }
        return method;
    }


    private String parseKey(String key, Method method, Object[] args) {
        //获取被拦截方法参数名列表(使用Spring支持类库)
        LocalVariableTableParameterNameDiscoverer u =
                new LocalVariableTableParameterNameDiscoverer();
        String[] paraNameArr = u.getParameterNames(method);

        //使用SPEL进行key的解析
        ExpressionParser parser = new SpelExpressionParser();
        //SPEL上下文
        StandardEvaluationContext context = new StandardEvaluationContext();
        //把方法参数放入SPEL上下文中
        for (int i = 0; i < paraNameArr.length; i++) {
            context.setVariable(paraNameArr[i], args[i]);
        }

        String value = parser.parseExpression(key).getValue(context, String.class);
        return value;
    }
}

改进思路:针对Controller中接口的参数形式,我们得知大概几种情况

1.增,改接口一般都会对应表中实体的名称,此时可以从参数列表中获取我们需要的参数,如创建用户,我们关注的是创建的用户名是什么,此时可以从form表单中获取得到,同类,修改用户接口可从修改表单中获取


    @ApiOperation("创建用户")
    @PostMapping(value = "/create")
    @SysLog(msg = "创建用户", field = "#form.username")
    public ResponseEntity<ResponseBean> createUser(AddUserForm form) {
        ResponseBean response = sysUserService.createUser(form);
        return ResponseEntity.status(response.getStatus()).body(response);
    }

2.删除接口都是提供对应实体的id,此时需要从数据库中查询记录实体名是什么,如删除用户,我们需要用SysUserService查询userId得到SysUserEntity,后获取username,如下


    @ApiOperation("删除用户")
    @DeleteMapping(value = "/delete")
    @SysLog(msg = "删除用户", field = "#userId",serviceEnum = ServiceEnum.SYS_USER,queryKey = "username")
    public ResponseEntity<ResponseMessage> deleteUser(Integer userId) {
        ResponseMessage response = sysUserService.deleteUser(userId);
        return ResponseEntity.status(response.getStatus()).body(response);
    }

SysLog 中 field 记录的是主键id,serviceEnum 是用哪个Service进行查询(配合Mybatis使用Service中的selectById方法),queryKey 指查询后需要获取哪个字段的值

步骤说明如下:

步骤一:扩展SysLog注解类,用于Controller的接口上

package com.swt.agy.annotation;

import com.swt.agy.aspect.ServiceEnum;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 系统日志注解,从方法上中提取参数,用于查、增、改操作
 *
 * @author Bleeth
 * @version 1.0
 * @date 2020-01-08 16:12
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {

    String msg() default "";

    String field() default "";

    ServiceEnum serviceEnum() default ServiceEnum.EMPTY;

    String queryKey() default "";
}

步骤二:每个Service上添加一个Bean名称,Bean名称不允许重复

/**
 * @author BleethNie
 * @version 1.0
 * @date 2020-01-08 17:48
 **/
@Service("SysLoginService")
public class SysLoginServiceImpl implements SysLoginService {

    private static DateTime LAST_DELETE_CAPTION_TIME;

    @Value(value = "${swt.login.mac}")
    private boolean swtLoginMac;

    @Autowired
    private SwtSsoConfigProperties swtSsoConfigProperties;

...


}

 

 

步骤三:添加辅助枚举类

package com.swt.agy.aspect;

import lombok.Getter;

/**
 * @author BleethNie
 * @version 1.0
 * @date 2020-09-28 18:42
 **/
@Getter
public enum ServiceEnum {

    EMPTY(""),

    SYS_USER("SysUserService"),

    private String serviceName;

    ServiceEnum(String serviceName) {
        this.serviceName = serviceName;
    }

}

步骤四:AOP解析

package com.swt.agy.aspect;

import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.extension.service.IService;
import com.swt.agy.annotation.SysLog;
import com.swt.agy.config.ResponseBean;
import com.swt.agy.config.ResponseMessage;
import com.swt.agy.module.sys.entity.SysLogEntity;
import com.swt.agy.module.sys.service.SysLogService;
import com.swt.agy.module.sys.service.SysMsgService;
import com.swt.agy.module.sys.service.SysUserService;
import com.swt.agy.shiro.ShiroUtils;
import com.swt.agy.util.IpUtil;
import com.swt.agy.util.RedisUtil;
import com.swt.agy.util.SpringContextUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * 系统日志,切面处理类
 *
 * @author Bleeth
 * @version 1.0
 * @date 2020-01-08 16:12
 */
@Slf4j
@Aspect
@Component
public class SysLogAspect {


    @Autowired
    private SysLogService sysLogService;

 

    @Autowired
    private SysUserService sysUserService;


    @Pointcut("@annotation(com.swt.agy.annotation.SysLog)")
    public void logPointCut() {

    }

    @Around("logPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        String methodParam = null;
        String methodMsg = null;

        DateTime current = DateUtil.date();
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Method method = getMethod(point);
        String methodName = method.getName();

        //想办法获取参数
        SysLog sysLog = method.getAnnotation(SysLog.class);
        ServiceEnum serviceEnum = sysLog.serviceEnum();
        String paramField = sysLog.field();
        String queryKey = sysLog.queryKey();
        methodParam = parseKey(paramField, method, point.getArgs());
        methodMsg = sysLog.msg();
        if (!serviceEnum.equals(ServiceEnum.EMPTY)) {
            String serviceName = serviceEnum.getServiceName();
            IService iService = (IService) SpringContextUtil.getBean(serviceName);
            boolean isNum = NumberUtil.isNumber(methodParam);
            if (isNum && iService != null) {
                Integer id = Convert.toInt(methodParam);
                Object entity = iService.getById(id);
                if (entity != null) {
                    JSONObject jsonObject = new JSONObject(entity);
                    methodParam = jsonObject.getStr(queryKey);
                }
            }
        }

        long beginTime = System.currentTimeMillis();
        //执行方法
        Object result = point.proceed();
        //执行时长(毫秒)
        long time = System.currentTimeMillis() - beginTime;

        Integer userId = ShiroUtils.getUserId();
        if (userId == null) {
            return result;
        }

        SysLogEntity entity = new SysLogEntity();
        entity.setUserId(userId);
        entity.setParams(methodParam);
        entity.setCreatedTime(current);
        entity.setIp(IpUtil.getIpAddr(request));
        entity.setMethod(methodName);
        entity.setTime(time);
        entity.setOperation(methodMsg);
        entity.setRemark("");
        if (result instanceof ResponseEntity) {
            ResponseEntity responseEntity = (ResponseEntity) result;
            Object body = responseEntity.getBody();
            if (body instanceof ResponseMessage) {
                ResponseMessage responseMessage = (ResponseMessage) body;
                entity.setRemark(responseMessage.getMessage());
            }
            if (body instanceof ResponseBean) {
                ResponseBean responseBean = (ResponseBean) body;
                entity.setRemark(responseBean.getMessage());
            }
        }

      
        sysLogService.save(entity);
        return result;
    }


    /**
     * 获取被拦截方法对象
     * <p>
     * MethodSignature.getMethod() 获取的是顶层接口或者父类的方法对象
     * 而缓存的注解在实现类的方法上
     * 所以应该使用反射获取当前对象的方法对象
     */
    public Method getMethod(ProceedingJoinPoint point) {
        //获取参数的类型
        Object[] args = point.getArgs();
        Class[] argTypes = new Class[point.getArgs().length];
        for (int i = 0; i < args.length; i++) {
            argTypes[i] = args[i].getClass();
        }
        Method method = null;
        try {
            method = point.getTarget().getClass().getMethod(point.getSignature().getName(), argTypes);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (SecurityException e) {
            e.printStackTrace();
        }
        return method;
    }


    private String parseKey(String key, Method method, Object[] args) {
        //获取被拦截方法参数名列表(使用Spring支持类库)
        LocalVariableTableParameterNameDiscoverer u =
                new LocalVariableTableParameterNameDiscoverer();
        String[] paraNameArr = u.getParameterNames(method);

        //使用SPEL进行key的解析
        ExpressionParser parser = new SpelExpressionParser();
        //SPEL上下文
        StandardEvaluationContext context = new StandardEvaluationContext();
        //把方法参数放入SPEL上下文中
        for (int i = 0; i < paraNameArr.length; i++) {
            context.setVariable(paraNameArr[i], args[i]);
        }

        String value = parser.parseExpression(key).getValue(context, String.class);
        return value;
    }
}

2.2 单点登录问题

 

2.3 整合Redis

整合Redis不是啥难事,网上教程很多,自己在网上找的RedisUtil工具,在此基础上进行了稍微的修改,使其可以直接set和get对象

package com.swt.agy.util;

import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * redisTemplate封装
 */
@Component
public class RedisUtil {

    public static final long ONE_MIN_EXPIRE_TIME = 1 * 60;

    public static final long DEFAULT_EXPIRE_TIME = 5 * 60;

    public static final long TEN_MIN_EXPIRE_TIME = 10 * 60;


    public static final long ONE_HOUR_EXPIRE_TIME = 60 * 60;

    public static final long ONE_DAY_EXPIRE_TIME = 24 * 60 * 60;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public RedisUtil(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 指定缓存失效时间
     *
     * @param key  键
     * @param time 时间(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根据key 获取过期时间
     *
     * @param key 键 不能为null
     * @return 时间(秒) 返回0代表为永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /**
     * 判断key是否存在
     *
     * @param key 键
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除缓存
     *
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }

    //============================String=============================

    /**
     * 普通缓存获取
     *
     * @param key 键
     * @return 值
     */
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }


    public <T> T getObject(String key, Class<T> clazz) {
        if (key == null) {
            return null;
        }
        String str = (String) (redisTemplate.opsForValue().get(key));
        if (str == null) {
            return null;
        }
        JSONObject jsonObject = JSONUtil.parseObj(str);
        if (jsonObject == null) {
            return null;
        }
        return jsonObject.toBean(clazz);
    }

    public <E> List<E> getArray(String key, Class<E> clazz) {
        if (key == null) {
            return null;
        }
        String str = (String) (redisTemplate.opsForValue().get(key));
        if (str == null) {
            return null;
        }
        JSONArray array = JSONUtil.parseArray(str);
        if (array == null) {
            return null;
        }
        return array.toList(clazz);
    }


    public JSONArray getJSONArray(String key) {

        if (key == null) {
            return null;
        }
        String str = (String) (redisTemplate.opsForValue().get(key));
        if (str == null) {
            return null;
        }
        JSONArray jsonArray = JSONUtil.parseArray(str);
        return jsonArray;
    }

    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean setObject(String key, Object value, long time) {
        JSONObject jsonObject = JSONUtil.parseObj(value);
        try {
            redisTemplate.opsForValue().set(key, jsonObject.toStringPretty());
            expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean setArray(String key, Object value, long time) {
        JSONArray array = JSONUtil.parseArray(value);
        try {
            redisTemplate.opsForValue().set(key, array.toStringPretty());
            expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通缓存放入并设置时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     * @return true成功 false 失败
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }


    /**
     * 递增
     *
     * @param key   键
     * @param delta 要增加几(大于0)
     * @return
     */
    public long incr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递增因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }

    /**
     * 递减
     *
     * @param key   键
     * @param delta 要减少几(小于0)
     * @return
     */
    public long decr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递减因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }

    //================================Map=================================

    /**
     * HashGet
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return 值
     */
    public Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }

    /**
     * 获取hashKey对应的所有键值
     *
     * @param key 键
     * @return 对应的多个键值
     */
    public Map<Object, Object> hmget(String key) {
        HashOperations<String, Object, Object> opsHash = redisTemplate.opsForHash();
        return opsHash.entries(key);
    }

    /**
     * HashSet
     *
     * @param key 键
     * @param map 对应多个键值
     * @return true 成功 false 失败
     */
    public boolean hmset(String key, Map<String, Object> map) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * HashSet 并设置时间
     *
     * @param key  键
     * @param map  对应多个键值
     * @param time 时间(秒)
     * @return true成功 false失败
     */
    public boolean hmset(String key, Map<String, Object> map, long time) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @param time  时间(秒)  注意:如果已存在的hash表有时间,这里将会替换原有的时间
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除hash表中的值
     *
     * @param key  键 不能为null
     * @param item 项 可以使多个 不能为null
     */
    public void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }

    /**
     * 判断hash表中是否有该项的值
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return true 存在 false不存在
     */
    public boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }

    /**
     * hash递增 如果不存在,就会创建一个 并把新增后的值返回
     *
     * @param key  键
     * @param item 项
     * @param by   要增加几(大于0)
     * @return
     */
    public double hincr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }

    /**
     * hash递减
     *
     * @param key  键
     * @param item 项
     * @param by   要减少记(小于0)
     * @return
     */
    public double hdecr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }

    //============================set=============================

    /**
     * 根据key获取Set中的所有值
     *
     * @param key 键
     * @return
     */
    public Set<Object> sGet(String key) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 根据value从一个set中查询,是否存在
     *
     * @param key   键
     * @param value 值
     * @return true 存在 false不存在
     */
    public boolean sHasKey(String key, Object value) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将数据放入set缓存
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSet(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 将set数据放入缓存
     *
     * @param key    键
     * @param time   时间(秒)
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSetAndTime(String key, long time, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0) {
                expire(key, time);
            }
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 获取set缓存的长度
     *
     * @param key 键
     * @return
     */
    public long sGetSetSize(String key) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 移除值为value的
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 移除的个数
     */
    public long setRemove(String key, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    //===============================list=================================

    /**
     * 获取list缓存的内容
     *
     * @param key   键
     * @param start 开始
     * @param end   结束  0 到 -1代表所有值
     * @return
     */
    public List<Object> lGet(String key, long start, long end) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 获取list缓存的长度
     *
     * @param key 键
     * @return
     */
    public long lGetListSize(String key) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 通过索引 获取list中的值
     *
     * @param key   键
     * @param index 索引  index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推
     * @return
     */
    public Object lGetIndex(String key, long index) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @return
     */
    public boolean lSet(String key, Object value) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, Object value, long time) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @return
     */
    public boolean lSet(String key, List<Object> value) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, List<Object> value, long time) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根据索引修改list中的某条数据
     *
     * @param key   键
     * @param index 索引
     * @param value 值
     * @return
     */
    public boolean lUpdateIndex(String key, long index, Object value) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 移除N个值为value
     *
     * @param key   键
     * @param count 移除多少个
     * @param value 值
     * @return 移除的个数
     */
    public long lRemove(String key, long count, Object value) {
        try {
            Long remove = redisTemplate.opsForList().remove(key, count, value);
            return remove;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

}

 

 

 

2.4 消息管理模块

 

2.5 意见反馈模块

 

2.6 登录失败锁定功能

 

2.7 初始化时接口定义问题

 

2.8 验证码登录

实现验证码登录流程:

步骤一:前端调用获取验证码接口,调用接口时提供一个唯一UUID,提供的目的是将登录用户和验证码进行绑定


获取验证码Controller层

 
    @ApiOperation("获取验证码")
    @GetMapping(value = "/captcha", produces = "image/png")
    @ApiImplicitParam(name = "uuid", value = "随机码", required = true, paramType = "query", dataTypeClass = String.class, defaultValue = "8e04634d-3420-47f8-a1af-392d77301bb9")
    public byte[] captcha(String uuid) {
        return sysLoginService.captcha(uuid);
    }

 获取验证码Service实现层

 @Override
    public byte[] captcha(String uuid) {
        //生成captcha
        LineCaptcha captcha = CaptchaUtil.createLineCaptcha(150, 50);
        String code = captcha.getCode();

        SysCaptchaEntity sysCaptchaEntity = new SysCaptchaEntity();
        sysCaptchaEntity.setCaptcha(code);
        sysCaptchaEntity.setUuid(uuid);
        sysCaptchaEntity.setCreatedTime(current);
        sysCaptchaEntity.setExpireTime(expireDay);
        sysCaptchaEntity.setStatus(SysCaptchaEntity.STATUS_VALID);
        int row = sysCaptchaDao.insert(sysCaptchaEntity);
        if (row == 0) {
            throw new AgyException("获取验证码失败");
        }
        return captcha.getImageBytes();
    }

验证码生成来源于Hutool中的工具类 

步骤二:前端调用登录接口,登录时提供用户名,密码,上一步得到的验证码还有uuid

 

登录接口Controller层


    @ApiOperationSupport(order = 3)
    @ApiOperation("用户登陆")
    @PostMapping(value = "/login")
    @SysLog(msg = "用户登陆", field = "#form.username")
    public ResponseEntity<ResponseMessage> login(@RequestBody SysLoginForm form) {
        ResponseMessage response = sysLoginService.login(form);
        return ResponseEntity.status(response.getStatus()).body(response);
    }

登录接口Service实现层

@Override
    public ResponseMessage login(SysLoginForm form) {
        HttpStatus httpStatus = HttpStatus.OK;

        //参数校验
        ValidatorUtil.validateEntity(form);

        //验证码校验
        String uuid = form.getUuid();
        String captcha = form.getCaptcha();
        String username = form.getUsername();


        SysCaptchaEntity sysCaptchaEntity = sysCaptchaDao.queryEntityByUuid(uuid);

        if (sysCaptchaEntity == null) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            return new ResponseMessage(httpStatus.value(), "验证码校验失败");
        }

        String sysCaptcha = sysCaptchaEntity.getCaptcha();
        sysCaptchaDao.useEntity(uuid);
        if (StrUtil.isEmpty(sysCaptcha) || !StrUtil.equalsIgnoreCase(sysCaptcha, captcha)) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            return new ResponseMessage(httpStatus.value(), "验证码校验失败");
        }

        String message = "登陆成功";
        Subject subject = ShiroUtils.getSubject();
        try {
            UsernamePasswordToken token = null;
            if (swtLoginMac) {
                token = new UsernamePasswordToken(username, form.getPassword());
            } else {
                token = new UsernamePasswordToken(username, form.getPassword());
            }
            subject.login(token);

            //登录成功,失败次数重置
            sysUserDao.updateUserLoginFail("1", username, 0);

        } catch (UnknownAccountException e) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            message = e.getMessage();
        } catch (IncorrectCredentialsException e) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            Integer failCount = sysUserDao.selectUserLoginFail(username);
            if (failCount == null) {
            } else if (failCount >= 4) {
                message = "账号已被锁定,请联系管理员";
                sysUserDao.updateUserLoginFail("0", username, 0);
            } else {
                failCount++;
                message = "密码错误,剩余重试次数" + (5 - failCount) + "次";
                sysUserDao.updateUserLoginFail("1", username, failCount);
            }
        } catch (LockedAccountException e) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            message = e.getMessage();
        } catch (AccountException e) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            message = e.getMessage();
        } catch (AuthenticationException e) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
            message = e.getMessage();
        }
        return new ResponseMessage(httpStatus.value(), message);
    }

 

3.0 安装部署

3.1 maven打包

springboot项目打包,一般存在的问题是如何打瘦包,即lib依赖和主程序jar包分开,Fat Jar的主要问题是打出的包太大,部署安装不太方便,所以本节主要讲SpringBoot如何打瘦包。

步骤一:在maven的pom中增加插件maven-assembly-plugin和maven-jar-plugin,代码如下:

<build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>install</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                        <configuration>
                            <!-- 打包生成的文件名 -->
                            <finalName>${project.artifactId}</finalName>
                            <!-- jar 等压缩文件在被打包进入 zip、tar.gz 时是否压缩,设置为 false 可加快打包速度 -->
                            <recompressZippedFiles>false</recompressZippedFiles>
                            <!-- 打包生成的文件是否要追加 release.xml 中定义的 id 值 -->
                            <appendAssemblyId>true</appendAssemblyId>
                            <!-- 指向打包描述文件 package.xml -->
                            <descriptors>
                                <descriptor>package.xml</descriptor>
                            </descriptors>
                            <!-- 打包结果输出的基础目录 -->
                            <outputDirectory>${project.build.directory}/</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <excludes>
                        <exclude>*.yml</exclude>
                        <exclude>application.properties</exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

maven-assembly-plugin:该插件可以使用户根据自己的需求自定义打包规则和打包类型,自定义的信息在package.xml中编写

maven-jar-plugin:将源码进行打包,打出的包不含依赖包

步骤二:项目根目录下添加package.xml,内容如下:

<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.0.0 http://maven.apache.org/xsd/assembly-2.0.0.xsd">


  <id>release</id>
  <!--
      设置打包格式,可同时设置多种格式,常用格式有:dir、zip、tar、tar.gz
      dir 格式便于在本地测试打包结果
      zip 格式便于 windows 系统下解压运行
      tar、tar.gz 格式便于 linux 系统下解压运行
  -->
  <formats>
    <format>dir</format>
  </formats>

  <!-- 打 zip 设置为 true 时,会在 zip 包中生成一个根目录,打 dir 时设置为 false 少层目录 -->
  <includeBaseDirectory>true</includeBaseDirectory>


  <fileSets>
    <!-- src/main/resources 全部 copy 到 config 目录下 -->
    <fileSet>
      <directory>${basedir}/src/main/resources</directory>
      <outputDirectory>config</outputDirectory>
      <includes>
        <include>application*.yml</include>
        <include>logback*.xml</include>
      </includes>
    </fileSet>

    <!-- 项目根下面的脚本文件 copy 到根目录下 -->
    <fileSet>
      <directory>${basedir}</directory>
      <!-- 脚本文件在 linux 下的权限设为 755,无需 chmod 可直接运行 -->
      <fileMode>755</fileMode>
      <includes>
        <include>*.sh</include>
        <include>*.bat</include>
        <include>*.md</include>
      </includes>
    </fileSet>
  </fileSets>


  <!-- 依赖的 jar 包 copy 到 lib 目录下 -->
  <dependencySets>
    <dependencySet>
      <outputDirectory>lib</outputDirectory>
    </dependencySet>
  </dependencySets>

</assembly>

配置完成,运行mvn package 则仅打包,为thin jar,运行mvn install则打完整包,会按照package.xml中描述进行打包,将依赖包,配置文件等整合在一起

示例项目结构:

打包后的结构,每次项目更新只需要mvn package打包,然后将该包放入最终结构的lib目录下,执行stop.sh 和 start.sh脚本启动停止;当第一次安装部署时用mvn install生成完整目录结构

3.2 脚本启动

上面的打包部署我们讲完了,但是启动的时候不可能用java命令执行程序,不够方便,下面则提供脚本,方便大家启动程序

start.sh内容如下:

#!/usr/bin/env bash
#!/bin/bash
# ----------------------------------------------------------------------
# name:         start.sh
# version:      1.0
#
# 使用说明:
# 1: 该脚本使用前需要首先修改 MAIN_CLASS 值,使其指向实际的启动类
#
# 2:使用命令行 ./run.sh  启动项目
#
# 3: JAVA_OPTS 可传入标准的 java 命令行参数,例如 -Xms256m -Xmx1024m 这类常用参数
#
#
# ----------------------------------------------------------------------

# 启动入口类,该脚本文件用于别的项目时要改这里
MAIN_CLASS=com.swt.agy.AgyApp

COMMAND="$1"


# Java 命令行参数,根据需要开启下面的配置,改成自己需要的,注意等号前后不能有空格
# JAVA_OPTS="-Xms256m -Xmx1024m -Dundertow.port=80 -Dundertow.host=0.0.0.0"
# JAVA_OPTS="-Dundertow.port=80 -Dundertow.host=0.0.0.0"

# 生成 class path 值
APP_BASE_PATH=$(cd `dirname $0`; pwd)
CP=${APP_BASE_PATH}/config:${APP_BASE_PATH}/lib/*

function backendStart()
{
    echo "backend 运行为后台进程,并且不在控制台输出信息"
    # 运行为后台进程,并在控制台输出信息
    # java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS} &

    # 运行为后台进程,并且不在控制台输出信息
    nohup java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS} >/dev/null 2>&1 &

    # 运行为后台进程,并且将信息输出到 output.log 文件
    # nohup java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS} > output.log &

    # 运行为非后台进程,多用于开发阶段,快捷键 ctrl + c 可停止服务
    # java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS}
}

function nohupStart()
{
    echo "nohup 运行为后台进程,并且将信息输出到 output.log 文件"
     nohup java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS} > output.log &
}

function start()
{
   echo "运行为非后台进程,多用于开发阶段,快捷键 ctrl + c 可停止服务"
   java -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS}
}

function debugStart()
{
   echo "运行调试模式,多用于开发阶段"
   java  -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005  -Xverify:none ${JAVA_OPTS} -cp ${CP} ${MAIN_CLASS}
}



if [[ "$COMMAND" == "backend" ]]; then
	backendStart
elif [[ "$COMMAND" == "nohup" ]]; then
  nohupStart
elif [[ "$COMMAND" == "debug" ]]; then
  debugStart
else
  start
fi

需要注意修改的是 MAIN_CLASS,将该项修改为自己项目运行的main类,stop.sh中的同理

stop.sh内容如下:

#!/usr/bin/env bash
#!/bin/bash
# ----------------------------------------------------------------------
# name:         run.sh
# version:      1.0
# author:       yangfuhai
# email:        fuhai999@gmail.com
#
# 使用说明:
# 1: 该脚本使用前需要首先修改 MAIN_CLASS 值,使其指向实际的启动类
#
# 2:使用命令行 ./run.sh start | stop | restart 可启动/关闭/重启项目

#
# 3: JAVA_OPTS 可传入标准的 java 命令行参数,例如 -Xms256m -Xmx1024m 这类常用参数
#
# ----------------------------------------------------------------------

# 启动入口类,该脚本文件用于别的项目时要改这里
MAIN_CLASS=com.swt.agy.AgyApp


# Java 命令行参数,根据需要开启下面的配置,改成自己需要的,注意等号前后不能有空格
# JAVA_OPTS="-Xms256m -Xmx1024m -Dundertow.port=80 -Dundertow.host=0.0.0.0"
# JAVA_OPTS="-Dundertow.port=80 -Dundertow.host=0.0.0.0"

# 生成 class path 值
APP_BASE_PATH=$(cd `dirname $0`; pwd)
CP=${APP_BASE_PATH}/config:${APP_BASE_PATH}/lib/*

function stop()
{
    # 支持集群部署
    kill `pgrep -f ${APP_BASE_PATH}` 2>/dev/null

    # kill 命令不使用 -9 参数时,会回调 onStop() 方法,确定不需要此回调建议使用 -9 参数
    # kill `pgrep -f ${MAIN_CLASS}` 2>/dev/null

    # 以下代码与上述代码等价
    # kill $(pgrep -f ${MAIN_CLASS}) 2>/dev/null
}

stop

 需要注意的:

  • start.sh和stop.sh脚本来源于JFinal项目中的脚本,再此进行了一些修改
  • start.sh启动方法./start.sh debug 配合 idea进行远程调试  ./start.sh nohup 后台启动   ./start.sh  前端启动,关闭shell则停止运行
  • start.sh脚本用于linux时需要赋予可执行权限和用vim修改为\n换行符(set ff=unix)

3.3 项目开机自启动配置

项目开机自启动,利用 systemctl 系统服务管理器指令,因为前面一节我们介绍了脚本启动程序,此时配置开机启动脚本相对简单,并且网上参考资料较多,在此贴上一份Elasticsearch的自启动脚本配置,仅供参考

[Unit]
Description= elasticsearch全文检索服务器
After=network.target remote-fs.target nss-lookup.target


[Service]
Type=forking
ExecStart=/home/shuwei/gapenv/elasticsearch-6.4.3/bin/elasticsearch -d -p /home/shuwei/gapenv/elasticsearch-6.4.3/esearch.pid
ExecStop=kill -INT `cat /home/shuwei/gapenv/elasticsearch-6.4.3/esearch.pid`
User=elsearch
Group=elsearch

LimitCORE=infinity
LimitNOFILE=65536
LimitNPROC=65536


[Install]
WantedBy=multi-user.target

 

 

 

 

 

  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
在实际的Spring Boot项目中,前端页面的开发通常采用前后端分离的方式。前端使用Vue.js框架进行开发,后端采用JSON报文的接口方式进行数据传输。 以下是一个实际的Spring Boot项目中前端页面开发的一般步骤: 1. 导入Vue组件库:引入饿了么的CSS和关键的JS文件。 2. 配置Spring Boot的静态资源:在Spring Boot的配置文件中配置静态资源路径,将前端页面所需的CSS、JS以及其他静态文件放置在指定目录下。 3. 编写HTML页面:根据项目需求,编写HTML页面的内容,包括页面的结构、样式和布局。 4. 引入Vue和其他JS文件:在HTML页面中引入Vue.js以及其他需要的JS文件,如axios.js,用于实现与后端接口的数据交互。 5. 编写页面的业务逻辑:在Vue实例中编写页面的业务逻辑,包括数据的获取、展示和交互的处理等。 6. 发送请求与接收数据:使用axios库发送HTTP请求与后端接口进行数据交互,将数据展示在页面上。 7. 处理页面事件方法:根据页面需求,编写事件方法来处理用户的点击、输入等操作,并实现相应的功能。 总结: 在Spring Boot项目中,实战前端页面的开发可以通过导入Vue组件库、配置静态资源、编写HTML页面、引入Vue和其他JS文件、编写页面的业务逻辑、发送请求与接收数据以及处理页面事件方法等步骤来完成。 参考文献: 文章目录中的相关内容 js文件的内容

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值