SpringBoot-MyBatis资源分享网站项目笔记

1 项目概述

1.1项目介绍
    基于SpringBoot+Mybatis框架和MySql数据库做了一个资源分享网站。该网站可以用于资源分享,也可以充当百度云盘重要资料整理说明的文档。其主要功能按照权限进行划分可分成游客、用户和管理员三大部分,游客:游客可进行注册,阅览资源;用户:注册用户可进行登录,密码找回,对自己的资源进行管理(分享资源或充值可获得积分)对他人的资源下载(下载需要消耗积分),对下载的资源进行评价评价、发布资源获取积分积分等;管理员:管理员可对资源类型进行管理、对资源和评价进行审核、并可以完成对登录人数、各类别资源发布数目等信息进行统计的任务。为减少用户对数据库的操作以提高系统效率,本项目采用了Redis数据库充当缓存,并用Shiro框架实现认证和权限管理。
1.2技术选型
    SpringBoot作为主体框架;MyBatis作为持久层框架;数据库选用MySql;并用Redis实现了对热门数据和常用数据的缓存(是不是也可以进行阅读量的增加)以减少对MySql的操作提高效率;Javamail实现:密码找回;Shiro作为Java安全框架,执行身份验证与授权。具体功能及核心实现如下所示。
1.3 项目流程
    1 建立数据库和数据表;
    2 利用IDEA创建SpringBoot项目;
    3 对Maven工程的Pom.xml进行配置,以导入依赖;
    4 在application.yml对Datasource等信息进行配置;
    5 引入前端资源文件;
    6 根据业务需求编写实体类Entity代码和Mapper层代码、Mapper.xml、service层接口和实现类、controller层的接口和实现类;
    7测试并部署(这一步实际没进行);

2 数据库建立

2.1数据库中的表以及表间关系
    创建数据库,并建立用户表、资源表、资源类型表、消息表、评价表、资源下载表和友情链接表,各个表之间的关系如图1所示。

SpringBoot-MyBatis资源分享网站项目笔记_第1张图片
2.2所用mysql知识点总结
    1 建立表的过程中,各个表要满足三大范式;
    2 存储引擎选择上,必须是InnoDB存储引擎,而不能是MyISAM存储引擎,因为MyISAM存储引擎是不支持外键的;
    3 涉及多表查询;
    4 外键约束的对业务逻辑来讲,也很重要;Mysql包含四种外键约束:
        CASCADE:父表delete、update的时候,子表会delete、update掉关联记录;
        SET NULL:父表delete、update的时候,子表会将关联记录的外键字段所在列设为null,所以注意在设计子表时外键不能设为not null;
        RESTRICT:如果想要删除父表的记录时,而在子表中有关联该父表的记录,则不允许删除父表中的记录;
        NO ACTION:同 RESTRICT,也是首先先检查外键;

3 后端核心功能及代码实现

    在此部分进行之前要先解决掉项目热更新热部署的问题,来减少启动,同时加入filter过滤规则,对所有页面进行过滤(注意要把filter声明为配置类)。
3.1 用户主页相关功能
3.1.1 注册功能
    用户通过添加用户名、密码、昵称、邮箱和性别几个属性进行注册,同时具备通过QQ进行注册的功能。(QQ注册的功能后面完善)。在进行存储注册之前,要先写好user Entity,再写Mapper层同时注入Sql,再写service层并进行事务和权限验证方面的操作,最后再对controller进行定义。
在这个功能中用到的UserService方法主要包含如下几种:

     // 根据用户名查找用户实体
    public User findByUserName(String userName);
     //根据邮箱查找用户实体
    public User findByEmail(String email);
     // 添加或修改用户信息
    public void save(User user);
     * 根据id获取用户信息
    public User getById(Integer id);

UserController中的关于注册的方法如下,其中BindingResult提供了参数校验功能。在项目当中少不了入参校验,服务器和浏览器互不信任,不能因为前端加入参判断了后台就不处理了,这样是不对的。比如前台传过来一个对象作为入参参数,这个对象中有些属性允许为空,有些属性不允许为空,那么我们可以使用@Valid+BindingResult进行controller参数校验。同时用户注册的过程中为了信息安全,不应将明文密码存入数据库,要进行MD5加密处理。controller如下:

   /**
     * 用户注册
     */
    @ResponseBody
    @PostMapping("/register")
    public Map<String,Object> register(@Valid User user, BindingResult bindingResult){
        Map<String,Object> map = new HashMap<>();
        if(bindingResult.hasErrors()){
            map.put("success",false);
            map.put("errorInfo",bindingResult.getFieldError().getDefaultMessage());
        }else if(userService.findByUserName(user.getUserName())!=null){
            map.put("success",false);
            map.put("errorInfo","用户名已存在,请更换!");
        }else if(userService.findByEmail(user.getEmail())!=null){
            map.put("success",false);
            map.put("errorInfo","邮箱已存在,请更换!");
        }else{
            //CryptographyUtil.md5()是自定义的用于MD5加密的方法
            user.setPassword(CryptographyUtil.md5(user.getPassword(),CryptographyUtil.SALT));
            user.setRegistrationDate(new Date());
            user.setLatelyLoginTime(new Date());
            user.setHeadPortrait("tou.jpg");
            userService.save(user);
            map.put("success",true);
        }
        return map;
    }

3.1.2 登录功能–Shrio认证
    本项目在认证和权限的实现上采用Shrio,Shiro是Apache下的一个开源项目。shiro主要有三大功能模块: Subject:主体,一般指用户,对应用提供的API的核心;SecurityManager:安全管理器,管理所有Subject,可以配合内部安全组件。(类似于SpringMVC中的DispatcherServlet);Realms:用于进行权限信息的验证,一般需要自己实现。相当于DataSource。在进行认证和权限管理之前,我们应该先定义好Shrio的配置类,并自定义Realms。
自定义Realm中要实现权限管理的方法和认证的方法,截止到目前只涉及认证方法,代码很固定,如下所示:

     // 权限认证
     //Token中包含了前端传来的userName+passWord
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String userName = (String) authenticationToken.getPrincipal();
        User user = userService.findByUserName(userName);
        if(user==null){
            return null;
        }else{
            AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user.getUserName(),user.getPassword(),"xx");
            return authenticationInfo;
        }
    }

    Realm自定义完成后,需要定义Shrio的配置类,配置类中包含了三个核心方法,从上到下依次为:public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) 在这个方法中定义拦截器,过滤链;public SecurityManager securityManager() 用于返回SecurityManager;public MyRealm myRealm() 在这个方法中返回上文自定义的Realm。过滤链定义,从上往下执行,一般将/**放在最下面 这是个坑,一不小心代码就不好使了,authc:所有的url必须认证通过才可以访问 anon:所有url都可以匿名访问 ,配置不会被拦截的链接。(filters:管理全部过滤器,包括默认的关于身份验证和权限验证的过滤器,这些过滤器分为两组,一组是认证过滤器,有anon,authcBasic,auchc,user,一组是授权过滤器,有perms,roles,ssl,rest,port;)所以在定义拦截链的过程中,根目录、静态页面、静态资源、登录页面、登录方法、注册方法等都不需要进行过滤;除了不需要进行过滤的页面和方法以外,其他的直接定义为 filterChainDefinitionMap.put("/星星",“authc”)即可。代码如下所示(截止到目前还没涉及太多权限问题,这里的代码是项目整体的Shrio核心代码):

Bean
    public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        //必须设置securityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //如果不设置,默认会自动寻找Web工程根目录下的"login.jsp"页面
        shiroFilterFactoryBean.setLoginUrl("/");
        Map<String,String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/","anon");
        filterChainDefinitionMap.put("/static/**","anon");
        filterChainDefinitionMap.put("/ueditor/**","anon");
        filterChainDefinitionMap.put("/upload/**","anon");
        filterChainDefinitionMap.put("/user/register.html","anon");
        filterChainDefinitionMap.put("/user/login.html","anon");
        filterChainDefinitionMap.put("/user/findPassword.html","anon");
        filterChainDefinitionMap.put("/user/register","anon");
        filterChainDefinitionMap.put("/user/login","anon");
        filterChainDefinitionMap.put("/user/sendEmail","anon");
        filterChainDefinitionMap.put("/user/checkYzm","anon");
        filterChainDefinitionMap.put("/user/bindEmail","anon");
        filterChainDefinitionMap.put("/QQ/qqLogin","anon");
        filterChainDefinitionMap.put("/article/**","anon");
        filterChainDefinitionMap.put("/comment/**","anon");
        filterChainDefinitionMap.put("/connect","anon");
        filterChainDefinitionMap.put("/buyVIP","anon");
        filterChainDefinitionMap.put("/fbzyzjf","anon");
        filterChainDefinitionMap.put("/admin/login.html","anon");
        filterChainDefinitionMap.put("/user/bindEmail.html","anon");
        filterChainDefinitionMap.put("/user/logout","logout");
        filterChainDefinitionMap.put("/**","authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    @Bean
    public SecurityManager securityManager(){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //设置realm
        securityManager.setRealm(myRealm());
        return securityManager;
    }

    /**
     * 身份认证realm
     * @return
     */
    @Bean
    public MyRealm myRealm(){
        MyRealm myRealm = new MyRealm();
        return myRealm;
    }

    在定义好Shrio配置类后,就要写登录的controller代码了,因为登录成功后一般会将用户信息添加到Session中,所以controller方法接受的参数中应该包含session。具体实现描述:当用户名和密码都不为空的时候,就要进行是否可以登录的判断了,这个判断是交给shrio来完成的,首先获取subject对象,然后将用户名和密码作为参数建立Token对象,再调用subject的login等,最后登录成功后将用户信息添加到session中,具体代码如下所示(其实整个登录过程完全可以由Realm来完成,具体实现并不唯一):

    /**
     * 用户登录
     * StringUtil.isEmpty()是个自定义的判断字符串是否为空的工具类,其实没太大实际意义;
     */
    @ResponseBody
    @PostMapping("/login")
    public Map<String,Object> login(User user, HttpSession session){
        Map<String,Object> map = new HashMap<>();
        if(StringUtil.isEmpty(user.getUserName())){
            map.put("success",false);
            map.put("errorInfo","请输入用户名!");
        }else if(StringUtil.isEmpty(user.getPassword())){
            map.put("success",false);
            map.put("errorInfo","请输入密码!");
        }else{
            Subject subject = SecurityUtils.getSubject();
            UsernamePasswordToken token = new UsernamePasswordToken(user.getUserName(),CryptographyUtil.md5(user.getPassword(),CryptographyUtil.SALT));
            try {
                subject.login(token);       //登录验证
                String userName = (String) SecurityUtils.getSubject().getPrincipal();
                User currentUser = userService.findByUserName(userName);
                if (currentUser.isOff()) {
                    map.put("success", false);
                    map.put("errorInfo", "该用户已封禁,请联系管理员!");
                    subject.logout();
                } else {
                    currentUser.setLatelyLoginTime(new Date());
                    userService.save(currentUser);
                    //未读消息数放到session
                    Integer messageCount = messageService.getCountByUserId(currentUser.getUserId());
                    currentUser.setMessageCount(messageCount);
                    //失效资源数
                    Article s_article = new Article();
                    s_article.setUseful(false);
                    s_article.setUser(currentUser);
                    session.setAttribute(Consts.UN_USEFUL_ARTICLE_COUNT,articleService.getCount(s_article,null,null,null));
                    session.setAttribute(Consts.CURRENT_USER,currentUser);
                    map.put("success", true);
                }
            }catch (Exception e){
                e.printStackTrace();
                map.put("success", false);
                map.put("errorInfo", "用户名或密码错误!");
            }
        }
        return map;
    }

3.1.3 找回密码
    用户可通过邮箱进行密码找回。
    首先要用SpringBoot实现一个给用户发送邮件的功能:
    1 在QQ邮箱中将POP3/SMTP开启,并点击生成授权码,根据QQ邮箱系统的提示发送短信,

SpringBoot-MyBatis资源分享网站项目笔记_第2张图片

    2 定义关于邮箱发送邮件的配置类(这是一个真正用法发送邮件的方法)

 //邮件配置类
@Configuration
public class MailConfig {
    //获取邮件发送实例
    @Bean
    public MailSender mailSender(){
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost("smtp.qq.com");          //指定用来发送Email的邮件服务器主机名
        mailSender.setPort(587);                    //默认端口,标准的SMTP端口
        mailSender.setUsername("用户名");   //用户名
        mailSender.setPassword("密码是收到的短信中的密码");                 //密码
        return  mailSender;
    }
}

    3 编写生成验证码并发送邮件的controller方法
    注意为了让方法生效,页面也可以看得到,需要在shrio配置类的第一个方法中的过滤器链中加入这些页面及其对应的方法。验证码发送以后要保存在session中,后面验证验证码填写是否正确的时候会用得到。userId也应该存储进session,后面验证验证码的时候同样用的到。

    // 发送验证码,传进来的参数是用户的邮箱地址
    @ResponseBody
    @PostMapping("/sendEmail")
    public Map<String,Object> sendEmail(String email, HttpSession session){
        Map<String,Object> map = new HashMap<>();
        if(StringUtil.isEmpty(email)){//如果邮箱地址为空则报错
            map.put("success",false);
            map.put("errorInfo","邮箱不能为空!");
            return map;
        }
        //验证邮件是否存在
        User u = userService.findByEmail(email);
        if(u==null){
            map.put("success",false);
            map.put("errorInfo","邮箱不存在!");
            return map;
        }
        String mailCode = StringUtil.genSixRandom();//自定义的一个util,随机生成验证码
        //发邮件
        SimpleMailMessage message = new SimpleMailMessage();        //消息构造器,这里只是构造消息,真正的发送任务,又配置类中的方法完成
        message.setFrom("[email protected]");                        //发件人
        message.setTo(email);                                       //收件人
        message.setSubject("分享网-用户找回密码");         //主题
        message.setText("您本次的验证码是:" +mailCode);            //正文内容
        mailSender.send(message);//这里的mailSender是上文的mail配置类中的方法
        System.out.println(mailCode);
        //验证码存到session
        session.setAttribute(Consts.MAIL_CODE_NAME,mailCode);
        session.setAttribute(Consts.USER_ID_NAME,u.getUserId());

        map.put("success",true);
        return map;
    }

    4 对用户提交的验证码进行验证,并重置密码;
    这一步对用户提交的验证码和session中存储的后台发送给用户的验证码进行比对,若比对成功,则将密码重置为123456,;具体代码如下所示,在重置密码后虽然对用户执行的是save方法,但是其实是更新操作,因为更新和保存操作的代码写进了一个service。

    /**
     * 邮件验证码判断
     */
    @ResponseBody
    @PostMapping("/checkYzm")
    public Map<String,Object> checkYzm(String yzm, HttpSession session){
        Map<String,Object> map = new HashMap<>();
        if(StringUtil.isEmpty(yzm)){
            map.put("success",false);
            map.put("errorInfo","验证码不能为空!");
            return map;
        }
        String mailCode = (String) session.getAttribute(Consts.MAIL_CODE_NAME);
        Integer userId = (Integer) session.getAttribute(Consts.USER_ID_NAME);

        if(!yzm.equals(mailCode)){
            map.put("success",false);
            map.put("errorInfo","验证码错误!");
            return map;
        }

        //给用户重置密码为123456
        User user = userService.getById(userId);
        user.setPassword((CryptographyUtil.md5(Consts.PASSWORD,CryptographyUtil.SALT)));
        userService.save(user);
        map.put("success",true);
        return map;
    }

3.1.4 资源和资源类型相关功能
    前文完成了注册、登录和密码找回功能。资源经过审核以后会显示在页面上,接下来对资源和资源类型相关的功能进行实现。先完成资源和资源类型相关的Entity、Mapper和Service。Mapper相对固定且简单,这里不再赘述。service方法如下所示:
    对于资源和资源类型一旦生成就很少进行更改和删除,对其进行的大部分操作都是查询,所以在这一部分中引入了Redis缓存,并用的SpringBoot的RedisTemplate来实现Redis的访问操作。使用过程包括:
    1当进行保存的时候,先调用Mapper中的方法进行save,如果save前redis为null,则调用redisTemplate.opsForList().rightPush(Consts.ALL_ARC_TYPE_NAME,arcType)将数据保存进redis,否则调用redisTemplate.delete(Consts.ALL_ARC_TYPE_NAME);删除掉redis中的数据(资源类型直接按上述操作就行,但是资源必须是审核以后才放入到redis中);
    2 在获取资源的时候如果redis中有资源则直接从redis中取,否则去数据库中去取,取完以后将资源放入redis后返回;
    3 删除的时候先将Redis中的数据删除掉,再删除数据库中的数据;这些顺序是不能反的,否则在高并发(虽然本项目不会出现高并发)过程中可能会出现“不安全”问题;

  //资源类型service中的方法
  /**
     * 分页查询资源类型列表
     * @param page          当前页
     * @param pageSize      每页记录数
     * @param direction     排序规则
     * @param properties    排序字段
     * @return
     */
    public List<ArcType> list(Integer page, Integer pageSize, Direction direction,String... properties);
    /**
     * 查询资源类型列表
     * @param direction     排序规则
     * @param properties    排序字段
     * @return
     */
    public List listAll(Direction direction,String... propert
   // 获取总计录数
    public Long getCount();
	//添加或修改资源类型
    public void save(ArcType arcType);
	//根据id删除一条资源类型
    public void delete(Integer id);
    //根据id查询一条资源类型
    public ArcType getById(Integer id);
	//资源相关的service
/**
     * 根据分页条件查询资源信息列表
     * @param s_article 条件
     * @param nickname  用户昵称
     * @param s_bpublishDate    发布开始时间
     * @param s_epublishDate    发布结束时间
     * @param page              当前页
     * @param pageSize          每页记录数
     * @param direction         排序规则
     * @param properties        排序字段
     * @return
     */
    public List<Article> list(Article s_article, String nickname, String s_bpublishDate, String s_epublishDate, Integer page, Integer pageSize, Direction direction, String... properties);
    /**
     * 根据条件获取总记录数
     * @param s_article 条件
     * @param nickname  用户昵称
     * @param s_bpublishDate    发布开始时间
     * @param s_epublishDate    发布结束时间
     * @return
     */
    public Long getCount(Article s_article, String nickname, String s_bpublishDate, String s_epublishDate);
    /**
     * 增加或修改资源
     * @param article   资源实体
     */
    public void save(Article article);
    /**
     * 根据主键id删除资源
     * @param id    资源id
     */
    public void delete(Integer id);
    /**
     * 根据主键id获取资源信息
     * @param id   资源id
     * @return
     */
    public Article getById(Integer id);
    /**
     * 根据条件分页查询资源信息(前台)
     * @param type      类型id
     * @param page      当前页
     * @param pageSize  每页记录数
     * @return
     */
    public Map<String,Object> list(String type,Integer page,Integer pageSize);
     //查询所有审核通过的资源信息列表
    public List<Article> listStatePass();
    // 点击+1
    public void updateClick(Integer articleId);
    // 今日发布资源总数
    public Integer todayPublish();
     //未审核资源总数
    public Integer noAudit();
    //10条最新资源
    public List<Article> getNewArticle(Integer n);
    //10条热门资源(按点击排序)
    public List<Article> getClickArticle(Integer n);
    //10条随机资源(热搜推荐)
    public List<Article> getRandomArticle(Integer n);

3.1.5资源查询
    按照条件对资源进行分页查询,其实这一部分也是用户管理的范围,这里的资源查询只是查询当前用户的资源,关于分页查询的service后面继续完善。

/**
     * 根据条件分页查询资源信息列表(只显示当前登录用户的资源)
     * @param s_article
     * @param page
     * @param pageSize
     * @param session
     * @return
     */
    @ResponseBody
    @RequestMapping("/articleList")
    public Map<String,Object> articleList(Article s_article,@RequestParam(value="page",required = false) Integer page,
                                          @RequestParam(value="limit",required = false) Integer pageSize,HttpSession session){
        Map<String,Object> map = new HashMap<>();
        User currentUser = (User) session.getAttribute(Consts.CURRENT_USER);
        s_article.setUser(currentUser);
        map.put("data",articleService.list(s_article,null,null,null,page,pageSize, Sort.Direction.DESC,"publishDate"));
        map.put("count",articleService.getCount(s_article,null,null,null));     //总计录数
        map.put("code",0);
        return map;
    }

3.1.6 资源发布功能
    点击资源发布按钮后,首先进入的应该是一个controller,这个controller会帮我们完成页面跳转,跳转到资源发布页面,页面大图如下图所示;

SpringBoot-MyBatis资源分享网站项目笔记_第3张图片

3.1.6 资源添加与修改
    这一部分也是用户功能部分,所以在进行操作时一定要进行判断,判断当前用户是否为资源所有者,同时不管前端代码是否对边界条件进行了考虑,我们后端都应该尽量将边界条件考虑周全。方法中分为两大部分article.getArticleId()==null时,说明数据库中没有这个资源,要进行资源添加工作,对article属性进行赋值,并将article加入到数据库中,如果article.getArticleId()!=null说明原来的数据库中有这个资源,那么我们需要对资源进行修改,不管是添加还是修改操作,操作时都应该将审核状态重新设置为待审核状态,管理员审核以后再进行发布。在点击资源修改按钮的时候,首先被执行的是一个判断当前用户是否是资源拥有者的controller方法,如果不是controller则会提供报错信息,如果是则会进入到一个页面跳转的controller,这个页面跳转controller会将页面跳转到资源修改页面,用户在资源修改页面对资源进行修改后点击提交,页面保存的controller被执行,更新成功;

/**
     * 进入资源发布页面
     */
    @GetMapping("toAddArticle")
    public String toAddArticle(){
        return "user/addArticle";
    }

    /**
     * 添加或修改资源
     */
    @ResponseBody
    @PostMapping("/saveArticle")
    public Map<String,Object> saveArticle(Article article,HttpSession session) throws IOException {
        Map<String,Object> resultMap = new HashMap<>();
        if(article.getPoints()<0||article.getPoints()>10){
            resultMap.put("success",false);
            resultMap.put("erroInfo","积分超出正常区间!");
            return resultMap;
        }
        if(!CheckShareLinkEnableUtil.check(article.getDownload())){
            resultMap.put("success",false);
            resultMap.put("erroInfo","百度云分享链接已经失效,请重新发布!");
            return resultMap;
        }
        User currentUser = (User)session.getAttribute(Consts.CURRENT_USER);
        if(article.getArticleId()==null){               //添加资源
            article.setPublishDate(new Date());
            article.setUser(currentUser);
            if(article.getPoints()==0){         //积分为0时,设置为免费资源
                article.setFree(true);
            }
            article.setState(1);                //未审核状态
            article.setClick(new Random().nextInt(150)+50);         //设置点击数为50~200
            articleService.save(article);
            resultMap.put("success",true);
        }else{                              //修改资源
            Article oldArticle = articleService.getById(article.getArticleId());        //获取实体
            if(oldArticle.getUser().getUserId().intValue()==currentUser.getUserId().intValue()){        //只能修改自己的资源
                oldArticle.setName(article.getName());
                oldArticle.setArcType(article.getArcType());
                oldArticle.setDownload(article.getDownload());
                oldArticle.setPassword(article.getPassword());
                oldArticle.setKeywords(article.getKeywords());
                oldArticle.setDescription(article.getDescription());
                oldArticle.setContent(article.getContent());
                if(oldArticle.getState()==3){           //假如原先是未通过,用户点击修改,则重新审核,状态变成未审核
                    oldArticle.setState(1);
                }
                articleService.save(oldArticle);
                //更新时需要把审核已经通过的新资源信息放入lucene
                if(oldArticle.getState().intValue()==2){
                    articleIndex.updateIndex(oldArticle);     //更新索引
                }
                resultMap.put("success",true);
            }
        }

        return resultMap;
    }

3.1.7资源删除
    关于资源删除部分,只需要判断当前用户是否是资源所有者,如果不是则不能删除,如果是则需要先删除评论,再删除内容,再删除Lucene。因为这里还没定义评论表的Entity所以先不对这部分代码进行展示,后面会继续完善。

3.2 管理员后台主页相关功能

    在管理员后台的相关功能中就涉及到了权限问题,管理员后台所具备的功能如下图所示,每个功能对应不同页面,当然前端的代码是直接进行引入的。

                                    SpringBoot-MyBatis资源分享网站项目笔记_第4张图片

在这里插入代码片

3.2.1 管理员登录
    管理员登录页面跟user的登录页面差不多(不要忘记在Shrio的filter中对管理员的登录页面进行配置),管理员登录成功后会进入IndexAdminController中的toAdminUserCenterPage方法,这个controller方法是一个页面跳转方法,页面跳转到admin/index页面,这个方法并不是所有人都能访问的,所以要对其进行权限管理,权限管理方法:在方法上加 @RequiresPermissions(value=“进入管理员主页”)注解,参数value值是随意写的,但是这个value要与Realm中info.addStringPermission(“进入管理员主页”)中参数的值相对应,同时要去编写Realm的授权方法,项目整体的Realm授权方法如下所示:

     //跳转到管理员主页面
    @RequiresPermissions(value="进入管理员主页")
    @RequestMapping("/toAdminUserCenterPage")
    public String toAdminUserCenterPage(){
        return "admin/index";
    }
 //Realm授权方法
 protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        String userName = (String) SecurityUtils.getSubject().getPrincipal();
        User user = userService.findByUserName(userName);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        Set<String> roles = new HashSet<>();
        if("管理员".equals(user.getRoleName())){
            roles.add("管理员");
            info.addStringPermission("进入管理员主页");
            info.addStringPermission("根据id查询资源类型实体");
            info.addStringPermission("添加或修改资源类型信息");
            info.addStringPermission("删除资源类型信息");
            info.addStringPermission("分页查询资源信息列表");
            info.addStringPermission("删除资源");
            info.addStringPermission("修改资源");
            info.addStringPermission("审核资源");
            info.addStringPermission("生成所有资源帖子索引");
            info.addStringPermission("分页查询评论信息");
            info.addStringPermission("修改评论状态");
            info.addStringPermission("删除评论");
            info.addStringPermission("分页查询用户信息");
            info.addStringPermission("修改用户VIP状态");
            info.addStringPermission("修改用户状态");
            info.addStringPermission("重置用户密码");
            info.addStringPermission("用户vip等级修改");
            info.addStringPermission("用户积分充值");
            info.addStringPermission("分页查询友情链接");
            info.addStringPermission("根据Id查询友情链接实体");
            info.addStringPermission("添加或修改友情链接");
            info.addStringPermission("批量删除友情链接");
            info.addStringPermission("修改管理员密码");
            info.addStringPermission("安全退出");
            info.addStringPermission("修改热门资源状态");
            info.addStringPermission("修改免费资源状态");
        }
        info.setRoles(roles);
        return info;
    }

3.2.2 资源类型管理
    管理员需要对资源类型进行管理(CRUD),对于service和Mapper与上文完全相同,在此处我们需要重新定义一个ArcTypeAdminController,这个controller中的方法只有管理员才能操作所以要对其权限进行配置,所有的ArcTypeAdminController中的方法都需要加@RequiresPermissions(value="")注解,同时要去增加Realm的授权方法中info信息。在功能实现上资源类型管理和与下文的资源管理差不多,所以这里不再赘述,将在下文进行讨论。资源类型管理中的添加类型页面如下图所示:
SpringBoot-MyBatis资源分享网站项目笔记_第5张图片3.2.3 资源管理
    此处将会对管理员资源管理部分的功能进行详细讨论,关于这部分功能的Mapper和Service代码已经在上文进行过了讨论,在此处我们需要重新定义一个ArticleAdminController,这个controller中的方法只有管理员才能操作所以要对其权限进行配置,所有的ArticleAdminControllerr中的方法都需要加@RequiresPermissions(value="")注解,同时要去增加Realm的授权方法中info信息。
    分页查询功能
    首先讨论分页查询功能,分页查询页面如下所示:
SpringBoot-MyBatis资源分享网站项目笔记_第6张图片    分页查询功能controller代码如下所示,@RequestParam(value = “nickname”,required = false)中required = false表示这个“nickname”参数并不是必填参数。

/**
     * 根据条件分页查询资源信息列表
     * @param s_article     条件
     * @param nickname      昵称
     * @param publishDates  发布时间段
     * @param page          当前页
     * @param pageSize      每页记录数
     * @return
     */
    @RequestMapping(value = "/list")
    @RequiresPermissions(value = "分页查询资源信息列表")
    public Map<String,Object> list(Article s_article,
                                   @RequestParam(value = "nickname",required = false) String nickname,
                                   @RequestParam(value = "publishDates",required = false) String publishDates,
                                   @RequestParam(value = "page",required = false) Integer page,
                                   @RequestParam(value = "pageSize",required = false) Integer pageSize){
        Map<String,Object> resultMap = new HashMap<>();
        String s_bpublishDate = null;       //开始时间
        String s_epublishDate = null;       //结束时间
        if(StringUtil.isNotEmpty(publishDates)){
            String[] str = publishDates.split(" - ");       //拆分时间段
            s_bpublishDate = str[0];
            s_epublishDate = str[1];
        }
        resultMap.put("data",articleService.list(s_article,nickname,s_bpublishDate,s_epublishDate,page,pageSize, Sort.Direction.DESC,"publishDate"));
        resultMap.put("total",articleService.getCount(s_article,nickname,s_bpublishDate,s_epublishDate));
        resultMap.put("errorNo",0);
        return resultMap;
    }

删除及批量删除功能
    删除和批量删除使用相同的controller方法即可,前端向方法传递的是一个关于id的字符串,各个字符串之间用“,”进行分割,后端接收到以后用split(“ ”)进行分割,然后遍历删除。在删除资源之前,需要把评论删除干净,再删除资源,删除资源后要删除资源索引,(关于评论和索引相关问题将在下文进行讨论)具体实现如下所示:

    //批量删除资源
    @RequestMapping(value = "/delete")
    @RequiresPermissions(value = "删除资源")
    public Map<String,Object> delete(@RequestParam(value = "articleId") String ids){
        Map<String,Object> resultMap = new HashMap<>();
        String[] idsStr = ids.split(",");
        for(int i=0;i<idsStr.length;i++){
            //删除评论
            commentService.deleteByArticleId(Integer.parseInt(idsStr[i]));
            articleService.delete(Integer.parseInt(idsStr[i]));           //删除帖子
            articleIndex.deleteIndex(idsStr[i]);                        //删除索引
        }
        resultMap.put("errorNo",0);
        return resultMap;
    }

审核资源
    管理员需要对user发布的资源进行审核,这一部分对管理员的资源审核功能进行说明,关于静态资源则直接引入,引入的过程中需要注意的是:在html中以.html结尾的页面都应该放在webapp目录下,而template中的html页面都需要在controller中进行跳转。在资源审核的时候前端会通过controller中的findById()方法获取到待审核的资源,并将资源展示在页面上,所以在写审核资源的功能钱,应该写个findById()方法。关于审核,其实就是在更改article的审核状态属性的值,当审核通过的时候除了更改数据库中的审核状态信息外,还要将数据添加到redis中,审核未通过的时候除了更改数据库中审核状态信息外,还应该增加审核未通过的原因。
3.2.4 管理员-用户管理
    其实用户管理、评论管理、资源类型管理都是大同小异的,其中资源管理相对复杂一些,上文已经对资源管理进行了详细讨论,这里只对用户管理与资源管理相差较大的部分进行讨论。当然这一部分中也不要忘了在Realm中配置权限,并在Controller中加@RequiresPermissions注解。
封禁用户
    用户表中有一个字段用来表示是否被封禁,我们直接操作这个状态就好。当然也要在Mapper 和 Service中定义相关方法,controller方法展示如下:

    @ResponseBody
    @RequestMapping(value = "/updateUserState")
    @RequiresPermissions(value="修改用户状态")
    public Map<String,Object> updateUserState(Integer userId,boolean isOff){
        User oldUser = userService.getById(userId);
        oldUser.setOff(isOff);
        userService.save(oldUser);
        Map<String,Object> map = new HashMap<>();
        map.put("success",true);
        return map;
    }

3.3 主页相关功能

    这一部分我们对主页中的相关功能进行讨论,主页中的相关功能还是比较多的,关于controller,我们先定义一个名为IndexController的controller,controller中先定义跳转到主页页面的方法(这个方法的路径:@RequestMapping("/")),这个方法中也会将资源类型列表和资源列表按照分页的方式查找出来并让前端对其进行显示。
3.3.1 首页中资源列表分页展示和资源按类型分页展示
    资源类型列表已经在页面上部显示后,页面上应该显示资源列表,在对资源列表进行显示之前,我们后端应该做一个查询,这个查询需要根据条件分页查询资源信息,所以我们要在ArticleService 和ArticleServiceImlp中添加相应代码。这个过程相对复杂,涉及到对Redis的操作(跟上文在service的save方法中对redis的操作有所不同,上文的操作是对单个增加的资源进行了redis存储,这里相当于一个批量存储过程,即所有资源的ID放进了一个Redis List,每个资源类型对应的所有资源ID放进了一个Redis List)。
    在ServiceImpl中根据条件分页查询资源的业务流程如下:
    1、初始化redis模版 初始化返回值;
    2、判断redis有没有资源列表;
    3、如果redis里没有资源列表,去数据库查询;
        3.1、遍历资源列表;
        3.2、将每一个资源放到redis(所有的资源进行redis缓存,点击首页按钮的时候资源进行展示的时候用得到);
        3.3、将每一个资源id推入redis的allarticleId列表;
        3.4、遍历资源类型列表,将该资源推入相应的redis资源类型列表(按照类型对资源进行相应缓存,比如点击JavaEE资源类型按钮的时候,根据JavaEE类型去redis中进行查找);
    4、对资源列表进行分页并且返回当前页;
    具体代码如下所示,注意可以展示的资源都是通过审核的资源,所以要定义并调用listStatePass() service方法,来获取通过审核的资源列表,listStatePass()方法代码相对简单,这里不做展示。这个方法会在IndexController的@RequestMapping("/") public ModelAndView index()方法中被调用

  @Override
    public Map<String, Object> list(String type, Integer page, Integer pageSize) {
        //1、初始化redis模版 初始化返回值
        redisTemplate.setKeySerializer(redisSerializer);
        ValueOperations<Object, Object> opsForValue = redisTemplate.opsForValue();
        ListOperations<Object,Object> opsForList = redisTemplate.opsForList();

        Map<String,Object> resultMap = new HashMap<>();
        List<Article> tempList = new ArrayList<>();
        //2、判断redis有没有资源列表
        Boolean flag = redisTemplate.hasKey("allarticleId");
        //3、如果redis里没有资源列表,去数据库查询
        if(!flag){
            //3.1、遍历资源列表
            List<Article> listStatePass = listStatePass();
            for(Article a:listStatePass){
                //3.2、将每一个资源放到redis
                opsForValue.set("article_"+a.getArticleId(),a);
                //3.3、将每一个资源id推入redis的allarticleId列表
                opsForList.rightPush("allarticleId",a.getArticleId());
                //3.4、遍历资源类型列表,将该资源推入相应的redis资源类型列表
                List<ArcType> arcTypeList = arcTypeService.listAll(Sort.Direction.ASC,"sort");
                for(ArcType arcType:arcTypeList){
                    if(a.getArcType().getArcTypeId().intValue() == arcType.getArcTypeId().intValue()){
                        opsForList.rightPush("article_type_"+arcType.getArcTypeId(),a.getArticleId());
                    }
                }
            }
        }

        //4、分页资源列表并且返回当前页
        long start = (page-1)*pageSize;
        long end = pageSize*page - 1;
        List idList;
        long count;
        if("all".equals(type)){
            idList = opsForList.range("allarticleId",start,end);
            count = opsForList.size("allarticleId");
        }else{
            idList = opsForList.range("article_type_"+type,start,end);
            count = opsForList.size("article_type_"+type);
        }
        //根据idList中的id取出id对应的对象,并且保存进tempList
        for(Object id:idList){
            tempList.add((Article) opsForValue.get("article_"+id));
        }
        resultMap.put("data",tempList);
        resultMap.put("count",count);
        return resultMap;
    }

    当点击首页的时候展示的是所有类型的资源分页列表,controller
对应的是IndexController,而当点击某个类别按钮的时候,应该展示这个类别的资源分页列表。这里controller对应的是AtricleController(对于是哪个controller并没有严格要求只是按照功能对其进行了划分,以方便管理)。

                        在这里插入图片描述

    /**
     * 按资源类型分页查询资源列表
     * @param type
     * @param currentPage
     * @return
     */
    @RequestMapping("/{type}/{currentPage}")
    public ModelAndView type(@PathVariable(value = "type",required = false) String type, @PathVariable(value = "currentPage",required = false)Integer currentPage){
        ModelAndView mav = new ModelAndView();
        mav.setViewName("index");
        //类型的html代码
        List arcTypleList = arcTypeService.listAll(Sort.Direction.ASC,"sort");
        mav.addObject("arcTypeStr", HTMLUtil.getArcTypeStr(type,arcTypleList));
        //资源列表
        Map<String,Object> map = articleService.list(type,currentPage, Consts.PAGE_SIZE);
        mav.addObject("articleList",map.get("data"));
        //分页html代码
        mav.addObject("pageStr",HTMLUtil.getPagation("/article/"+type,Integer.parseInt(String.valueOf(map.get("count"))),currentPage,"该分类还没有数据..."));
        return mav;
    }

3.3.2 主页查看资源详情
    首页中看到的资源都是资源名称和用户名称等信息,点开资源可以查看资源详情(这个资源详情中没有百度云链接和密码,只有点击下载后前端才会显示出资源的百度云链接和密码),所以我们应该在后台的ArticleController中编写Controller方法给前端返回文章详情。点开文章后文章的点击量应该进行+1操作(这个是不是可以用Redis完成呢),注意:这个方法也应该在Shrio过滤器中进行配置。(这里涉及到了Lucene相关的知识点)。

4 其他功能

4.1 用户下载
    关于用户下载,我们要首先定义好用户下载表的Entity实体,用户下载表中包含了下载时间、下载资源和下载用户;再写Mapper相关代码;再写Respority;最后编写Controller。service接口中的方法如下所示:

 //用户下载Service接口
public interface UserDownloadService {
     //查询某个用户下载某个资源的次数
    public Integer getCountByUserIdAndArticleId(Integer userId,Integer articleId);
     //分页查询某个用户下载的所有资源
    public Page<UserDownload> list(Integer userId, Integer page, Integer pageSize, Sort.Direction direction,String... properties);
    // 统计某个用户下载的资源数
    public Long getCount(Integer userId);
    //添加或修改某用户的下载信息
    public void save(UserDownload userDownload);
}

    在点击资源下载按钮的时候,后端的判断当前资源是否被当前用户下载过的controller方法会被执行,如果下载过,前端会弹出页面询问是否再次下载,并不会二次扣除积分,如果没有进行下载过,则前端会判断一下这个资源是否是免费资源(这由另外一个判断是否是免费资源的方法来完成),如果是免费资源则下载过程不扣除积分,否则前端将会调用判断积分是否足够的controller,积分不足则提示积分不足,如果积分足够则提示积分数目,并询问是否确定下载资源,点击确认资源下载的controller方法toDownloadPage()会被执行,这个toDownloadPage()方法进行了一系列操作,最后会跳转到article/downloadPage下载页面,这个下载页面会展示百度云资源和密码。如果是会员则可以点击会员免积分下载,前端会调用后端的controller中的判断用户是否是vip的方法,如果是vip则直接跳转到controller toVipDownloadPage()方法。
    虽然前端已经对是否下载过,积分是否足够等问题进行了判断,但是我们后端应该再进行一次判断,代码如下:

    /**
     * 跳转到资源下载页面
     */
    @RequestMapping("/toDownloadPage/{articleId}")
    public ModelAndView toDownloadPage(@PathVariable("articleId")Integer articleId,HttpSession session){
        Article article = articleService.getById(articleId);
        //查不到或者没审核通过,直接返回
        if(article==null||article.getState().intValue()!=2){
            return null;
        }
        User currentUser = (User)session.getAttribute(Consts.CURRENT_USER);
        //判断当前用户是否下载过
        int count = userDownloadService.getCountByUserIdAndArticleId(currentUser.getUserId(),articleId);
        if(count==0){           //没下载过
            if(!article.isFree()){      //非免费资源
                if(currentUser.getPoints()-article.getPoints()<0){      //积分不够
                    return null;
                }
                //扣除积分后保存到数据库
                currentUser.setPoints(currentUser.getPoints()-article.getPoints());
                userService.save(currentUser);
                //资源分享人加积分
                User articleUser=article.getUser();
                article.setPoints(articleUser.getPoints()+article.getPoints());
                userService.save(articleUser);
            }
            //保存用户下载
            UserDownload userDownload = new UserDownload();
            userDownload.setArticle(article);
            userDownload.setUser(currentUser);
            userDownload.setDownloadDate(new Date());
            userDownloadService.save(userDownload);
        }
        ModelAndView mav = new ModelAndView();
        mav.addObject("article",article);
        mav.setViewName("article/downloadPage");
        return mav;
    }

4.2 用户评论功能
    如果用户下载过某个资源,则这个用户可以对资源进行评论,后台管理员对评论进行审核,审核通过则进行展示。先定义关于评论表的Entity,这里包含了评价时间、评价内容、评价者、评价状态和所属资源相关信息。(在上文资源删除已经提到过,在删除资源的过程中后台应该先删除资源对应的评价)
4.3评价审核
    评价审核与资源审核功能类似,这里不再赘述。
4.4 增加评论和查询评论
    评论相关的功能比资源相关内容相似,且简单很多。
4.5 管理员数据统计
    在管理员后台中管理员会对用户数、资源数、今日注册数、今日登录数进行统计,以方便管理员对网站进行管理。如下图所示,这其实就是对Sql聚合函数的熟练掌握学习,所以这里不再赘述。
SpringBoot-MyBatis资源分享网站项目笔记_第7张图片

  @Autowired
    private CommentService commentService;
    //前端提交保存评论信息
    @PostMapping("/add")
    public Map<String,Object> add(Comment comment, HttpSession session){
        Map<String,Object> map = new HashMap<>();
        comment.setContent(StringUtil.esc(comment.getContent()));
        comment.setCommentDate(new Date());
        comment.setState(0);
        comment.setUser((User)session.getAttribute(Consts.CURRENT_USER));
        commentService.save(comment);
        map.put("success",true);
        return map;
    }
     //分页查询某个资源的评论信息
    @ResponseBody
    @RequestMapping(value = "/list")
    public Map<String,Object> list(Comment s_comment, @RequestParam(value = "page",required = false)Integer page){
        s_comment.setState(1);
        Page<Comment> commentPage = commentService.list(s_comment,page,5, Sort.Direction.DESC,"commentDate");
        Map<String,Object> map = new HashMap<>();
        map.put("data", HTMLUtil.getCommentPageStr(commentPage.getContent()));          //评论的HTML代码
        map.put("total",commentPage.getTotalPages());                                   //总页数
        return map;
    }

5 相关知识点

5.1 Redis

redis用到了String和List数据结构 3.3节中有详细说明

5.2 按条件分页查询

    可以采用MySql的Limit分页查询,客户端通过传递start(页码),pageSize(每页显示的条数)两个参数去分页查询数据库表中的数据,那我们知道MySql数据库提供了分页的函数limit m,n,但是该函数的用法和我们的需求不一样,所以就需要我们根据实际情况去改写适合我们自己的分页语句,通过分析,可以得出符合我们需求的分页sql格式是:select * from table limit (start-1)*pageSize,pageSize; 其中start是页码,pageSize是每页显示的条数。当需要进行按条件分页查询的时候需要在Sql中加入查询条件,如select * from orders_history where type=2
and id between 1000000 and 1000100 limit 100; 当然也可以采用PageHelper进行分页查询。

5.3 MySql

    涉及多表查询:多表查询主要关注ResultMap方法。
    聚合函数(管理员后台统计用户数、资源数、当日登陆人数等信息的时候用得到)

5.4 SpringBoot

    直接推荐尚硅谷在B站发布的视频吧,讲的太好了
    事务管理:在掌握SpringBoot的事务管理之前,先学习一下Spring的事务管理,参考博文链接。
以前学ssh ssm都有事务管理service层,通过applicationContext.xml配置,在service类或所有service方法都加上事务操作;用来保证一致性,即service方法里的多个dao操作,要么同时成功,要么同时失败;springboot下的话 搞一个@Transactional即可,无需再进行配置。SpringBoot项目会自动配置一个 DataSourceTransactionManager,所以我们只需在方法(或者类)加上 @Transactional 注解(一般是Service方法上),就自动纳入 Spring 的事务管理了
参考文献1
事务管理的注意事项
    1.不要在接口上声明@Transactional ,而要在具体类的方法上使用 @Transactional 注解,否则注解可能无效。
    2.不要图省事,将@Transactional放置在类级的声明中,放在类声明,会使得所有方法都有事务。故@Transactional应该放在方法级别,不需要使用事务的方法,就不要放置事务,比如查询方法。否则对性能是有影响的。
    3.使用了@Transactional的方法,对同一个类里面的方法调用, @Transactional无效。比如有一个类Test,它的一个方法A,A再调用Test本类的方法B(不管B是否public还是private),但A没有声明注解事务,而B有。则外部调用A之后,B的事务是不会起作用的。(经常在这里出错)
    4.使用了@Transactional的方法,只能是public,@Transactional注解的方法都是被外部其他类调用才有效,故只能是public。道理和上面的有关联。故在 protected、private 或者 package-visible 的方法上使用 @Transactional 注解,它也不会报错,但事务无效。
    5.spring的事务在抛异常的时候会回滚,如果是catch捕获了,事务无效。可以在catch里面加上throw new RuntimeException();
    6.最后有个关键的一点:和锁同时使用需要注意:由于Spring事务是通过AOP实现的,所以在方法执行之前会有开启事务,之后会有提交事务逻辑。而synchronized代码块执行是在事务之内执行的,可以推断在synchronized代码块执行完时,事务还未提交,其他线程进入synchronized代码块后,读取的数据不是最新的。
参考文献2

5.5 MyBatis

    因为项目中持久层并没有使用SpringData-JPA,而是采用的MyBatis,所有要对SpringBoot和MyBatis,MyBatis的使用可以分为注解方式和Mapper配置文件方式。这里采用的是Mapper配置文件的方式,首先在POM.xml中导入依赖,在Reasource文件夹下简历MyBatis/Mapper文件夹,用来存放mapper主配置文件和映射配置文件,当然要在Application.yml中指出mapper主配置文件和映射文件的存放位置,在SpringBoot中集成MyBatis,可以在mapper接口上添加@Mapper注解,将mapper注入到Spring,但是如果每一给mapper都添加@mapper注解会很麻烦,这时可以使用@MapperScan注解来扫描包,这个MapperScan(“ ”)是加载启动类上面的。
5.5.1 多表查询
    MyBatis的多表查询是这个项目中重点问题:比如一对多(一个用户有多个自己的资源)、多对一(多个资源属于一个用户,当针对某一特定的资源的时候只能是一对一即一个资源有一个用户,在MyBatis中对多对一是按照一对一进行处理的)、一对一(一个资源属于一个类型);
一对一:
     在实现一对一的时候比如查询资源并且查出资源所对应的用户的时候,可以采用一种最粗暴的方式—建立一个新的实体类,这个实体类包含了资源和用户属性,比如建立一个ArticleUser,那么Mapper.xml中的returnType也应该改成ArticleUser,这样就把联表查询的任务交给了SQL语句,查询的结果会自动封装进ArticleUser(虽然这是一种解决方案,但是这种方法并不推荐使用)。可采用在配置文件中定义ResultMap+association的方法。
一对多:
     一对多关系映射中,主表实体应该包含从表实体的集合引用,即在User实体类中加上 List list;并加之get和set方法。这里就涉及到了左外连接的知识点:

5.6 Shrio

     shiroConfig 也不复杂,基本就三个方法。再说这三个方法之前,我想给大家说一下shiro的三个核心概念:
     Subject: 代表当前正在执行操作的用户,但Subject代表的可以是人,也可以是任何第三方系统帐号。当然每个subject实例都会被绑定到SercurityManger上。向外提供的API的核心。
     SecurityManger:SecurityManager是Shiro核心,主要协调Shiro内部的各种安全组件,这个我们不需要太关注,只需要知道可以设置自定的Realm。SpringMVC中DisparctherServlet的角色。
Realm:用户数据和Shiro数据交互的桥梁。比如需要用户身份认证、权限认证。都是需要通过Realm来读取数据。
     参考文献

你可能感兴趣的:(web)