旅游评点项目

旅游评点项目

1、项目概述

目前电商类 App 应该占有市场最大氛围。但是目前单纯类的销售型电商平台越来越少,逐渐进入了内容为主的模式

内容类 App 主要分为三类:

  1. UGC:user generated content,用户生产内容,要求平台普通用户都能参与内容的生产, 比如社区类平台,短视频平台都是典型的 UGC 类型平台;

  2. PGC:professional generated content,专业生产内容。如果所有由用户参与平台的内容生产,造成的最大问题是内容质量的参差不齐,所以要求平台的内容只由部分平台指定用户生产,比如平台认证专家等;例如优酷的专家栏目,喜马拉雅的专家声音等;

  3. OGC:occupational generated content,职业生产内容。内容由专职人员生产,并以此支付对应的报酬

    和 PGC 最大的区别在于,PGC 是以免费生产内容为主

    而 OGC 以收费作为输出内容的回报。一般企业网站就是典型的 OGC

因为内容类 App 涵盖的面很广,包括知识内容分享,购物体验分享,新闻资讯分享,纯社区类,社区偏内容,内容偏社区等等。在本项目中,我们主要针对典型的 OGC+UGC 场景, 做一个点评类内容 App

点评类内容 App 针对的行业非常多,常见的美食,旅游,等等,包含了生活中的吃喝玩乐 住行都有。在本项目中,主要针对旅游行业,做一个点评内容 App

在本项目中,我们把关注点聚焦,来完成一个针对旅游类的攻略,点评,分享旅游日记的 App

技术路线

项目技术路线:

  1. 数据库:mongodb + elasticsearch

  2. 持久化层:mongodb+Redis (缓存)

  3. 业务层:Springboot;

  4. Web:SpringMVC;

  5. 前端:

    管理后台:jQuery+Bootstrap3

    前端展示:vue +jquery + css;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hkg6fYoT-1651834690330)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324140734197.png)]

项目组成结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nuBYFcUx-1651834690331)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324140848132.png)]

trip-parent 项目怎么管理依赖:

  1. 如果 所有子项目都需要 某个依赖,将该依赖,添加到父项目pom. xml文件的 <dependencies> 标签里面,表示所有项目共享

  2. 如果 部分子项目都需要 某个依赖,将该依赖,添加到父项目pom. xml文件的 <dependencyManagement> 标签里面,父项目对这个依赖进行版本管理

    其他需要用该依赖的子项目在pom. xml的 <dependencies> 标签里面引入依赖,此时不需要引入依赖的版本

  3. 如果 某个子项目需要 某个依赖,将该依赖,添加到子项目pom. xml文件的 <dependencies> 标签里面,自己用即可

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wfNYmQcD-1651834690332)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324111246542.png)]

2、搭建环境

打开 MongoDB

进入 MongoDB 安装目录的 bin 目录中,双击打开 mongo.exe

执行以下语句,准备数据:

db.getCollection("userInfo").insert([ {
   _id: ObjectId("5e295f01a00e265228f963ea"),
   nickname: "dafei",
   phone: "13700000000",
   email: "",
   password: "1111",
   gender: NumberInt("0"),
   level: NumberInt("1"),
   city: "广州",
   headImgUrl: "/images/default.jpg",
   info: "广州最靓的仔",
   state: NumberInt("0")
} ]);
db.getCollection("userInfo").insert([ {
   _id: ObjectId("5e92d6cdacd8de311e99c99e"),
   nickname: "xiaofeii",
   phone: "13700000001",
   email: "",
   password: "1111",
   gender: NumberInt("0"),
   level: NumberInt("1"),
   city: "广州",
   headImgUrl: "/images/default.jpg",
   info: "广州最靓的仔",
   state: NumberInt("0")
} ]);

1、wolf2w 模块

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.afei</groupId>
    <artifactId>parent</artifactId>
    <packaging>pom</packaging>
    <version>1.0.0</version>
    <modules>
        <module>../trip-core</module>
        <module>../trip-mgrsite</module>
        <module>../trip-website-api</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/>
    </parent>

    <!-- 依赖版本管理 -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.afei</groupId>
                <artifactId>trip-core</artifactId>
                <version>1.0.0</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

</project>

2、trip-core 模块

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>parent</artifactId>
        <groupId>com.afei</groupId>
        <version>1.0.0</version>
        <relativePath>../wolf2w/pom.xml</relativePath>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>trip-core</artifactId>

    <dependencies>
        <!-- gettgetset方法 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- mongodb -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <!-- toJson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>
        <!-- redis -->
        <!--<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>provided</scope>
        </dependency>-->
        <!-- oss -->
        <!--<dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
            <version>3.5.0</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.6</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
            <version>1.9.3</version>
        </dependency>-->
    </dependencies>
</project>

domain

在 trip-core 模块中定义一些类

@Setter
@Getter
public class BaseDomain implements Serializable {
    @Id   //MongoDB中文档的id建议使用 String 类型,并且贴上@Id注解
    private String id;
}
@Setter
@Getter
@Document("userInfo")   //设置MongoDB中文档所在的集合
@ToString
public class UserInfo extends BaseDomain{
    public static final int GENDER_SECRET = 0; //保密
    public static final int GENDER_MALE = 1;   //男
    public static final int GENDER_FEMALE = 2;  //女
    public static final int STATE_NORMAL = 0;  //正常
    public static final int STATE_DISABLE = 1;  //冻结

    private String nickname;  //昵称
    private String phone;  //手机
    private String email;  //邮箱
    private String password; //密码
    private int gender = GENDER_SECRET; //性别
    private int level = 0;  //用户级别
    private String city;  //所在城市
    private String headImgUrl; //头像
    private String info;  //个性签名
    private int state = STATE_NORMAL; //状态
}

MongoDB 数据库表

db.getCollection("userInfo").drop();
db.createCollection("userInfo");

db.getCollection("userInfo").insert([ {
    _id: ObjectId("5e295f01a00e265228f963ea"),
    nickname: "dafei",
    phone: "13700000000",
    email: "",
    password: "1111",
    gender: NumberInt("0"),
    level: NumberInt("1"),
    city: "广州",
    headImgUrl: "/images/default.jpg",
    info: "广州最靓的仔",
    state: NumberInt("0")
} ]);
db.getCollection("userInfo").insert([ {
    _id: ObjectId("5e92d6cdacd8de311e99c99e"),
    nickname: "xiaofeii",
    phone: "13700000001",
    email: "",
    password: "1111",
    gender: NumberInt("0"),
    level: NumberInt("1"),
    city: "广州",
    headImgUrl: "/images/default.jpg",
    info: "广州最靓的仔",
    state: NumberInt("0")
} ]);

参数查询对象

@Setter
@Getter
public class QueryObject implements Serializable {

    private int currentPage = 1;
    private int pageSize = 10;
    private String keyword;
    
    /*private Pageable pageable;  //分页设置对象
    public Pageable getPageable(){
        if(pageable == null){
            //没有指定分页对象值, 默认id倒序
            return  PageRequest.of(currentPage - 1, pageSize,
                    Sort.Direction.ASC, "_id");
        }
        return pageable;
    }*/

    public String getKeyword(){
        return StringUtils.hasLength(keyword)? keyword : null;
    }

}

持久化接口

//用户持久化操作接口,类似 Mapper 接口
/**
 *  继承接口:MongoRepository
 *    1、接口泛型1:操作domain对象  UserInfo
 *    2、接口泛型2:操作对象主键类型  String
 */
@Repository
public interface UserInfoRepository extends MongoRepository<UserInfo, String> {
}

服务接口及实现类

/**
 * 用户服务接口
 */
public interface IUserInfoService  {
    //添加
    void save(UserInfo userInfo);
    void delete(String id);
    //更新
    void update(UserInfo userInfo);
    /**
     * 查单个
     */
    UserInfo get(String id);
    //查所有
    List<UserInfo> list();
}
@Service
//@Transactional  现在先不能用,MongoDB不支持,在讲完复制集后才需要
public class UserInfoServiceImpl implements IUserInfoService {

    @Autowired
    private UserInfoRepository repository;

    @Override
    public void save(UserInfo userInfo) {
        repository.save(userInfo);
    }

    @Override
    public void delete(String id) {
        repository.deleteById(id);
    }

    @Override
    public void update(UserInfo userInfo) {
        repository.save(userInfo);
    }

    @Override
    public UserInfo get(String id) {
        //orElse 表示若没有值就返回null
        return repository.findById(id).orElse(null);
    }

    @Override
    public List<UserInfo> list() {
        return repository.findAll();
    }
}

3、trip-website 模块

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>parent</artifactId>
        <groupId>com.afei</groupId>
        <version>1.0.0</version>
        <relativePath>../wolf2w/pom.xml</relativePath>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>trip-website-api</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.afei</groupId>
            <artifactId>trip-core</artifactId>
        </dependency>

        <!--<dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>-->
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <fork>true</fork>
                    <mainClass>cn.wolfcode.wolf2w.WebSite</mainClass>
                    <layout>ZIP</layout>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <!--<goal>repackage</goal>-->
                            <!-- 可以把依赖的包都打包到生成的Jar包中 -->
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

application.properties

server.port=8080
#mongodb
#spring.data.mongodb.uri=mongodb://127.0.0.1:27017,127.0.0.1:27018,127.0.0.1:27019/wolf2w?replicaSet=rs
spring.data.mongodb.uri=mongodb://127.0.0.1:27017/wolf2w
logging.level.org.springframework.data.mongodb.core= DEBUG

#redis
#spring.redis.host=127.0.0.1

#elasticsearch
# elasticsearch集群名称,默认的是elasticsearch
#spring.data.elasticsearch.cluster-name=elasticsearch
#节点的地址,注意api模式下端口号是9300,千万不要写成9200
#集群时,用逗号隔开,es会自动寻找节点
#spring.data.elasticsearch.cluster-nodes=127.0.0.1:9300
#是否开启本地存储
#spring.data.elasticsearch.repositories.enable=true


#短信
#sms.url=https://way.jd.com/chonry/smsapi?sign={1}&mobile={2}&content={3}&appkey={4}
#sms.appkey=dd1f7d99cd632060789a56cfaa3b77ce
#sms.url=https://way.jd.com/HZXINXI/noticeSms?mobile={1}&content={2}&sendTime=&appkey={3}
#sms.appkey=dd1f7d99cd632060789a56cfaa3b77ceappkey

后端控制器方法

@RestController
@RequestMapping("users")
public class UserInfoController {
    @Autowired
    private IUserInfoService userInfoService;
    
    @GetMapping("/get")
    public Object get(String id){
        return userInfoService.get(id);
    }
}

springBoot 启动类

@SpringBootApplication
public class WebSite {
    public static void main(String[] args) {
        SpringApplication.run(WebSite.class,args);
    }
}

修改一下启动类的名称

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nIUCZP9j-1651834690333)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324141526048.png)]

测试一下

运行启动类

访问 http://localhost:8080/users/get?id=5e295f01a00e265228f963ea

有数据,表示环境搭建成功

4、trip-website

创建 静态的 web 项目

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bx9baCGK-1651834690334)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324132239981.png)]

拷贝文件

配置 Tomcat

搭建环境的问题集合

  1. 如果搭建错误,但是不想重新搭建

    打开项目所在文件夹,删除里面 .idea 等所有文件,只保留 src 目录、pom.xml 文件 两个内容

    然后打开 idea ,选择导入项目工程 ,注意 勾选search for projects recursively

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4imub7s5-1651834690335)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324133431549.png)]

    对于静态的web模块,需要在导入项目之后,单独导入模块

  2. 在右侧的 Maven Projects 里面,呈现灰色

    点击上面的 + ,选择磁盘中 pom.xml 文件导入

  3. 在 pom.xml 文件中爆红

    1. 网络不行,下载慢

    2. 可能以前下载一半,或者下载错误。导致现在找不到对应的依赖

      进入磁盘中 maven 仓库的文件夹,找到对应爆红依赖的目录,删除,然后回到 idea 重新下载

    3. 可能是 idea 版本问题

    4. 可能是 SpringBoot 版本问题,换个版本

  4. 还是不行。清空并重启 idea

码云准备

  1. 先在 码云 上新建仓库

  2. 进入磁盘中项目所在目录,放入忽略文件

  3. 右击打开 Git Bash,分步执行一下命令

    git config --global user.name "afei"
    git config --global user.email "2371412162@qq.com"
    git init 
    git add .
    git commit -m "项目初始化"
    # 丢入远程仓库
    git remote add origin https://gitee.com/leigedexiaomimei/wolf2w.git
    # 推送上去
    git push -u origin "master"
    
  4. 码云上可以查看项目

  5. 如果有更新,本地更新完毕之后,执行以下,即可推送

    git init 
    git add .
    git commit -m "项目初始化"
    git push
    

3、用户注册

手机号校验

分析需求

查看前端代码,发现注册发起请求,会写带参数,且返回值为 Boolean 类型

编写后端控制器

trip-website-api模块中
    
@GetMapping("/checkPhone")
public Object checkPhone(String phone){
    boolean ret = userInfoService.checkPhone(phone);
    return ret;
}

service 接口

注意,对于 返回值为 Boolean 类型的方法, 一定要写注解表明返回值所表达的意思

/**
 * 检查手机号码是否存在
 * @param phone
 * @return true:手机号码存在;false:手机号码不存在
 */
boolean checkPhone(String phone);
@Override
public boolean checkPhone(String phone) {
    UserInfo userInfo =repository.findByPhone(phone);
    return userInfo == null;
}

Repository 接口中声明方法

注意,定义方法要有写注释的习惯

@Repository
public interface UserInfoRepository extends MongoRepository<UserInfo, String> {
    /**
     * 通过手机号查询用户对象
     * @param phone
     * @return
     */
    UserInfo findByPhone(String phone);
}

跨域配置

启动 trip-website-api 的启动类,启动 trip-website 的 Tomcat

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8TIs8hsf-1651834690336)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324161219021.png)]

查看注册页面,进行注册

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vxqzzelq-1651834690336)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324161837940.png)]

出现跨域问题,在启动类中进行配置即可

@SpringBootApplication
public class WebSite {

    //跨域访问
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            //重写父类提供的跨域请求处理的接口
            public void addCorsMappings(CorsRegistry registry) {
                //添加映射路径
                registry.addMapping("/**")
                        //放行哪些原始域
                        .allowedOrigins("*")
                        //是否发送Cookie信息
                        .allowCredentials(true)
                        //放行哪些原始域(请求方式)
                        .allowedMethods("GET", "POST", "PUT", "DELETE","OPTIONS")
                        //放行哪些原始域(头部信息)
                        .allowedHeaders("*")
                        //暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
                        .exposedHeaders("Header1", "Header2");
            }
        };
    }

    public static void main(String[] args) {
        SpringApplication.run(WebSite.class,args);
    }
}

短信验证码

分析需求

需要在点击获取验证码时发起请求,并得到验证码1,

用户输入后,对比验证码时会发起请求,得到用户输入的验证码2,

如何在跨请求中获取到验证码数据,以进行对比是否输入正确?可选择将验证码存储到 session 、redis 、数据库 等中。但是因为验证码还有时效要求,推荐使用 session 、redis 。

下面是使用 session :

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4EP8raMJ-1651834690337)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324203011209.png)]

但是 session 在 安卓 等平台不是很好使用,所以推荐使用 redis

下面是使用 redis :

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1tueoK3Q-1651834690338)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220324203430492.png)]

启动 Redis 服务

推荐使用 Redis ,因为在 trip-website-api 和 trip-mgrsite 里面都会用到 Redis,所以在 trip-core 里面进行代码编写

下面是 Redis 代码部分:

trip-core 的pom.xml

引入依赖 redis

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

trip-core 的service

@Service
public class UserInfoRedisServiceImpl implements IUserInfoRedisService {
    @Autowired
    private StringRedisTemplate template;
}

trip-website-api 的配置文件

为什么配置文件在 trip-website-api 而不是在 trip-core 里面?

如果 trip-core 里面有配置文件,在 trip-website-api 引入其 jar 包时,就会被覆盖

同理,trip-mgrsite 里面也会被覆盖

然后二者各自还要配置自己需要的,还不如直接自己配置自己的

#redis
spring.redis.host=127.0.0.1

工具类

提供两个工具类:

  • Consts :提供时间常量

    /**
     * 系统常量
     */
    public class Consts {
        //验证码有效时间
        public static final int VERIFY_CODE_VAI_TIME = 5;  //单位分
        //token有效时间
        public static final int USER_INFO_TOKEN_VAI_TIME = 30;  //单位分
    }
    
  • JsonResult :提供返回结果

    @Setter
    @Getter
    @NoArgsConstructor
    public class JsonResult<T> {
        public static final int CODE_SUCCESS = 200;
        public static final String MSG_SUCCESS = "操作成功";
        public static final int CODE_NOLOGIN = 401;
        public static final String MSG_NOLOGIN = "请先登录";
    
        public static final int CODE_ERROR = 500;
        public static final String MSG_ERROR = "系统异常,请联系管理员";
    
        public static final int CODE_ERROR_PARAM = 501;  //参数异常
    
        private int code;  //区分不同结果, 而不再是true或者false
        private String msg;
        private T data;  //除了操作结果之后, 还行携带数据返回
        public JsonResult(int code, String msg, T data){
            this.code = code;
            this.msg = msg;
            this.data = data;
        }
        public static <T> JsonResult success(T data){
            return new JsonResult(CODE_SUCCESS, MSG_SUCCESS, data);
        }
    
        public static JsonResult success(){
            return new JsonResult(CODE_SUCCESS, MSG_SUCCESS, null);
        }
    
        public static <T>  JsonResult error(int code, String msg, T data){
            return new JsonResult(code, msg, data);
        }
    
        public static JsonResult defaultError(){
            return new JsonResult(CODE_ERROR, MSG_ERROR, null);
        }
    
        public static JsonResult noLogin() {
            return new JsonResult(CODE_NOLOGIN, MSG_NOLOGIN, null);
        }
    }
    

后端控制器方法

@GetMapping("/sendVerifyCode")
public Object sendVerifyCode(String phone){
    userInfoService.sendVerifyCode(phone);
    return JsonResult.success();
}

MongoDB的service (trip-website-api)

@Autowired
private IUserInfoRedisService userInfoRedisService;
@Override
public void sendVerifyCode(String phone) {
    //创建验证码
    String code = UUID.randomUUID().toString()
            .replaceAll("-", "")
            .substring(0, 4);
    //创建短信
    StringBuilder sb = new StringBuilder(80);
    sb.append("您注册的短信验证码是:").append(code).append(",请在")
            .append(Consts.VERIFY_CODE_VAI_TIME)
            .append("分钟内使用");
    //假装短信已发送
    System.out.println(sb);
    //将验证码缓存到redis中
    userInfoRedisService.setVerifyCode(phone,code);
}

Redis的service (trip-core)

@Service
public class UserInfoRedisServiceImpl implements IUserInfoRedisService {
    @Autowired
    private StringRedisTemplate template;

    //将验证码缓存到redis中
    @Override
    public void setVerifyCode(String phone, String code) {
        //key 必须唯一、可读、有效、灵活
        String key = "verify_code:" + phone;
        template.opsForValue()
                //参数1:key值;  参数2:value值;  参数3:有效时间;  参数4:时间单位
                .set(key, code, Consts.VERIFY_CODE_VAI_TIME*60L, TimeUnit.SECONDS);
    }
}

点击注册

自定义异常

/**
 * 自定义异常, 用于区分给用户看异常与系统异常
 */
public class LogicException extends RuntimeException {
    public LogicException(String message) {
        super(message);
    }
}

参数断言判断的工具类

/**
 * 参数断言判断
 */
public class AssertUtils {
    /**
     * 判断指定value参数是否有值, 如果没有抛出异常, 信息: msg
     * @param v
     * @param msg
     */
    public static void hasLength(String v, String msg) {
        if(!StringUtils.hasLength(v)){
            throw new LogicException(msg);
        }
    }

    /**
     * 判断传入的2个参数是否相等
     * @param v1
     * @param v2
     * @param msg
     */
    public static void isEquals(String v1 , String v2, String msg) {
        if(v1 == null || v2 == null){   //判断,工具类必须严谨
            throw new LogicException("传入参数不能为null");
        }
        if(!v1.equals(v2)){
            throw new LogicException(msg);
        }
    }
}

实现注册

后端控制器

@PostMapping("/regist")
public Object regist(String phone, String nickname,String password,String rpassword,String verifyCode){
    userInfoService.regist(phone,nickname,password,rpassword,verifyCode);
    return JsonResult.success();
}

MongoDB 中 service 实现类

@Override
public void regist(String phone, String nickname, String password, String rpassword, String verifyCode) {
    //校验参数是否为空
    AssertUtils.hasLength(phone , "手机号不可为空");
    AssertUtils.hasLength(nickname , "昵称不可为空");
    AssertUtils.hasLength(password , "密码不可为空");
    AssertUtils.hasLength(rpassword , "确认密码不可为空");
    AssertUtils.hasLength(verifyCode , "验证码不可为空");
    //校验两次密码是否相等
    AssertUtils.isEquals(password,rpassword,"两次输入的密码不一致");
    //校验手机号码是否正确 @Todo java的正则表达式
    //校验手机号是否唯一
    if (!this.checkPhone(phone)){
        throw new RuntimeException("该手机号码已经被注册");
    }
    //从redis获取验证码,再校验短信验证码是否正确
    String code = userInfoRedisService.getVerifyCode(phone, verifyCode);
    if (!verifyCode.equalsIgnoreCase(code)){
        throw new RuntimeException("验证码失效或错误");
    }
    //注册
    UserInfo userInfo = new UserInfo();
    userInfo.setNickname(nickname);
    userInfo.setPhone(phone);
    userInfo.setEmail("");
    userInfo.setPassword(password);  //假装加密
    userInfo.setGender(UserInfo.GENDER_SECRET);
    userInfo.setLevel(1);
    userInfo.setCity("");
    userInfo.setHeadImgUrl("/images/default.jpg");
    userInfo.setInfo("");

    //核心属性必须自己控制,就算实体类里面有默认值,为了防止意外最好自己添加
    userInfo.setState(UserInfo.STATE_NORMAL);

    this.save(userInfo);
}

Redis 中 service 实现类

//从redis获取验证码
@Override
public String getVerifyCode(String phone, String verifyCode) {
    String key = "verify_code:" + phone;
    return template.opsForValue().get(key);
}

细节优化

统一异常处理

现在的异常只有后台可以看到,用户体验不到。所以需要将异常信息反馈给用户,需要捕获异常

但是存在用户提示异常、系统异常,系统异常需要美化,所以使用自定义异常类来区分两种异常

  1. 可以选择直接修改后端控制器方法(不推荐)

    这样很麻烦,所以选择统一异常处理

    @PostMapping("/regist")
    public Object regist(String phone, String nickname,String password,String rpassword,String verifyCode){
        try {
            userInfoService.regist(phone,nickname,password,rpassword,verifyCode);
        } catch (LogicException e) {
            e.printStackTrace();
            //给用户看得提示异常
            return JsonResult.error(JsonResult.CODE_ERROR_PARAM, e.getMessage(), null);
        } catch (Exception e) {
            /*
                这种抓取异常方式存在问题:
                    该给用户看的提示异常、系统的异常,都在这个位置被抓取了
                    而真正的要求是系统的异常要美化
                    此时需要操作区分:区分系统异常、用户提示异常
             */
            e.printStackTrace();
            //出问题后给页面提示信息
            return JsonResult.defaultError();
        }
        return JsonResult.success();
    }
    
  2. 自定义异常处理类

    这个类是为了 controller 方法定义的,所以建议声明在 trip-website-api 模块里面

    /**
     *  通用异常处理类
     *      @ControllerAdvice:controller类功能增强注解,动态代理controller类实现一些额外功能
     *  作用:
     *      请求进入controller方法之前做功能增强:日期格式化
     *      请求进入controller方法之后做功能增强:统一异常处理
     */
    @ControllerAdvice
    public class CommonExceptionHandler {
        
        @ExceptionHandler(LogicException.class)
        @ResponseBody
        public Object logicExp
            (Exception e, HttpServletResponse resp) throws IOException {
            e.printStackTrace();
            resp.setContentType("application/json;charset=utf-8");
            return JsonResult.error(JsonResult.CODE_ERROR_PARAM, e.getMessage(), null);
        }
        
        @ExceptionHandler(RuntimeException.class)
        @ResponseBody
        public Object runtimeExp
            (Exception e, HttpServletResponse resp) throws IOException {
            e.printStackTrace();
            resp.setContentType("application/json;charset=utf-8");
         return JsonResult.defaultError();
        }
    

}


现在的后端控制器方法

```java
@PostMapping("/regist")
public Object regist(String phone, String nickname,String password,String rpassword,String verifyCode){
    userInfoService.regist(phone,nickname,password,rpassword,verifyCode);
    return JsonResult.success();
}

_class属性排除

因为给 MongoDB 添加数据,自动会多一列 _class 属性

在启动类里面添加以下代码:

//mongodb 去除_class属性
@Bean
public MappingMongoConverter mappingMongoConverter(MongoDbFactory factory, MongoMappingContext context, BeanFactory beanFactory) {
    DbRefResolver dbRefResolver = new DefaultDbRefResolver(factory);
    MappingMongoConverter mappingConverter = new MappingMongoConverter(dbRefResolver, context);
    try {   mappingConverter.setCustomConversions(beanFactory.getBean(CustomConversions.class));
    } catch (NoSuchBeanDefinitionException ignore) {
    }
    // Don't save _class to mongo
    mappingConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
    return mappingConverter;
}

Redis key重设计:枚举类

前面对于验证码存入 redis 时,key 都是直接保存为 手机号:

String key = "verify_code:" + phone;

当项目多人开发时,其他人也可能使用以上字符串作为 key ,就会导致不唯一

手写容易写错

怎么管理 redis 的 key ?

使用 枚举类 直接定义死 前面的前缀拼接

/*
    枚举类特点:
        1、构造器私有
        2、当类定义完成之后,实例个数固定
        3、剩下操作和普通类一样
 */
/**
 *  redis key管理
 *  约定:一个枚举实例就是一个 key
 */
@Getter
public enum  RedisKeys{
    //短信验证码
    VERIFY_CODE("verify_code", Consts.VERIFY_CODE_VAI_TIME * 60L);

    private String prefix;  //redis的key的前缀
    private Long time;  //redis的key的有效时间,-1L 表示不需要指定过期时间,单位 秒
    private RedisKeys(String prefix, Long time){
        this.prefix = prefix;
        this.time = time;
    }
    
    //拼接完整的redis的 key
    public String join(String ...keys){
        StringBuilder sb = new StringBuilder();
        sb.append(prefix);
        for (String key : keys) {
            sb.append(":").append(key);
        }
        return sb.toString();
    }
}

修改 redis 的 service 实现类

@Service
public class UserInfoRedisServiceImpl implements IUserInfoRedisService {
    @Autowired
    private StringRedisTemplate template;

    @Override
    public void setVerifyCode(String phone, String code) {
        //key 必须唯一、可读、有效、灵活
        String key = RedisKeys.VERIFY_CODE.join(phone);
        template.opsForValue()
                //参数1:key值;  参数2:value值;  参数3:有效时间;  参数4:时间单位
                .set(key, code, RedisKeys.VERIFY_CODE.getTime(), TimeUnit.SECONDS);
    }

    @Override
    public String getVerifyCode(String phone, String verifyCode) {
        String key = RedisKeys.VERIFY_CODE.join(phone);
        return template.opsForValue().get(key);
    }
}

发送真实短信

短信原理分析

需要第三方中间商,使用短信网关实现

就是针对网关暴露出来的接口,进行调用,携带网管提供的 appkey ,

短信网关

京东万象:https://wx.jdcloud.com/api-66

中国网建:http://sms.webchinese.com.cn/

阿里短信

这里使用京东万象,登录进页面。选择一个短信接口,点击测试

右边会有短信网关提供的接口

用户注册(加入短信验证)

SpringMVC 提供了一个工具类 RestTemplate 用于发起 http 请求,使用此类需要引入 web 依赖

需要引入以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <scope>provided</scope>  编译时有效,运行时无效
</dependency>

修改 MongoDB 中 业务实现类

@Override
public void sendVerifyCode(String phone) {
    //创建验证码
    String code = UUID.randomUUID().toString()
            .replaceAll("-", "")
            .substring(0, 4);
    //创建短信
    StringBuilder sb = new StringBuilder(80);
    sb.append("您注册的短信验证码是:").append(code).append(",请在")
            .append(Consts.VERIFY_CODE_VAI_TIME)
            .append("分钟内使用");
    //假装短信已发送
    System.out.println(sb);
    
    //真实发送短信,本质就是使用java发起 http 请求即可。路径就是短信网关提供的路径
    //SpringMVC 提供了一个工具类 RestTemplate 用于发起 http 请求,使用此类需要引入 web 依赖
    RestTemplate template = new RestTemplate();

    //参数1:短信接口 url
    String url = "https://way.jd.com/chuangxin/dxjk?mobile={1}&content=【创信】{2},3分钟内有效!&appkey={3}";
    //参数2:请求接口完之后响应数据封装的对象类型
    //参数3:请求参数列表。通过在接口地址上进行挖洞占位,一一对应参数 ?mobile={1}
    String ret = template.getForObject(url ,String.class, phone, sb.toString(),
                                       "5572fb4609843c40daa7e517e2a65b70");
    System.out.println(ret);
    if (!ret.contains("Success")){
        throw new LogicException("短信发送失败");
    }

    //将验证码缓存到redis中
    userInfoRedisService.setVerifyCode(phone,code);
}

20220325124315005

短信硬编码优化

因为上面代码将 短信内容、网关接口 写死了,后续修改不方便

可以将信息放入配置文件

配置文件:

#短信
sms.url=https://way.jd.com/chuangxin/dxjk?mobile={1}&content=【创信】{2},3分钟内有效!&appkey={3}
sms.appkey=5572fb4609843c40daa7e517e2a65b70

业务实现类:

@Value("sms.url")
private String url;
@Value("sms.appkey")
private String appkey;

@Override
public void sendVerifyCode(String phone) {
    //创建验证码
    String code = UUID.randomUUID().toString()
            .replaceAll("-", "")
            .substring(0, 4);
    //创建短信
    StringBuilder sb = new StringBuilder(80);
    sb.append("您注册的短信验证码是:").append(code).append(",请在")
            .append(Consts.VERIFY_CODE_VAI_TIME)
            .append("分钟内使用");
    //假装短信已发送
    System.out.println(sb);

    //真实发送短信,本质就是使用java发起 http 请求即可。路径就是短信网关提供的路径
    //SpringMVC 提供了一个工具类 RestTemplate 用于发起 http 请求,使用此类需要引入 web 依赖
    RestTemplate template = new RestTemplate();

    //参数1:短信接口 url
    //参数2:请求接口完之后响应数据封装的对象类型
    //参数3:请求参数列表。通过在接口地址上进行挖洞占位,一一对应参数 ?mobile={1}
    String ret = template.getForObject(url ,String.class, phone, sb.toString(), appkey);
    System.out.println(ret);
    if (!ret.contains("Success")){
        throw new LogicException("短信发送失败");
    }

    //将验证码缓存到redis中
    userInfoRedisService.setVerifyCode(phone,code);
}

4、用户登录

互联网项目登录

逻辑分析

传统登录逻辑分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Uulfdkj-1651834690339)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220325154717004.png)]

互联网登录方式:令牌方式(token)+redis

逻辑分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YbGJVWNc-1651834690340)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220325154758045.png)]

首次登陆代码实现

枚举类 RedisKeys 中设置登录token

//登录token
LOGIN_TOKEN("user_login_token", Consts.USER_INFO_TOKEN_VAI_TIME * 60L);

MongoDB 中 service 实现类:

@Override
public UserInfo login(String username, String password) {
   
    UserInfo user = repository.findByPhone(username);
    
    if (user == null || !user.getPassword().equals(password)) {
        throw new LogicException("账号或密码错误");
    }
    //防止将user传递给浏览器时,看到用户的真实密码
    user.setPassword("");
    return user;
}

redis 中 service 实现类:

@Override
public String setToken(UserInfo user) {
    // 1、创建token
    String token = UUID.randomUUID().toString().replaceAll("-", "");
    
    // 2、将token作为key,用户对象作为value,设置到redis中
    String key = RedisKeys.LOGIN_TOKEN.join(token);
    String value = JSON.toJSONString(user);
    
    // 3、将数据存到redis中,设置有效时间30分钟
    template.opsForValue().set(key,value,RedisKeys.LOGIN_TOKEN.getTime(), TimeUnit.SECONDS);
    
    return token;
}

后端控制器方法

@Autowired
private IUserInfoRedisService userInfoRedisService;
@PostMapping("/login")
public Object login(String username,String password){
    //user
    UserInfo user = userInfoService.login(username,password);
    //token
    String token = userInfoRedisService.setToken(user);

    Map<String, Object> map = new HashMap<>();
    map.put("user",user);
    map.put("token",token);
    return JsonResult.success(map);
}

二次访问代码实现

需求:页面通过 /users/currentUser 接口获取到redis中当前登录用户信息(注意需要携带参数token)

<script>
    //需求:页面通过/users/currentUser接口获取到redis中当前登录用户信息(注意需要携带参数token)
    $(function () {
        /*$.ajax({
            url:domainUrl+"/users/currentUser",
            data:{},
            beforeSend:function (xhr) {  //请求发送前执行逻辑
                // xhr:ajax执行对象
                //getToken():前端自定义方法,获取到前面存储在cookie中的 token
                xhr.setRequestHeader("token", Cookies.get('token'));
            },
            success:function (data) {
                console.log(data);
            }
        })*/
    
        // 对上面代码进行封装,然后使用以下代码进行调用
        ajaxGet("/users/currentUser", {}, function (data) {
            console.log(data);
        })
    })
</script>

后端查询登录用户

后端控制器方法:

@GetMapping("/currentUser")
public Object currentUser(HttpServletRequest request){
    String token = request.getHeader("token");
    UserInfo user = userInfoRedisService.getUserByToken(token);
    return JsonResult.success(user);
}

redis 中 service 实现类:

@Override
public UserInfo getUserByToken(String token) {
    if (!StringUtils.hasLength(token)){
        return null;
    }
    String key = RedisKeys.LOGIN_TOKEN.join(token);
    if (!template.hasKey(key)){
        return null;
    }
    String user = template.opsForValue().get(key);
    //JSON的解析操作,将JSON字符串还原为java对象
    UserInfo userInfo = JSON.parseObject(user, UserInfo.class);
    //重置token的有效时间30分钟
    template.expire(key, RedisKeys.LOGIN_TOKEN.getTime(), TimeUnit.SECONDS);
    return userInfo;
}

浏览器代码分析

在首次登陆,点击登录之后,浏览器接收并解析相应数据得到 token 、user。存储到 cookie 中,并设置有效时间 30 分钟

实现了如果在一个页面点击了登录,登录之后会回到这个页面。

如果这个页面是注册页面、登录页面,就跳转到首页

登录时代码如下:

$("#_js_loginBtn").click(function () {
    $("#_j_login_form").ajaxSubmit({
        url:domainUrl +"/users/login",
        type:"POST",
        success:function (data) {
            if(data.code == 200){
                var map = data.data;
                var token = map.token;  //后续后端获取当前登录用户信息
                var user = map.user;  //前端页面需要显示用户信息
                // 1、sessionStorage  客户端技术可以在浏览器窗口存储数据, 一但关闭窗口,
                						// 数据就没了, 如果多个窗口, 数据无法共享
                // 2、localStorage  客户端技术可以在浏览器窗口存储数据, 数据操作是永久
                // 3、cookie 客户端技术可以在浏览器窗口存储数据, 特点有时效性

                //参数1:cookie的key值, 参数2: cookie的value值, 参数3: 有效时间, 单位天
                Cookies.set('user', JSON.stringify(user), { expires: 1/48,path:'/'});
                Cookies.set('token', token, { expires: 1/48,path:'/'});

                // document.referrer 上一个请求路径
                //实现了如果在一个页面点击了登录,登录之后会回到这个页面
                var url = document.referrer ? document.referrer : "/";

                //如果这个页面是注册页面、登录页面,就跳转到首页
               if(url.indexOf("regist.html") > -1 || url.indexOf("login.html") > -1){
                    url = "/";
                }
                window.location.href = url
            }else{
                popup(data.msg);
            }
        }
    })
})

二次登陆时,前端也会延长 cookie 的有效时间

//延长登录时间
var token = Cookies.get("token");
var user = Cookies.get("user");
if(token&&user){
    Cookies.set('token', token, { expires: 1/48,path:'/'});
    Cookies.set('user', user, { expires: 1/48,path:'/'});
}

将数据库中的用户信息,显示前端页面

显示头像代码:

<script>
    var user = getUserInfo();
    if(user){
        $(".login_info").css("display", "")
        $(".login-out").css("display", "none")

        $("#login_user_headUrl").prop("src", user.headImgUrl);
    }else{
        $(".login-out").css("display", "")
        $(".login_info").css("display", "none")
    }
    $("li[name="+currentNav+"]").addClass("header_nav_active");
</script>

登录控制——请求统一拦截

分析

登录控制可以通过 过滤器 filter 、拦截器 interceptor 两种方式实现

但是本例中使用的是 SpringBoot 基础的框架,可选择 拦截器 interceptor

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WexlIrPc-1651834690341)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220328095556644.png)]

代码实现

因为现在的登录控制,控制的是前端用户的登录,所以拦截器放在 trip-website-api 模块里面比较合适

自定义拦截器:

这里需要 解决拦截器的跨域问题

/**
 *  用户登录检查
 */
public class CheckLoginInterceptor implements HandlerInterceptor {
    @Autowired
    private IUserInfoRedisService userInfoRedisService;

    //请求进入是进行登录拦截
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //解决拦截器跨域问题
        /*
        原理:
            如果请求不是动态的(静态资源),handler对象就不是HandlerMethod类的实例
            如果请求是跨域的(请求方式是:OPTIONS),handler对象也不是HandlerMethod类的实例
         */
        if (!(handler instanceof HandlerMethod)){
            //说明访问的是静态资源,不是控制器的处理方法
            return true;
        }
        /**
         *  HandlerMethod 请求映射方法信息(所在类的信息、方法信息[映射路径/方法名/参数/注解/返回值...])的封装对象
         *  1、SpringMVC启动时,扫描所有Controller类,解析所有映射方法,将每个映射方法封装为一个对象 HandlerMethod
         *      该类包含所有请求映射方法信息
         *  2、SpringMVC针对 请求映射方法信息封装对象类 ,使用类似map的数据结构进行统一管理
         *      key:请求映射路径     value:请求映射方法信息封装对象类
         *  3、页面发起请求时,进入拦截器之后,SpringMVC自动解析请求路径,得到 url
         *      获取 url 之后,进而获取 url 路径对于的映射方法的 HandlerMethod 示例(handler)
         *  4、调用拦截器 preHandle 方法,并将请求对象request、响应对象response、映射方法对象handler 一起传入
         */

        String token = request.getHeader("token");
        UserInfo user = userInfoRedisService.getUserByToken(token);
        if (user == null) {
            //没登录
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(JSON.toJSONString(JsonResult.noLogin()));
            return false;
        }
        return false;
    }
}

配置拦截器:

在主配置类中 implements WebMvcConfigurer ,实现 WebMvcConfigurer 此类,表示配置的 web.xml

@SpringBootApplication
public class WebSite implements WebMvcConfigurer{
    
    //将拦截器注入Spring容器中,交给SpringBoot管理
    @Bean
    public CheckLoginInterceptor checkLoginInterceptor(){
        return new CheckLoginInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(checkLoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/users/checkPhone")
                .excludePathPatterns("/users/sendVerifyCode")
                .excludePathPatterns("/users/regist")
                .excludePathPatterns("/users/login");
    }

    //mongodb 去除_class属性
    @Bean
    public MappingMongoConverter mappingMongoConverter(MongoDbFactory factory, MongoMappingContext context, BeanFactory beanFactory) {
        //......
    }

    //处理跨域访问
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            //重写父类提供的跨域请求处理的接口
            public void addCorsMappings(CorsRegistry registry) {
                //添加映射路径
                registry.addMapping("/**")
                        //放行哪些原始域
                        .allowedOrigins("*")
                        //是否发送Cookie信息
                        .allowCredentials(true)
                        //放行哪些原始域(请求方式)
                        .allowedMethods("GET", "POST", "PUT", "DELETE","OPTIONS")
                        //放行哪些原始域(头部信息)
                        .allowedHeaders("*")
                        //暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
                        .exposedHeaders("Header1", "Header2");
            }
        };
    }

    public static void main(String[] args) {
        SpringApplication.run(WebSite.class,args);
    }
}

为什么要二次解决跨域?

明明之前在启动类里面已经处理了跨域问题,为什么现在在自定义拦截器类里面又要处理一次跨域问题

跨域请求原理:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h203Y3MB-1651834690342)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220328102701218.png)]

拦截器放行:

本例中,首次预请求的时候,没有携带 cookie 的参数,在拦截器的位置就被拦截,导致第二步浏览器没有收到允许的指令,就直接报错。访问失败

所以需要在拦截器对跨域请求进行放行

if (!(handler instanceof HandlerMethod)){
    //说明访问是静态的,或者请求是跨域的
    return true;
}

登录控制——请求区分拦截

对于旅游网页,就算用户不登录应该也可以看到一部分的网页信息,所以此处有新的需求

分析

**需求:**要求部分请求必须登录之后才可以访问,但是部分请求就算不登录也可以访问

此处就可以想到 RBAC 项目中权限控制 的操作:

使用注解来控制,需要控制权限的就加上注解,不需要控制的就不加上注解

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8UaJXBgy-1651834690343)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220328103651438.png)]

代码实现

trip-website-api 自定义注解

因为和拦截器相关,所以放在 trip-website-api 模块

/**
 * 登录校验注解
 *   约定:如果该注解贴某个映射方法上面,表示该映射方法必须登录之后才可以访问
 *         如果某个映射方法没有该注解,表示随意
 */
@Target(ElementType.METHOD)  //表示该注解贴的是方法上面
@Retention(RetentionPolicy.RUNTIME)  //表示该注解在代码执行时
public @interface RequireLogin { }
在拦截器里实现逻辑

需要注意:

​ 在拦截器里面,对于让登录的 token 增加有效时间的方法,需要让其一直能执行到,不能放在条件判断里面。

​ 否则会出现无法登陆的 bug

/**
 *  用户登录检查
 */
public class CheckLoginInterceptor implements HandlerInterceptor {
    @Autowired
    private IUserInfoRedisService userInfoRedisService;

    //请求进入是进行登录拦截
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        
        /*
        原理:
            如果请求不是动态的(静态资源),handler对象就不是HandlerMethod类的实例
            如果请求是跨域的(请求方式是:OPTIONS),handler对象也不是HandlerMethod类的实例
         */
        //解决拦截器跨域问题
        if (!(handler instanceof HandlerMethod)){
            return true;
        }

        //判断当前请求映射方法是否贴有 @RequireLogin 注解
        HandlerMethod hm = (HandlerMethod) handler;

        // getUserByToken()方法底层会重置token的有效时间30分钟
        //所以,注意此行代码需要放在判断的 if 语句外面,以防出现登录不上的 bug
        String token = request.getHeader("token");
        UserInfo user = userInfoRedisService.getUserByToken(token);
        
        if (hm.hasMethodAnnotation(RequireLogin.class)){
            //如果有,需要进行登录校验
            if (user == null) {
                //没登录
                response.setContentType("application/json;charset=utf-8");
                response.getWriter().write(JSON.toJSONString(JsonResult.noLogin()));
                return false;
            }
        }
        //如果没有,直接放行
        return true;
    }
}

5、目的地

trip-mgrsite 模块配置

现在做的是数据管理后端,需要单独模块,也需要启动类

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>parent</artifactId>
        <groupId>com.afei</groupId>
        <version>1.0.0</version>
        <relativePath>../../wolf2w/pom.xml</relativePath>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>trip-mgrsite</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
        <dependency>
            <groupId>com.afei</groupId>
            <artifactId>trip-core</artifactId>
        </dependency>
    </dependencies>

</project>

配置文件

server.port=9999
#spring.data.mongodb.uri=mongodb://127.0.0.1:27017,127.0.0.1:27018,127.0.0.1:27019/wolf2w?replicaSet=rs
spring.data.mongodb.uri=mongodb://127.0.0.1:27017/wolf2w
logging.level.org.springframework.data.mongodb.core= DEBUG

spring.freemarker.suffix=.ftl
#freemarker去除数字间隔符:330,000
spring.freemarker.settings.number_format=0.##

启动类

@SpringBootApplication
public class MgrSite {
    public static void main(String[] args) {
        SpringApplication.run(MgrSite.class,args);
    }
}

静态页面

区域管理

表、模型

添加 MongoDB 数据库表:地区(destination_region)

refIds Array 列是关联的目的地的 id

db.getCollection("destination_region").drop();
db.createCollection("destination_region");

db.getCollection("destination_region").insert([ {
    _id: ObjectId("5e2aa2e1884700005900694a"),
    name: "日本",
    sn: "Japan",
    ishot: NumberInt("1"),
    sequence: NumberInt("2"),
    info: "日本",
    refIds: [
        ObjectId("5e2ab4f48847000059006e65"),
        ObjectId("5e2ab4f48847000059006e66"),
        ObjectId("5e2ab4f48847000059006e67"),
        ObjectId("5e2ab4f48847000059006e68")
    ]
} ]);
-- ... 此处省略

domain

/**
 * 区域
 */
@Setter
@Getter
@Document("destination_region")
public class Region extends BaseDomain {
    public static final int STATE_HOT = 1;
    public static final int STATE_NORMAL = 0;
    
    private String name;        //地区名
    private String sn;          //地区编码
    private List<String> refIds = new ArrayList<>();     //关联的id
    private int ishot = STATE_NORMAL;         //是否为热点
    private int sequence;   //序号
    private String info;  //简介
    
    public String getJsonString(){
        Map<String, Object> map = new HashMap<>();
        map.put("id", id);  //注意,此处需要处理
        map.put("name",name);
        map.put("sn",sn);
        map.put("refIds",getRefIds());
        map.put("ishot",ishot);
        map.put("sequence",sequence);
        map.put("info",info);
        return JSON.toJSONString(map);
    }
}

因为 id 是从父类 BaseDomain 中获取的,有两种方式:

  1. 若父类中是 private 修饰:

    不可以直接获取,会报错。使用 super.getId()

  2. 若父类中是 protected 修饰:

    直接 id 获取,不报错

CRUD 准备

参数查询对象

@Setter
@Getter
public class RegionQuery extends QueryObject { }

持久化接口

//区域持久化操作接口,类似 Mapper 接口
@Repository
public interface RegionRepository extends MongoRepository<Region, String> { }

业务接口及实现类

@Service
public class IRegionServiceImpl implements IRegionService {
    @Autowired
    private RegionRepository regionRepository;
    
    // ......
}

分页列表

PageHelper 是一个 MyBatis 的分页插件,是一个 PageInfo<> 对象

Page<> 对象是 spring-data 提供的,需要使用 MongodbTemplate

springdata 是spring团队为了同一数据库(关系型与非关系型)操作而提供一套访问数据库的API规范

MongoDB的service

//直接使用
@Autowired
private MongoTemplate mongoTemplate;
@Override
public Page<Region> query(RegionQuery qo) {
    // 创建查询对象:理解为MQL语句拼接对象
    Query query = new Query();
    //总数total
    Long total = mongoTemplate.count(query, Region.class);
    if(total == 0){
        return Page.empty();  //空的分页对象
    }
    //分页参数对象pageable
    Pageable pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(), Sort.Direction.ASC, "id");
    //分页数据content
    query.with(pageable);
    List<Region> list = mongoTemplate.find(query, Region.class);

    //需要三个参数:List<T> content, Pageable pageable, long total
    return new PageImpl<Region>(list,pageable,total);
}

后端控制器方法

@Controller
@RequestMapping("region")
public class RegionController {
    @Autowired
    private IRegionService regionService;
    @RequestMapping("/list")
    public String list(Model model, @ModelAttribute("qo") RegionQuery qo){
        Page<Region> page = regionService.query(qo);
        model.addAttribute("page",page);
        return "region/list";
    }
}

添加与编辑

添加编辑需要注意点, 关联的目的地需要是多选的

下拉框数据显示、搜索

显示:

@RequestMapping("/getDestByDeep")
public Object getDestByDeep(){
    return destinationService.list();
}
$.get("/region/getDestByDeep", {deep:3}, function (data) {
    var html = '';
    $.each(data, function (index, item) {
        html += '<option value="' + item.id + '">'+item.name+'</option>'
    })
    $("#refIds").html(html);

    $('#refIds').selectpicker('refresh'); //刷新组件
})

下拉搜索框:

使用 bootstrap-select 插件:引入插件之后,在下拉框的 class 属性中,使用 selectpicker 即可。

multiple 的作用是可以在下拉框中实现多选

<select class="form-control selectpicker "  multiple 
        id="refIds" name="refIds" data-live-search="true" title="请选择关联的目的地">
</select>

在下拉框中需要刷新

$('#refIds').selectpicker('refresh'); //刷新组件
添加与编辑

在区域的 service 实现类中,给保存数据的方法设置空的 id 。MongoDB 会在添加的时候无法设置自动生成的 id 值(后面游记的时候会说)

@Service
public class RegionServiceImpl implements IRegionService {
    @Autowired
    private RegionRepository regionRepository;

    @Override
    public void save(Region region) {
        region.setId(null);
        regionRepository.save(region);
    }
    
    // ......
}

后端控制器方法

@RequestMapping("/saveOrUpdate")
@ResponseBody
public Object saveOrUpdate(Region region){
    regionService.saveOrUpdate(region);
    return JsonResult.success();
}

service 实现类

@Override
public void saveOrUpdate(Region region) {
    if (StringUtils.hasLength(region.getId())){ //编辑
        /**
         * 注意,MongoDB的Repository接口的更新操作是全量更新,容易出现参数丢失问题
         * 此处没有出现问题,是因为编辑页面需要输入的数据个数和表中列数一致。是全量
         */
        this.update(region);
    }else {  // 添加
        this.save(region);
    }
}

查看

查看:指定区域下挂载的关联目的地

后端控制器方法

@RequestMapping("/getDestByRegionId")
@ResponseBody
public Object getDestByRegionId(String rid){
    return destinationService.queryDestByRegionId(rid);
}

service 实现类

@Autowired
private IRegionService regionService;
@Override
public List<Destination> queryDestByRegionId(String rid) {
    //获取区域对象
    Region region = regionService.get(rid);
    //通过区域对象获取管理目的地id集合
    List<String> ids = region.getRefIds();
    return destinationRepository.findByIdIn(ids);
}

这里使用了 MongoDB 的 JPA 查询规范: 参考下图

设置热门

后端控制器方法

@RequestMapping("/changeHotValue")
@ResponseBody
public Object changeHotValue(String id, Integer hot){
    regionService.changeHotById(id, hot);
    return JsonResult.success();
}

service 实现类

@Override
public void changeHotById(String id, Integer hot) {
    //全量更新
    /*Region region = this.get(id);
    region.setIshot(hot);
    this.update(region);*/

    //部分字段更新 :db.集合名.updateMany({_id: "123"},{$set: {ishot: 1}})
    Query query = new Query();
    query.addCriteria( Criteria.where("_id").is(id));
    Update update = new Update();
    update.set("ishot",hot);
    mongoTemplate.updateMulti(query, update, Region.class);
}

删除

@RequestMapping("/delete")
@ResponseBody
public Object delete(String id){
    regionService.delete(id);
    return JsonResult.success();
}

目的地管理

表、模型

添加 MongoDB 数据库表:目的地表(destination)

属性名称属性类型属性说明
IdString主键
parentIdString父级id
parentNameString父级名称
nameString目的地名
englishString英文
coverUrlString封面
infoString简介,描述

domain

/**
 * 目的地(行政地区:国家/省份/城市)
 */
@Setter
@Getter
@Document("destination")
public class Destination extends BaseDomain {
    private String name;        //名称
    private String english;  //英文名
    private String parentId; //上级目的地
    private String parentName;  //上级目的名
    private String info;    //简介
    private int deep;
    private  String coverUrl;
    
    //子地区  org.springframework.data.annotation.Transient
    @Transient  //MongoDB添加时忽略该字段,不需要添加到库中
    private List<Destination> children = new ArrayList<>();
    
    public String getJsonString(){
        Map<String,Object> map = new HashMap<>();
        map.put("id", super.getId());
        map.put("info", this.info);
        return JSON.toJSONString(map);
    }
}

CRUD 准备

参数查询对象

@Setter
@Getter
public class DestinationQuery extends QueryObject { }

持久化接口

//区域持久化操作接口,类似 Mapper 接口
@Repository
public interface DestinationRepository extends MongoRepository<Destination, String> {}

业务接口及实现类

@Service
public class IDestinationServiceImpl implements IRegionService {
    @Autowired
    private DestinationRepository destinationRepository;
    
    // ......
}

分页工具类

涉及高级查询

因为分页操作与 区域的分页有很多相同代码,所以抽取工具类

工具类

/**
 * 数据库操作工具类
 */
public class DBHelper {
    //Class<Destination> cla = Destination.class;
    public static <T> Page<T> query(Class<T> clazz, MongoTemplate template, 
                                    Query query, Pageable pageable, QueryObject qo) 
    {
        //total
        long total = template.count(query, clazz);
        if (total == 0){
            return Page.empty();
        }
        //list
        query.with(pageable);
        List<T> list = template.find(query, clazz);
        return new PageImpl<T>(list,pageable,total);
    }
}

service实现类 涉及高级查询

//直接使用
@Autowired
private MongoTemplate template;
@Override
public Page<Destination> query(DestinationQuery qo) {
    Query query = new Query();
    
    //需要携带关键字查询
    if (StringUtils.hasLength(qo.getKeyword())){
        query.addCriteria(Criteria.where("name").regex(qo.getKeyword()));
    }
    
    //pageable
    Pageable pageable = PageRequest.of(qo.getCurrentPage() - 1, 
                                       qo.getPageSize(), Sort.Direction.ASC, "id");
    return DBHelper.query(Destination.class,template,query,pageable,qo);
}

导航吐司

从国家位置开始,一直往下探, 根>>中国>>广东>>广州

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IFnAmUE4-1651834690344)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220329095400831.png)]

数据库表里面设计了列 parentId ,可以通过上级 id 来实现导航吐司

数据显示

数据库表里面设计了列 parentId ,可以通过上级 id 来实现查询当前点击的目的地的 子级 目的地

页面携带参数 <a href="/destination/list?parentId=${entity.id}">

添加查询参数属性

@Setter
@Getter
public class DestinationQuery extends QueryObject {
    private String parentId;
}

修改分页查询方法

@Override
public Page<Destination> query(DestinationQuery qo) {
    Query query = new Query();
    //携带关键字查询
    if (StringUtils.hasLength(qo.getKeyword())){
        query.addCriteria(Criteria.where("name").regex(qo.getKeyword()));
    }
    //携带上级 id 查询
    if (StringUtils.hasLength(qo.getParentId())){
        query.addCriteria(Criteria.where("parentId").is(qo.getParentId()));
    }else {  //导航是 根 的情况
        query.addCriteria(Criteria.where("parentId").is(null));
    }
    //pageable
    Pageable pageable = PageRequest.of(qo.getCurrentPage() - 1, qo.getPageSize(), Sort.Direction.ASC, "id");
    return DBHelper.query(Destination.class,template,query,pageable,qo);
}
导航

要实现导航是多级导航,中国>>广东>>广州 。当点击广州,导航会出现 根、中国、广东、广州

有两种实现方式: 1、递归; 2、 使用 while(父id不为null)

下面是递归方式:

service实现类

@Override
public List<Destination> getToasts(String desId) {
    List<Destination> list = new ArrayList<>();
    createToast(list,desId);
    //list集合元素顺序调转
    Collections.reverse(list);
    return list;
}
//示例:中国 >> 广东 >> 广州
private void createToast(List<Destination> list, String parentId){
    //递归前提:必须能出递归的循环
    if (!StringUtils.hasLength(parentId)){  //当前id为空
        return;
    }
    Destination dest = this.get(parentId);
    list.add(dest);
    if (StringUtils.hasLength(dest.getParentId())){  //当前的父id不为空
        createToast(list,dest.getParentId());  // 执行父id的方法
    }
}

后端控制器方法

@RequestMapping("/list")
public String list(Model model, @ModelAttribute("qo") DestinationQuery qo){
    //page
    Page<Destination> page = destinationService.query(qo);
    model.addAttribute("page", page);
    //toasts
    model.addAttribute("toasts", destinationService.getToasts(qo.getParentId()));
    return "destination/list";
}

前端热门目的地

项目启动后, 访问 /views/destination/index.html 进入前端目的地展示页面

分析

  1. 鼠标移动到不同区域,显示该区域下挂载的目的地集合
  2. 同时将挂载目的地的所有子目的地集合进行列表显示

热门目的地一

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FF7Sl1z6-1651834690344)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220329112656884.png)]

因为现在写的是前端页面的数据,所以需要在 trip-website-api 模块 里面编写

var vue = new Vue({
    el:"#app",
    data:{
        regions:[],  //热门排序的区域集合
        destListLeft:[],
        destListRight:[],
        regionId:''
    },
    methods:{
		//.....
    },
    //数据初始化位置
    mounted:function () {
        //热门数据
        ajaxGet("/destinations/hotRegion",{}, function (data) {
            vue.regions = data.data;
        })

        //通过区域id查询目的地
        this.queryRegion(-1);
    }
});

后端控制器方法

@RestController
@RequestMapping("destinations")
public class DestinationController {

    @Autowired
    private IRegionService regionService;

    @GetMapping("/hotRegion")
    public Object hotRegion(){
        List<Region> list = regionService.queryhotRegion();
        return JsonResult.success(list);
    }
}

service实现类

@Override
public List<Region> queryhotRegion() {
    return regionRepository.findByIshotOrderBySequence(1);
}

Repository 接口

//区域持久化操作接口,类似 Mapper 接口
@Repository
public interface RegionRepository extends MongoRepository<Region, String> {
    /**
     * 查询热门区域并排序
     * @param hot
     * @return
     */
    List<Region> findByIshotOrderBySequence(int hot);
}

热门目的地二

实体类中定义属性,用于挂载目的地下的子目的地

/**
 * 目的地(行政地区:国家/省份/城市)
 */
@Setter
@Getter
@Document("destination")
public class Destination extends BaseDomain {
    
    //......

    //子地区  org.springframework.data.annotation.Transient
    @Transient  //MongoDB添加时忽略该字段,不需要添加到库中
    private List<Destination> children = new ArrayList<>();
}

后端控制器方法

@GetMapping("/search")
public Object search(String regionId){
    List<Destination> list = destinationService.queryByRegionIdForApi(regionId);
    return JsonResult.success(list);
}

service实现类

@Override
public List<Destination> queryByRegionIdForApi(String rid) {
    //区域挂载的目的地
    List<Destination> list = null;
    if ("-1".equals(rid)){
        //查询国内:查询中国所有省份
        list = repository.findByParentName("中国");
    }else {
        list = this.queryDestByRegionId(rid);
    }
    //目的地下的子目的地
    for (Destination des : list) {
        //要求只显示5个
        // 1、截取  children.subList(0,5) 包含0不包含5
        // 2、使用 JPA
        Pageable pageable = PageRequest.of(0,5);
        List<Destination> children = repository.findByParentId(des.getId(),pageable);
        des.setChildren(children); //是实体类定义的一个属性
    }
    return list;
}

Repository 接口

List<Destination> findByParentName(String parentName);
List<Destination> findByParentId(String parentId);

6、旅游攻略

攻略对象分析

一般表数据量比较少的时候才需要单独 序号 列来进行排序

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BwhaqVbf-1651834690345)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220330094829732.png)]

导入数据库数据:在文件所在目录下执行以下操作 mongo localhost/wolf2w luowowoDemo.js

攻略分类(strategy_catalog)表设计 :

属性名称属性类型属性说明
idString主键
nameString分类名称
destIdString目的地id
destNameString目的地名称
stateInt状态
sequenceInt排序

攻略主题(strategy_theme)表设计 :

属性名称属性类型属性说明
IdString主键
nameString攻略主题名字
stateInt状态
sequenceint排序

攻略明细(strategy_detail)表设计 :

属性名称属性类型属性说明
idString主键
destIdString目的地id
destNameString目的地名称
themeIdString攻略主题id
themeNameString攻略主题name
catalogIdString分类id
catalogNameString分类名称
titleString攻略标题
subTitleString攻略副标题
summaryString摘要,文章正文的前100个字
coverUrlString攻略封面
createtimeDate创建时间
isabroadboolean是否国外
viewnumInt阅读人数
replynumInt回复人数
favornumint收藏人数
sharenumInt分享人数
thumbsupnumint点赞人数
stateint状态 正常, 发布
contentString内容

CRUD准备

domain

QueryObject

repository

service

trip-mgrsite模块的controller

攻略明细的添加:

1、上传封面图片

前端 uploadifive

前端需要使用上传图片的插件 uploadifive ,输入框没有要求,但是需要有 js 代码,如下:

$(function () {


    //富文本框图片配置
    var ck = CKEDITOR.replace( 'content',{
        filebrowserBrowseUrl: '/图片服务器,假装这里有',
        filebrowserUploadUrl: '/uploadImg_ck'
    });

    //图片上传
    $('.imgBtn').uploadifive({
        'auto' : true,  //自动发起图片上传请求
        'uploadScript' : '/uploadImg',   //处理上传文件的请求路径
        buttonClass:"btn-link",
        'fileObjName' : 'pic',   //上传文件参数名
        'buttonText' : '浏览...',
        'fileType' : 'image',
        'multi' : false,
        'fileSizeLimit'   : 5242880,
        'removeCompleted' : true, //取消上传完成提示
        'uploadLimit' : 1,
        //'queueSizeLimit'  : 10,
        'overrideEvents': ['onDialogClose', 'onError'],    //onDialogClose 取消自带的错误提示
        'onUploadComplete' : function(file, data) {
            $("#imgUrl").attr("src" ,data);  //data约定是json格式 图片地址
            $("#coverUrl").val(data);

        },
        onFallback : function() {
            $.messager.alert("温馨提示","该浏览器无法使用!");
        }
    });
})

后端 阿里OSS

封面图片上传之后,保存在哪里?

因为现在是前后端分离,此封面前后端都要使用。可以将图片放在网上(文件共享服务器/图片共享服务器) ,让都可以进行访问

**操作原理:**将浏览器上传图片上传到一个公共服务空间,并将这个图片路径返回即可

公共服务空间:公司自建服务器、新浪图床、七牛图床、阿里对象存储(OSS)

  1. 进入阿里云官网,搜索阿里对象存储

  2. 创建 Bucket

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ms980AJU-1651834690346)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220329180046591.png)]

  3. 注意域名,即访问时文件路径

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3ZGsdW0K-1651834690347)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220329180251731.png)]

  4. 上传文件时如果有目录,访问文件时还需要在域名后加目录路径

java使用步骤:

  1. 导入依赖

    <!-- oss -->
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
        <version>3.5.0</version>
    </dependency>
    <!-- io流处理 -->
    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.6</version>
    </dependency>
    
  2. 拷贝工具类

    /**
     * 文件上传工具
     */
    public class UploadUtil {
       //阿里域名
       public static final String ALI_DOMAIN = "https://wolf2w42.oss-cn-shenzhen.aliyuncs.com/";
    
       public static String uploadAli(MultipartFile file) throws Exception {
          //生成文件名称
          String uuid = UUID.randomUUID().toString();
          String orgFileName = file.getOriginalFilename();//获取真实文件名称 xxx.jpg
          String ext= "." + FilenameUtils.getExtension(orgFileName);//获取拓展名字.jpg
          String fileName =uuid + ext;//xxxxxsfsasa.jpg
    
          // Endpoint以杭州为例,其它Region请按实际情况填写。
          String endpoint = "http://oss-cn-shenzhen.aliyuncs.com";
          // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,
          // 请登录 https://ram.console.aliyun.com 创建。
          String accessKeyId = "LTAI4FcxXbQVaDXncy2bgaGz";
          String accessKeySecret = "wAsBTLhiNobVkwgmVUSPjdDpdxhhUz";
          // 创建OSSClient实例。
          OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId,accessKeySecret);
          // 上传文件流。
          ossClient.putObject("wolf2w42", fileName, file.getInputStream());
          // 关闭OSSClient。
          ossClient.shutdown();
          return ALI_DOMAIN + fileName;
       }
    }
    
  3. 后端控制器方法使用

    @Controller
    public class UploadController {
        @Autowired
        private IStrategyService strategyService;
    
        @RequestMapping("/uploadImg")
        public String uploadImg(MultipartFile pic) throws Exception {
            //执行文件保存
            String path = UploadUtil.uploadAli(pic);
            return path;
        }
    }
    

2、分组下拉框

分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fl4m6SEo-1651834690347)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220330122238805.png)]

代码实现

定义 vo 类,用于封装需要的数据,因为没有对应的 domain 可以封装,就可以自定义 VO 类

/**
 * 分组下拉框的vo对象
 */
@Setter
@Getter
public class CatalogVO {
    private String destName;
    private List<StrategyCatalog> catalogList = new ArrayList<>();
}

操作哪种对象,就使用哪种服务。

所以将方法定义在 IStrategyCatalogService 接口里面

@Override
public List<CatalogVO> groupList() {
    List<CatalogVO> list = new ArrayList<>();
    /* 等价于如下:
    db.strategy_catalog.aggregate([{
        $group: {
            _id: "$destName",
            names: {
               $push: "$name"
            },
            ids: {
               $push: "$_id"
            }
        }
    }])
     */
    //聚合查询
    TypedAggregation<StrategyCatalog> agg = Aggregation.newAggregation(StrategyCatalog.class,
            Aggregation.group("destName").
                    push("name").as("names").
                    push("id").as("ids")
    );
    //执行聚合查询,指定返回的类型为 Map.class
    AggregationResults<Map> result = mongoTemplate.aggregate(agg,Map.class);
    List<Map> datas = result.getMappedResults(); //将结果转化成list<map>   map描述一行数据
    for (Map data : datas) {  //key为字段名,value为字段值
        CatalogVO vo = new CatalogVO();
        vo.setDestName(data.get("_id").toString());  //id
        List<Object> names = (List<Object>) data.get("names");  //攻略分类的名称集合
        List<Object> ids = (List<Object>) data.get("ids");  //攻略分类的ids集合
        List<StrategyCatalog> mapList = new ArrayList<>(); //攻略分类对象
        for(int i = 0;i < names.size(); i++){
            StrategyCatalog sc = new StrategyCatalog();
            String name = names.get(i).toString();
            String id = ids.get(i).toString();
            sc.setId(id);
            sc.setName(name);
            mapList.add(sc);
        }
        vo.setMapList(mapList);
        list.add(vo);
    }
    return list;
}

controller方法

//分类下拉框 catalogs
model.addAttribute("catalogs",catalogService.groupList());

3、富文本编辑 CKEditor

可以插入除了文本之外的编辑器 ,这里使用 CKEditor

该插件默认传递的参数名固定 upload ,且固定了需要的 返回数据格式

前端:

<textarea id="content" name="content" class="ckeditor">${(strategy.content)!}</textarea>
//富文本框图片配置
var ck = CKEDITOR.replace('content',{
    filebrowserBrowseUrl: '/图片服务器,假装这里有',
    filebrowserUploadUrl: '/uploadImg_ck'   //请求路径
});

后端控制器方法

@RequestMapping("/uploadImg_ck")
@ResponseBody
public Map<String, Object> upload(MultipartFile upload, String module){
    Map<String, Object> map = new HashMap<String, Object>();
    String imagePath= null;
    if(upload != null && upload.getSize() > 0){
        try {
            //图片保存, 返回路径
            imagePath =  UploadUtil.uploadAli(upload);
            //表示保存成功
            map.put("uploaded", 1);
            map.put("url",imagePath);
        }catch (Exception e){
            e.printStackTrace();
            map.put("uploaded", 0);
            Map<String, Object> mm = new HashMap<String, Object>();
            mm.put("message",e.getMessage() );
            map.put("error", mm);
        }
    }
    return map;
}

4、攻略明细的保存

添加

因为页面表单提交的数据,明显少于数据库集合中需要的数据。所以需要在执行保存操作的时候,将缺少的数据补充进去

点赞数等不需要补充,因为添加操作时,点赞数等会有默认值0

但是需要单独设置创建时间,因为编辑不需要创建时间

修改service实现类中的方法

//......

编辑

实现编辑回显

if (StringUtils.hasLength(id)){
    //编辑回显
    model.addAttribute("strategy",strategyService.get(id));
}

注意,因为编辑的时候,对象里面会有点赞数等数据,需要保持不变

因为 MongoDB 是全量更新

@Override
public void saveOrUpdate(Strategy strategy) {
    StrategyCatalog catalog = catalogService.get(strategy.getCatalogId());
    StrategyTheme theme = themeService.get(strategy.getThemeId());
    //目的地id
    strategy.setDestId(catalog.getDestId());
    //目的地名称
    strategy.setDestName(catalog.getDestName());
    //主题名
    strategy.setThemeName(theme.getName());
    //分类名
    strategy.setCatalogName(catalog.getName());
    //是否国外,目的地id,通过导航吐司获取到国家名
    List<Destination> toasts = destinationService.getToasts(catalog.getDestId());
    if (toasts != null && toasts.size()>0 ) {
        Destination dest = toasts.get(0);
        strategy.setIsabroad("中国".equals(dest.getName()) ? Strategy.ABROAD_NO : Strategy.ABROAD_YES);
    }

    if (StringUtils.hasLength(strategy.getId())){ //编辑
        //先查询
        Strategy straMDB = this.get(strategy.getId());
        //替换
        strategy.setCreateTime(straMDB.getCreateTime());
        strategy.setViewnum(straMDB.getViewnum());
        strategy.setReplynum(straMDB.getReplynum());
        strategy.setThumbsupnum(straMDB.getThumbsupnum());
        strategy.setSharenum(straMDB.getSharenum());
        strategy.setFavornum(straMDB.getFavornum());
        //更新
        this.update(strategy);
    }else {  // 添加
        //创建时间
        strategy.setCreateTime(new Date());
        this.save(strategy);
    }
}

实现 上架、下架 (修改状态)

@Override
public void changeState(String id, Integer state) {
    //全量更新
    /*Strategy strategy = this.get(id);
    strategy.setState(hot);
    this.update(region);*/

    //部分字段更新 :db.集合名.updateMany({_id: "123"},{$set: {state: 1}})
    Query query = new Query();
    query.addCriteria( Criteria.where("_id").is(id));
    Update update = new Update();
    update.set("state",state);
    mongoTemplate.updateMulti(query, update, Strategy.class);
}

前端目的地明细

项目启动,访问 /views/destination/detail.html?id=目的地id 进入明细页面

导航吐司

var param = getParams();
//吐司
ajaxGet("/destinations/toasts",{destId:param.id}, function (data) {
    var list = data.data;  //中国 广东  广州
    vue.dest = list.pop();  //数组的最后一个元素赋值给dest
    vue.toasts = list;
})
@GetMapping("/toasts")
public Object toasts(String destId){
    return JsonResult.success(destinationService.getToasts(destId));
}

攻略分类

此处分类的情况和前面 分类分组下拉框的 情况 一致

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L5FCE21h-1651834690348)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220330134021740.png)]

此处可以使用 List<StrategyCatalog> 接收参数,就不需要自定义 VO 类了

//目的下所有攻略分类
ajaxGet("/destinations/catalogs",{destId:param.id}, function (data) {
    vue.catalogs = data.data;
})

控制器方法

@GetMapping("/catalogs")
public Object catalogs(String destId){
    return JsonResult.success(catalogService.queryCatalogByDestId(destId));
}

service实现类

@Autowired
private IStrategyService strategyService;
@Override
public List<StrategyCatalog> queryCatalogByDestId(String destId) {
    //查找目的地下的攻略分类集合
    List<StrategyCatalog> catalogs = strategyCatalogRepository.findByDestId(destId);
    for (StrategyCatalog catalog : catalogs) {
        //遍历每个攻略分类,查询该分类下所有攻略集合
        List<Strategy> strategys = strategyService.queryByCatalogId(catalog.getId());
        catalog.setStrategies(strategys);
    }
    return catalogs;
}

repository接口

//攻略
@Repository
public interface StrategyRepository extends MongoRepository<Strategy, String> {
    /**
     * 通过分类id查找攻略集合
     * @param id
     * @return
     */
    List<Strategy> findByCatalogId(String id);
}
//攻略分类
@Repository
public interface StrategyCatalogRepository extends MongoRepository<StrategyCatalog, String> {
    /**
     * 通过目的地id查找分类集合
     * @param destId
     * @return
     */
    List<StrategyCatalog> findByDestId(String destId);

}

攻略明细

注意:左边是攻略的分类, 右边是分类下所有的攻略

右边上部分显示所有当前分类下的所有攻略标题,下部分显示第一篇攻略的内容

使用上面两个后端控制器方法:导航吐司、攻略分类

var param = getParams();
//吐司
ajaxGet("/destinations/toasts",{destId:param.destId}, function (data) {
    var list = data.data;
    vue.dest = list.pop();
    vue.toasts = list;
})
//概况
ajaxGet("/destinations/catalogs",{destId:param.destId}, function (data) {
    //[{攻略分类1}, {攻略分类2},{攻略分类3}]
    vue.catalogs = data.data;
    $.each(vue.catalogs, function(index, item){
        if(item.id == param.catalogId){
            vue.catalog = item;  //选中的攻略分类
            vue.strategy = item.strategies[0]
            //攻略分类下所有攻略文章第一篇, 需要在页面显示文章内容
        }
    })
})

目的地下攻略(点击量前3)

查询当前目的下所有的关联攻略, 显示点击量最高3篇(这里可以自定义,比如:回复数最高)

//点击量前3的攻略文章
ajaxGet("/destinations/strategies/viewnumTop3",{destId:param.id}, function (data) {
    vue.strategies = data.data;
})

后端控制器方法

@GetMapping("/strategies/viewnumTop3")
public Object strategiesViewnumTop3(String destId){
    return JsonResult.success(strategyService.queryViewnumTop3ByDestId(destId));
}

service实现类

@Override
public List<Strategy> queryViewnumTop3ByDestId(String destId) {
    Pageable pageable = PageRequest.of(0, 3, Sort.Direction.DESC, "viewnum");
    return strategyRepository.findByDestId(destId, pageable);
}

repository接口

接口中可以加入分页条件,其他操作交给 JPA

/**
 * 通过目的地id查找点击量前三的攻略
 * @param destId 目的地id
 * @param pageable 点击量前三的条件
 * @return
 */
List<Strategy> findByDestId(String destId, Pageable pageable);

查看详情

点击查看详情,进入攻略明细显示页面 views/strategy/detail.html?id=攻略id

//攻略明细
ajaxGet("/strategies/detail",{id:param.id}, function (data) {
    vue.strategy = data.data;
})
@RestController
@RequestMapping("strategies")
public class StrategyController {
    @Autowired
    private IStrategyService strategyService;

    @GetMapping("/detail")
    public Object detail(String id){
        return JsonResult.success(strategyService.get(id));
    }
}

查看全部

点击查看全部,进入攻略列表页面: /views/strategy/list.html?destId=目的地id

//攻略分页
ajaxGet("/strategies/query",{destId:param.destId}, function (data) {
    vue.page =data.data;
    //构建分页条
    buildPage(vue.page.number, vue.page.totalPages,vue.doPage);
})
doPage:function(page){
    var param = getParams();
    ajaxGet("/strategies/query",
            {destId:param.destId, currentPage:page}, function (data) {
        vue.page = data.data;
        buildPage(vue.page.number, vue.page.totalPages,vue.doPage);
    })
}

因为涉及到分页,所以接收参数一般都是用 QueryObject

@Setter
@Getter
public class StrategyQuery extends QueryObject {
    private String destId;
}

后端控制器方法

@GetMapping("/query")
public Object query(StrategyQuery qo){
    return JsonResult.success(strategyService.query(qo));
}

修改service方法

@Override
public Page<Strategy> query(StrategyQuery qo) {
    // 创建查询对象:理解为MQL语句拼接对象
    Query query = new Query();
    
    if (StringUtils.hasLength(qo.getDestId())){
        query.addCriteria(Criteria.where("destId").is(qo.getDestId()));
    }
    //分页参数对象pageable
    Pageable pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(),
                                       Sort.Direction.ASC, "id");
    //需要三个参数:List<T> content, Pageable pageable, long total
    return DBHelper.query(Strategy.class,mongoTemplate,query,pageable,qo);
}

前端攻略首页

与目的地下攻略列表一致, 唯一区别是少了目的地id这个查询条件

访问 /views/strategy/list.html 进入

罗列所有的主题:

//攻略主题
ajaxGet("/strategies/themes",{}, function (data) {
    vue.themes = data.data;
})
@GetMapping("/themes")
public Object themes(){
    return JsonResult.success(themeService.list());
}

点击单个主题,展示攻略

前端

//攻略分页列表
ajaxGet("/strategies/query",{}, function (data) {
    vue.page = data.data;
    //分页条
    buildPage(vue.page.number, vue.page.totalPages,vue.doPage);  //vue.doPage
})
doPage:function(page){
    var themeId = $("._j_tag.on").data("tid");
    ajaxGet("/strategies/query",{themeId:themeId, currentPage:page}, function (data) {
        vue.page = data.data;
        //vue.page.number = page;
        buildPage(vue.page.number, vue.page.totalPages,vue.doPage);
    })
}

后端修改 service方法

@Setter
@Getter
public class StrategyQuery extends QueryObject {
    private String destId;
    private String themeId;
}
if (StringUtils.hasLength(qo.getThemeId())){
    query.addCriteria(Criteria.where("themeId").is(qo.getThemeId()));
}

7、旅游日记

后端

表设计

游记 (travel) 表设计 :

属性名称属性类型属性说明
IdString主键
destidString目的地id
destNameString目的地名
userIdString作者id
titleString游记标题
coverUrlString游记封面
travelTimeDate旅游时间
perExpendString人均消费
dayInt出行天数
personint和谁旅游
createTimeDate创建时间
releaseTimeDate发布时间
lastUpdateTimeData最后更新时间
isPublicboolean是否公开
viewnumInt阅读人数
replynumInt回复人数
favernumint收藏人数
sharenumInt分享人数
thumbsupnumint点赞人数
stateInt状态
contentString内容

CRUD准备

domain

QueryObject

repository

service

trip-mgrsite 模块的 controller

游记审核

注意审核的步骤:

  1. 判断是否满足审核条件
  2. 审核通过之后做什么
  3. 审核拒绝之后做什么

页面:

//发布/拒绝
$(".updateStateBtn").click(function () {
    var id = $(this).data('id');
    var state = $(this).data('state');
    $.get('/travel/changeState',{id:id,state:state},function (data) {
        if(data.code == 200){
            window.location.reload();
        }else{
            $.messager.alert("温馨提示", "操作失败");
        }
    })
})

后端:

@RequestMapping("/changeState")
@ResponseBody
public Object changeState(String id, Integer state){
    travelService.changeState(id, state);
    return JsonResult.success();
}
@Override
public void changeState(String id, Integer state) {
    //判断是否满足审核条件
    Travel travel = this.get(id);
    if (travel == null) {
        throw new LogicException("参数异常");
    }
    if (state == Travel.STATE_RELEASE){ //审核通过之后
        //游记状态改为审核通过
        travel.setState(state);
        //游记发布时间为当前时间
        travel.setReleaseTime(new Date());
        //记录最后更新时间
        travel.setLastUpdateTime(new Date());
        //记录审核信息(审核人、审核备注、时间等等)
    }else if(state == Travel.STATE_REJECT){ //审核拒绝之后
        //游记状态改为审核拒绝
        travel.setState(state);
        //游记发布时间改为 null
        travel.setReleaseTime(null);
        //记录最后更新时间
        travel.setLastUpdateTime(new Date());
        //记录审核信息(审核人、审核备注、时间等等)
    }else{
        //下架游记
        //游记状态改为下架
        travel.setState(state);
        //游记发布时间改为 null
        travel.setReleaseTime(null);
        //记录最后更新时间
        travel.setLastUpdateTime(new Date());
        //记录审核信息(审核人、审核备注、时间等等)
        //考虑要不要清空点赞、评论等数据...
    }
    this.update(travel);  //这里更新不会丢失数据,因为是先查、再改、再更新的
}

游记查看

页面:

//查看文章
$(".lookBtn").click(function () {
    var id = $(this).data('id');
    //根据id查询游记内容
    $.get('/travel/getContentById',{id:id},function (data) {
        $("#inputModal .modal-body").html(data.data);
        $("#inputModal").modal('show');
    })
})

后端:

@RequestMapping("/getContentById")
@ResponseBody
public Object getContentById(String id){
    return JsonResult.success(travelService.get(id).getContent());
}

前端目的地明细下游记

分页显示

页面:

//游记分页
ajaxGet("/travels/query",{destId:param.id}, function (data) {
    vue.page = data.data;
    buildPage(vue.page.number, vue.page.totalPages, vue.doPage)
})

后端:

注意,游记分页需要显示作者信息,数据库有 userId ,而 domain 里是 userInfo 对象

所以需要在分页之前,给每个游记对象的作者属性进行赋值

@Setter
@Getter
public class TravelQuery extends QueryObject {
    private String destId;
}
@Override
public Page<Travel> query(TravelQuery qo) {
    // 创建查询对象:理解为MQL语句拼接对象
    Query query = new Query();
    
    if (StringUtils.hasLength(qo.getDestId())){
        query.addCriteria(Criteria.where("destId").is(qo.getDestId()));
    }
    
    Pageable pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(),
                                       Sort.Direction.ASC, "id");
    Page<Travel> page = DBHelper.query(Travel.class,mongoTemplate,query,pageable,qo);
    
    for (Travel travel : page) {
        travel.setAuthor(userInfoService.get(travel.getUserId()));
    }
    
    return page;
}
@GetMapping("/query")
public Object query(TravelQuery qo){
    return JsonResult.success(travelService.query(qo));
}

带范围条件查询

页面传递过来的是单个 value 值:

分析

将一个值转换为另外的值:映射思想(map)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IoEoEVcO-1651834690349)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220331103649238.png)]

代码

新增游记查询条件,并且需要在 静态代码块中,初始化查询条件

/**
 * 游记条件
 */
@Setter
@Getter
public class TravelCondition {
    public static final Map<Integer, TravelCondition> TRAVEL_DAYS; //旅游天数
    public static final Map<Integer, TravelCondition> TRAVEL_PRE_EXPENDS; //旅游人均消费
    static{
        //旅游天数
        TRAVEL_DAYS = new HashMap<>();
        TRAVEL_DAYS.put(-1, new TravelCondition(-1, Integer.MAX_VALUE));
        TRAVEL_DAYS.put(1, new TravelCondition(0, 3));
        TRAVEL_DAYS.put(2, new TravelCondition(4, 7));
        TRAVEL_DAYS.put(3, new TravelCondition(8, 14));
        TRAVEL_DAYS.put(4, new TravelCondition(15,Integer.MAX_VALUE));
        //人均消费
        TRAVEL_PRE_EXPENDS = new HashMap<>();
        TRAVEL_PRE_EXPENDS.put(-1, new TravelCondition(-1, Integer.MAX_VALUE));
        TRAVEL_PRE_EXPENDS.put(1, new TravelCondition(1, 999));
        TRAVEL_PRE_EXPENDS.put(2, new TravelCondition(1000, 6000));
        TRAVEL_PRE_EXPENDS.put(3, new TravelCondition(6001, 200000));
        TRAVEL_PRE_EXPENDS.put(4, new TravelCondition(200001,Integer.MAX_VALUE));
    }
    private int min;
    private int max;
    private TravelCondition(int min, int max){
        this.min = min;
        this.max = max;
    }
}

页面传递的参数,使用 QueryObject 接收,所以还需要修改游记的页面查询参数对象

/**
 * 游记查询对象
 */
@Setter
@Getter
public class TravelQuery extends QueryObject {
    public static final Integer ORDER_NEW = 1;
    public static final Integer ORDER_HOT = 2;
    
    private String destId;
    private  int state = -1; //游记状态
    private int orderType = -1; //排序类型
    private int dayType = -1;   //旅游天数类型
    private TravelCondition days;
    private int perExpendType = -1;  //人均消费类型
    private TravelCondition perExpends;

    //页面传递 dayType=2 时,TRAVEL_DAYS.get(2),得到旅游天数 4,7
    public TravelCondition getDays(){
        return TravelCondition.TRAVEL_DAYS.get(dayType);
    }
    public TravelCondition getPerExpends(){
        return TravelCondition.TRAVEL_PRE_EXPENDS.get(perExpendType);
    }
}

同时还要修改 query 方法

@Override
public Page<Travel> query(TravelQuery qo) {
    // 创建查询对象:理解为MQL语句拼接对象
    Query query = new Query();
    if (StringUtils.hasLength(qo.getDestId())){
        query.addCriteria(Criteria.where("destId").is(qo.getDestId()));
    }

    //查询天数 TravelCondition.TRAVEL_DAYS.get(qo.getDayType())
    TravelCondition days = qo.getDays();
    if (days != null) {
        query.addCriteria(Criteria.where("day")
                          .gte(days.getMin()).lte(days.getMax()));
    }

    //人均消费 TravelCondition.TRAVEL_PRE_EXPENDS.get(qo.getPerExpendType())
    TravelCondition per = qo.getPerExpends();
    if (per != null) {
        query.addCriteria(Criteria.where("perExpend")
                          .gte(per.getMin()).lte(per.getMax()));
    }

    //最新、最热排序:默认根据id升序
    Pageable pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(),
                                       Sort.Direction.ASC, "_id");
    if(qo.getOrderType() == TravelQuery.ORDER_HOT){
        pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(),
                                  Sort.Direction.DESC, "viewnum");
    }else if (qo.getOrderType() == TravelQuery.ORDER_NEW){
        pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(),
                                  Sort.Direction.DESC, "createTime");
    }

    Page<Travel> page = DBHelper.query(Travel.class,mongoTemplate,query,pageable,qo);
    //给查询出的每个游记对象的作者属性赋值
    for (Travel travel : page) {
        travel.setAuthor(userInfoService.get(travel.getUserId()));
    }
    return page;
}

前端游记首页

因为游记首页也是使用分页查询,且查询条件与上面一致,所以直接调用后端控制器方法,无需改动

前端游记首页添加游记

要求:

  1. 封面图片上传(这里简单起见没有使用插件, 可以大家可以自己实现)
  2. 富文本框, 这里选用的是umeditor, 迷你百度富文本编辑器

需要注意: 游记具有4种状态

草稿状态:用户添加游记完游记,在不点击发布操作时,游记为草稿状态(默认)

待发布状态:用户写完游记,点击发布操作时,游记为待发布状态,需要管理员审核

发布拒绝状态:如果游记不合法,管理员直接拒绝,此时游记为发布拒绝状态 注意:草稿状态下进行游记编辑,但不发布,游记依然为草稿状态, 如果游记已经发表,再进行编辑,此时游记回 到草稿状态或待发布状态。

已删除状态:用户自己手动删除的游记,记录状态为已删除状态

编辑添加成功后要同步修改elasticsearch中游记

上传封面

页面:此处没有使用插件

function uploadPic () {
    var url = domainUrl +"/coverImageUpload";
    if($("#coverBtn").val()){
        $("#coverForm").ajaxSubmit({
            url:url,
            type:"post",
            success:function (data) {
                $("#choseBtn").html(" + 选择封面");
                $("#coverImage").attr("src", data);
                $("#coverValue").val(data);
            }
        })
    }
}
<form  method="post" id="coverForm" enctype="multipart/form-data">
    <input type="file" name="pic" id="coverBtn" style="display: none;" onchange="uploadPic()">
</form>

后端:

@Controller
public class UploadController {
    @Autowired
    private IStrategyService strategyService;

    @RequestMapping("/coverImageUpload")
    @ResponseBody
    public String coverImageUpload(MultipartFile pic) throws Exception {
        //执行文件保存,使用阿里OSS
        //String path = UploadUtil.uploadAli(pic);
        return "https://img0.baidu.com/it/u=2659712525,3315272485&fm=253&fmt=auto&app=138&f=PNG?w=220&h=137";
    }
}

富文本编辑 UEditor

UEditor 这个插件比较旧,使用的是 JSP,所以插入图片的操作也是 基于 JSP ,需要将其转换为 SpringBoot 支持的方式

  1. 贴入工具类

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C6jfO8Zd-1651834690350)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220331121949402.png)]

  2. 贴入上传图片的方法

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mjFLC3iT-1651834690350)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220331122018713.png)]

目的地下拉框显示: 编辑页面需要选择目的地,所以需要在下拉框回显目的地数据

//查询目的地
ajaxGet("/destinations/list",{},function (data) {
    vue.dests = data.data;
})
<select name="destId" data-placeholder="请选择目的地"  id="region" style="width: 150px;">
    <option  v-for="d in dests"  :value="d.id" :selected="tv.destId == d.id">
        {{d.name}}</option>
</select>
@GetMapping("/list")
public Object list(){
    return JsonResult.success(destinationService.list());
}

编辑回显:

//数据回显
ajaxGet("/travels/detail",{id:id},function (data) {
    var travel = data.data;
    if(travel){ //travel,当id有值时需要回显
        vue.tv = travel;
        ue.setContent( travel.content);
    }
})
//编辑回显
@GetMapping("/detail")
public Object detail(String id){
    if (StringUtils.hasLength(id)){
        Travel travel = travelService.get(id);
        return JsonResult.success(travel);
    }
    return JsonResult.noLogin();
}

保存

对于 MongoDB 保存更新的时候,需要注意,前端页面填入的数据和数据库的列数是否能对等,因为 MongoDB 是全量更新。稍有不慎就会丢失数据,或者插入数据为空

注意,因为保存游记需要保存用户id,而用户id获取的来源就是当前登录用户

要获取到当前登录用户,就必须要先登录,所以此功能是必须登录之后才可以运行的,就需要使用注解

后端:

//添加/编辑 保存
@RequireLogin
@PostMapping("/saveOrUpdate")
public Object saveOrUpdate(Travel travel, HttpServletRequest request){
    String token = request.getHeader("token");
    UserInfo user = userInfoRedisService.getUserByToken(token);
    travel.setUserId(user.getId());
    travelService.saveOrUpdate(travel);
    return JsonResult.success(travel.getId());
}
@Override
public void saveOrUpdate(Travel travel) {
    //目的地名称
    travel.setDestName(destinationService.get(travel.getDestId()).getName());
    //用户id,需要获取当前登录用户的id
    //创建时间
    travel.setCreateTime(new Date());
    //最后更新时间
    travel.setLastUpdateTime(new Date());

    if (StringUtils.hasLength(travel.getId())){ //编辑
        this.update(travel);
    }else {  //注意save方法里travel.setId(null),将id设置null,否则无法将自动生成的id注入进去
        this.save(travel);
    }
}
@Override
public void save(Travel travel) {
    travel.setId(null);  //如果id为"",spring-data-mongodb 不会将自动生成的id注入
    repository.save(travel);
}

页面:

saveOrUpdate:function (state) {
    $("#state").val(state);
    var param = $("#editForm").serialize() ;
    ajaxPost("/travels/saveOrUpdate", param, function (data) {
        //还缺一参数:目的地id
        var destId = $("#region").val();
        window.location.href = "/views/travel/detail.html?id=" + data.data + "&destId=" + destId;
    })
}

前端游记明细

从游记列表,点击某个游记进入游记明细页面 “/views/travel/detail.html?id=游记id”

展示游记

//游记
ajaxGet("/travels/detail",{id:param.id}, function (data) {
    vue.detail = data.data;
})
@GetMapping("/detail")
public Object detail(String id){
    if (StringUtils.hasLength(id)){
        return JsonResult.success(travelService.get(id));
    }
    return null;
}

此时运行之后,页面报错:

报这个错误是因为页面要显示用户信息,但是在 travel 对象属性 user 并没有数据,因为数据库只有 userId 字段

所以需要手动赋值一下

@Override
public Travel get(String id) {
    Optional<Travel> opt = repository.findById(id);
    if (opt.isPresent()){
        Travel travel = opt.get();
        travel.setAuthor(userInfoService.get(travel.getUserId()));
        //dest属性也是需要赋值
        travel.setDest(destinationService.get(travel.getDestId()));
        return travel;
    }
    return null;
}

关联目的地

查询当前篇游记的目的地, 关联查询目的地的父目的地(导航吐司), 显示当前目的地的封面与名称

吐司:

//吐司
ajaxGet("/destinations/toasts",{destId:param.destId}, function (data) {
    vue.toasts =data.data
})
@GetMapping("/toasts")
public Object toasts(String destId){
    return JsonResult.success(destinationService.getToasts(destId));
}

显示攻略前3

根据当前篇游记的目的地查询该目的地下阅读量为前3的攻略,循环播放

//点击量前3的攻略文章
ajaxGet("/destinations/strategies/viewnumTop3",{destId:param.destId} function(data) {
    vue.strategies = data.data;
})
@GetMapping("/strategies/viewnumTop3")
public Object strategiesViewnumTop3(String destId){
    return JsonResult.success(strategyService.queryViewnumTop3ByDestId(destId));
}

显示游记前3

根据当前篇游记的目的地查询该目的地下阅读量为前3的游记,循环播放

//点击量前3的游记文章
ajaxGet("/destinations/travels/viewnumTop3",{destId:param.destId}, function (data) {
    vue.travels = data.data;
})

后端:

@GetMapping("/travels/viewnumTop3")
public Object travelsViewnumTop3(String destId){
    return JsonResult.success(travelService.queryViewnumTop3ByDestId(destId));
}
@Override
public List<Travel> queryViewnumTop3ByDestId(String destId) {
    Pageable pageable = PageRequest.of(0,3, Sort.Direction.DESC, "destId");
    return repository.findByDestId(destId, pageable);
}
/**
 * 通过目的地id查找点击量前三的游记
 * @param destId
 * @param pageable
 * @return
 */
List<Travel> findByDestId(String destId, Pageable pageable);

用户对象注入

存在问题:

后端获取当前登录用户信息,都需要进行以下操作:属于重复

String token = request.getHeader("token");
UserInfo user = userInfoRedisService.getUserByToken(token);

需求:使用简化的方式获取当前登录用户信息

直接在请求映射方法中声明 UserInfo 这个类型的形参,SpringMVC 就自动将当前登录用户对象注入

例如:

@GetMapping("/info")
public Object info(UserInfo userInfo){
    return JsonResult.success(userInfo);
}

怎么做到的? 自定义SpringMVC参数解析器

SpringMVC 所有映射方法的形参的值的注入都是靠 SpringMVC 自带的参数解析器实现的

现在想要实现将登录的用户自动注入,单靠 SpringMVC 是不行的,还需要自定义参数解析器

操作原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kliNYKKz-1651834690351)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402210911408.png)]

自定义参数解析器

自定义参数解析器:

/**
 * 用户参数解析器,
 *  将请求映射方法中的 UserInfo 类型的形参解析成当前登录用户对象,并注入
 */
public class UserInfoArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private IUserInfoRedisService userInfoRedisService;

    // 指定当前解析器能解析的形参类型
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType() == UserInfo.class;
    }

    /**
     *  当上面方法 supportsParameter() 返回true时,才执行此方法
     *  作用:获取当前登录用户信息,并注入 UserInfo 的形参中
     */
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                 WebDataBinderFactory binderFactory)throws Exception{
        //获取request对象
        HttpServletRequest request
            = webRequest.getNativeRequest(HttpServletRequest.class);
        String token = request.getHeader("token");
        UserInfo user = userInfoRedisService.getUserByToken(token);
        return user;
    }
}

启动类配置此解析器

@SpringBootApplication
public class WebSite implements WebMvcConfigurer{
    //用户参数解析器
    @Bean
    public UserInfoArgumentResolver userInfoArgumentResolver(){
        return new UserInfoArgumentResolver();
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userInfoArgumentResolver());
    }
    
    //......
}

自定义注解区分使用

现在还有一个问题,就是当用户更改信息之后,怎么实现用户的更新操作,因为更新需要传入旧的用户,然后更新之后再保存新的

但是现在形参部分,被解析器注入的都是当前用户

使用注解解决

/**
 *  定义 userInfo 参数注入注解
 *  只有使用了这个注解的参数,自定义参数解析器才执行解析逻辑
 */
@Target({ElementType.PARAMETER})  //是用在参数位置
@Retention(RetentionPolicy.RUNTIME)
public @interface UserParam {
}

修改自定义参数解析器使用的条件

public class UserInfoArgumentResolver implements HandlerMethodArgumentResolver {

    // 指定当前解析器能解析的形参类型
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return 
            parameter.getParameterType() == UserInfo.class 
            &&
            parameter.hasParameterAnnotation(UserParam.class);
    }
    
    //......
}

使用:

@GetMapping("/info")
public Object info(@UserParam UserInfo userInfo){  //自动注入当前登录用户
    return JsonResult.success(userInfo);
}
/*
    如果要进行用户编辑,映射方法接收参数也是 UserInfo ,怎么区分
    定义一个参数级别的注解,当参数贴有这个注解,自定义参数解析器才进行解析逻辑
 */
@GetMapping("/updateInfo")
public Object updateInfo(UserInfo userInfo){  //需要在请求的时候传入参数,不是当前登录用户
    return JsonResult.success(userInfo);
}

8、内容评论

评论分为 盖楼式微信评论式

分析

对于评论,需要记录用户信息,以及点赞信息

对于点赞的用户,能看到点赞的特效,以及增加后的点赞数

换成其他用户之后,没有点赞的特效,但是可以看到增加后的点赞数

如何区分不同用户有不同的点赞特效?将点了赞的用户与评论绑定。点过赞的用户会留下印记给此评论

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FnsIPOBq-1651834690351)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220401102336173.png)]

前端攻略评论

CRUD准备

对于有关于用户的信息,没有使用 UserInfo 对象进行封装起来

因为使用对象封装的,用户更新的时候,因为对象是联合查询查出来的,更新较快

使用以下这种更新可能较慢,因为评论中主要关注点不在于用户头像的等信息

/**
 * 攻略评论
 */
@Setter
@Getter
@Document("strategy_comment")
@ToString
public class StrategyComment extends BaseDomain {
    private String strategyId;  //攻略(明细)id
    private String strategyTitle; //攻略标题
    private String userId;    //用户id
    private String nickname;  //用户名
    private String city;
    private int level;
    private String headImgUrl;     //头像
    private Date createTime;    //创建时间
    private String content;      //评论内容
    private int thumbupnum;     //点赞数
    private List<String> thumbuplist = new ArrayList<>();
}

QueryObject

repository

service

/**
 * 攻略评论服务接口
 */
public interface StrategyCommentService {
    //单个查询
    StrategyComment get(String id);
    //添加
    void save(StrategyComment strategyComment);
    //分页查询
    Page<StrategyComment> query(StrategyCommentQuery qo);
}

trip-website-api 模块的 controller

添加评论

页面:

addComment:function(){ //添加评论
    var param = {}
    param.strategyId = vue.strategy.id;
    param.strategyTitle = vue.strategy.title;

    var content = $("#content").val();
    if(!content){
        popup("评论内容必填");
        return;
    }
    param.content = content;
    $("#content").val('');

    ajaxPost("/strategies/addComment",param, function (data) {
        vue.queryStatisVo(param.strategyId);
        vue.commentPage(1,param.strategyId);
    })
}

因为前端只传递了攻略、评论内容的信息,所以需要获取当前登录用户信息

所以需要登陆了之后才可以进行此操作,就需要 @RequireLogin 注解

@RequireLogin
@PostMapping("/addComment")
public Object addComment(StrategyComment comment, @UserParam UserInfo user){
    //设置用户信息
    /*
        bean属性赋值  org.springframework.beans.BeanUtils
        前提:属性名一致时可以进行赋值
        注意:comment里有id属性,user里也有id属性,也会进行赋值,所以save方法里面要setId(null)
        操作原理:JavaBean的内省机制
     */
    BeanUtils.copyProperties(user, comment);
    comment.setUserId(user.getId()); //属性名不一致的还需要手动赋值

    strategyCommentService.save(comment);
    return JsonResult.success();
}

注意再添加评论时,需要添加创建时间

@Override
public void save(StrategyComment strategyComment) {
    strategyComment.setId(null);  //如果id为"",spring-data-mongodb 不会将自动生成的id注入
    strategyComment.setCreateTime(new Date());
    repository.save(strategyComment);
}

显示评论

页面:

commentPage:function (page,strategyId) {//分页
    strategyId = strategyId || vue.strategy.id;
    ajaxGet("/strategies/comments", {currentPage:page, strategyId:strategyId}, function(data){
        vue.page = data.data;
        buildPage(vue.page.number, vue.page.totalPages,vue.commentPage);
    })
}

后端:

因为是分页操作,所以使用 QueryObject 类对象作为参数接收

@Setter
@Getter
public class StrategyCommentQuery extends QueryObject {
    private String strategyId;
}
@Override
public Page<StrategyComment> query(StrategyCommentQuery qo) {
    Query query = new Query();
    if (StringUtils.hasLength(qo.getStrategyId())){
        query.addCriteria(Criteria.where("strategyId").is(qo.getStrategyId()));
    }
    Pageable pageable = PageRequest.of(qo.getCurrentPage()-1, qo.getPageSize(), Sort.Direction.DESC, "createTime");
    return DBHelper.query(StrategyComment.class, mongoTemplate, query, pageable, qo);
}
@GetMapping("/comments")
public Object comments(StrategyCommentQuery qo){
    return JsonResult.success(strategyCommentService.query(qo));
}

点赞操作

页面:

页面实现点赞变红,取消点赞变白

<span class="_j_comment_like_num">{{c.thumbupnum}}</span>&nbsp;
<a href="javascript:;" class="btn-comment-like _j_like_comment_btn "
   :class="c.thumbuplist.indexOf(user== undefined?-1:user.id) == -1? 'like':'liked'"
   @click="commentThumb(c.id)">
</a>
commentThumb:function(commentId){
    var page = $("#pagination").find("a.active").html()||1;
    ajaxPost("/strategies/commentThumb",{cid:commentId,sid:getParams().id}, function (data) {
        vue.commentPage(page,getParams().id);  //getParams()  获取url上的请求参数
    })
}

代码:

//评论点赞
@RequireLogin
@GetMapping("/commentThumb")
public Object commentThumb(String cid, @UserParam UserInfo user){
    strategyCommentService.commentThumb(cid, user.getId());
    return JsonResult.success();
}
@Override
public void commentThumb(String cid, String uid) {
    //获取点赞 id 集合
    StrategyComment comment = this.get(cid);
    List<String> userIdList = comment.getThumbuplist();
    //判断传入的用户id是否在集合中
    if (userIdList.contains(uid)){
        //如果在,点赞数 -1 移出
        comment.setThumbupnum(comment.getThumbupnum()-1);
        userIdList.remove(uid);
    }else {
        //如果不在,点赞数 +1 添加
        comment.setThumbupnum(comment.getThumbupnum()+1);
        userIdList.add(uid);
    }
    /*
        注意:这里不需要在将集合注入comment对象
        因为本身这个集合指向的就是comment对象里面的属性,地址值一致
        所以修改的其实就是此对象的属性
        comment.setThumbuplist(userIdList);
     */

    //更新操作
    repository.save(comment);
}

分析

需要实现 评论的回复 ,会引用被回复评论的信息及内容(没有点赞需求)

所以 评论对象属性 里面要包含 被回复评论的对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Lti0oYu-1651834690352)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220401220407723.png)]

前端游记评论

CRUD准备

domain

/**
 * 游记评论
 */
@Setter
@Getter
@Document("travel_comment")
public class TravelComment extends BaseDomain {
    public static final int TRAVLE_COMMENT_TYPE_COMMENT = 0; //普通评论
    public static final int TRAVLE_COMMENT_TYPE = 1; //评论的评论
    private String travelId;  //游记id
    private String travelTitle; //游记标题
    private String userId;    //用户id
    private String nickname; //用户名
    private String city;
    private int level;
    private String headImgUrl;   // 用户头像
    private int type = TRAVLE_COMMENT_TYPE_COMMENT; //评论类别
    private Date createTime; //创建时间
    private String content;  //评论内容
    private TravelComment refComment;  //关联的评论
}

QueryObject

repository

service

trip-website-api 模块的 controller

添加评论

评论必须要先登录

页面:

commentAdd:function (e) {
    var content = $("#commentContent").val();
    if(!content){ popup("评论不能为空"); return; }
    var param = {};
    param.travelId = vue.detail.id;
    param.travelTitle = vue.detail.title;
    param.content = emoji(content);
    param.type =$("#commentTpye").val();
    if(param.type == 1){
        param["refComment.id"] = $("#refCommentId").val();
    }else{
        param["refComment.id"] = "";
    }
    $("#commentTpye").val(0);
    $("#refCommentId").val("");
    ajaxPost("/travels/commentAdd",param, function (data) {
        $("#commentContent").val("");
        $("#commentContent").attr("placeholder","");
        vue.queryComments( param.travelId);
    })
}

后端:

@RequireLogin
@PostMapping("/commentAdd")
public Object commentAdd(TravelComment comment, @UserParam UserInfo user){
    //需要注入当前登录用户信息
    BeanUtils.copyProperties(user, comment);
    comment.setUserId(user.getId());
    //保存
    travelCommentService.save(comment);
    return JsonResult.success();
}

注意再添加评论时,需要添加创建时间、需要维护关联的评论

隐藏输入框值默认为 0,前端判断:0 表示没有回复,就不会注入关联评论的 id

点击回复的时候,会设置隐藏输入框值为 1,前端会判断:1 表示回复,就会将关联的评论 id 注入

@Override
public void save(TravelComment travelComment) {
    travelComment.setId(null);  //如果id为"",spring-data-mongodb 不会将自动生成的id注入
    //维护关联的评论
    String refId = travelComment.getRefComment().getId();
    if (StringUtils.hasLength(refId)) {
        //第二层评论
        TravelComment refComment = this.get(refId);
        travelComment.setRefComment(refComment);
        travelComment.setType(TravelComment.TRAVLE_COMMENT_TYPE); //评论的评论
    }else {
        travelComment.setType(TravelComment.TRAVLE_COMMENT_TYPE_COMMENT); //普通评论
    }
    //设置创建时间
    travelComment.setCreateTime(new Date());
    repository.save(travelComment);
}

显示评论

注意,此处的评论不分页

queryComments:function (travelId) {
    //游记评论不分页
    ajaxGet("/travels/comments",{travelId:travelId}, function (data) {
        vue.comments = data.data;
    })
}

后端:

@GetMapping("/comments")
public Object comments(String travelId){
    return JsonResult.success(travelCommentService.queryByTravelId(travelId));
}
@Override
public List<TravelComment> queryByTravelId(String travelId) {
    return repository.findByTravelId(travelId);
}

评论表情

将指定中文文字转换为表情图片

//将 (中文)   格式数据替换成标签图片:
function emoji(str) {
    //表情图片资源, 从马蜂窝扣出来的, 可以百度:
    //1:HttpURLConnection 使用  jdk自带的 代码中如何发起http请求
    //   RestTemplate  项目发短信用到
    //2:HttpClient  第三方http请求发送的框架
    //RestTemplate(client)
    //3:webmagic  专门用于爬虫框架

    //例如:传入字符 str=现金付款山东省看得(大笑小蜂)见风科技适(大笑小蜂)得府君书(得意小蜂)的开发决胜巅峰
    //匹配中文
    var reg = /\([\u4e00-\u9fa5A-Za-z]*\)/g;
    var matchArr = str.match(reg);  //此处得到所有匹配字符[(大笑小蜂), (大笑小蜂),(得意小蜂)]
    
    if(!matchArr){
        return str;
    }
    for(var i = 0; i < matchArr.length; i++){  //遍历匹配的字符集合,将字符替换为图片表情
        str = str.replace(matchArr[i],
                          '<img src="'+EMOJI_MAP[matchArr[i]]
                          +'"style="width: width:28px;"/>')
    }
    return str;
}

EMOJI_MAP 多个键值对,可以通过指定字符,找到指定表情图片

var EMOJI_MAP = {
    "(愤怒小蜂)": "/images/emoji/brands_v3/38@2x.png",
    
    //......
}

回复评论操作

toComment:function(nickname, refId){
    $("#commentTpye").val(1);
    $("#refCommentId").val(refId);
    $("#commentContent").focus();
    $("#commentContent").attr("placeholder","回复:" + nickname );
}

最后调用的还是评论的 controller 方法

9、数据统计

分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fu7mIVaR-1651834690353)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220401220619214.png)]

攻略的数据统计

阅读数分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QBRj7NRL-1651834690354)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220401220639965.png)]

阅读数+1

先创建统计的 vo 对象

因为和 redis 相关,所以放在 redis 的包内

/**
 * 攻略redis中统计数据
 * 运用模块:
 *  1:数据统计(回复,点赞,收藏,分享,查看)
 */
@Getter
@Setter
public class StrategyStatisVO implements Serializable {
    private String strategyId;  //攻略id

    private int viewnum;  //点击数
    private int replynum;  //攻略评论数
    private int favornum; //收藏数
    private int sharenum; //分享数
    private int thumbsupnum; //点赞个数
}

因为从页面加载了攻略之后就会 阅读数 +1,所以是在访问攻略的接口里面进行操作

@Autowired
private IStrategyStatisVORedisService strategyStatisVORedisService;

@GetMapping("/detail")
public Object detail(String id){
    Strategy strategy = strategyService.get(id);
    //阅读数 +1
    strategyStatisVORedisService.viewnumIncrease(id , 1);
    return JsonResult.success(strategy);
}

service

@Override
public void viewnumIncrease(String id, int num) {
    //拼接vo对象的key
    String key = RedisKeys.STRATEGY_STATIS_VO.join(id);
    //判断key是否存在
    StrategyStatisVO vo = null;
    if (!template.hasKey(key)){
        //如果不存在,初始化 vo —— 需要从数据库中查询数据
        vo = new StrategyStatisVO();
        Strategy strategy = strategyService.get(id);
        BeanUtils.copyProperties(strategy, vo);
        vo.setStrategyId(strategy.getId());
    }else {
        //如果存在,获取vo对象,注意返回的是JSON格式数据
        String voStr = template.opsForValue().get(key);
        vo = JSON.parseObject(voStr, StrategyStatisVO.class);
    }
    //更新 vo,直接 +num
    vo.setViewnum(vo.getViewnum() + num);
    template.opsForValue().set(key, JSON.toJSONString(vo));
}

因为使用 Redis,需要生成 key

/**
 *  redis key管理
 *  约定:一个枚举实例就是一个 key
 */
@Getter
public enum  RedisKeys{
    //攻略统计对象:-1L 表示不需要指定过期时间
    STRATEGY_STATIS_VO("strategy_statis_vo", -1L),
    //短信验证码
    VERIFY_CODE("verify_code", Consts.VERIFY_CODE_VAI_TIME * 60L),
    //登录token
    LOGIN_TOKEN("user_login_token", Consts.USER_INFO_TOKEN_VAI_TIME * 60L);

    private String prefix;  //redis的key的前缀
    private Long time;  //redis的key的有效时间,-1L 表示不需要指定过期时间,单位 秒
    private RedisKeys(String prefix, Long time){
        this.prefix = prefix;
        this.time = time;
    }

    //拼接完整的redis的 key
    public String join(String ...keys){
        StringBuilder sb = new StringBuilder();
        sb.append(prefix);
        for (String key : keys) {
            sb.append(":").append(key);
        }
        return sb.toString();
    }
}

页面显示统计数

页面:

queryStatisVo:function (sid) {
    //统计数据
    ajaxGet("/strategies/statisVo",{sid:sid}, function (data) {
        vue.vo =data.data;
    })
}

注意,因为异步请求,在初始化页面数据的时候,不一定会根据代码顺序来初始化页面数据

为了保证查询统计数据的时候,不会出现空指针异常,所以建议将统计数据的函数执行放在,攻略明细函数里面

如下

var _this = this;
//攻略明细
ajaxGet("/strategies/detail",{id:param.id}, function (data) {
    vue.strategy = data.data;
    //统计数据
    _this.queryStatisVo(param.id);
})

后端

@GetMapping("/statisVo")
public Object statisVo(String sid){
    return JsonResult.success(strategyStatisVORedisService.getStrategyStatisVo(sid));
}
@Override
public StrategyStatisVO getStrategyStatisVo(String sid) {
    //拼接vo对象的key
    String key = RedisKeys.STRATEGY_STATIS_VO.join(sid);
    String voStr = template.opsForValue().get(key);
    StrategyStatisVO vo = JSON.parseObject(voStr, StrategyStatisVO.class);
    return vo;
}

数据封装优化一下:

@Override
public void viewnumIncrease(String id, int num) {
    StrategyStatisVO vo = this.getStrategyStatisVo(id);
    vo.setViewnum(vo.getViewnum() + num);
    this.setStrategyStatisVo(vo);
}

@Override
public StrategyStatisVO getStrategyStatisVo(String strategyId) {
    //拼接vo对象的key
    String key = RedisKeys.STRATEGY_STATIS_VO.join(strategyId);
    //判断key是否存在
    StrategyStatisVO vo = null;
    if (!template.hasKey(key)){
        //如果不存在,初始化 vo —— 需要从数据库中查询数据
        vo = new StrategyStatisVO();
        Strategy strategy = strategyService.get(strategyId);
        BeanUtils.copyProperties(strategy, vo);
        vo.setStrategyId(strategy.getId());
        template.opsForValue().set(key, JSON.toJSONString(vo));
    }else {
        //如果存在,获取vo对象,注意返回的是JSON格式数据
        String voStr = template.opsForValue().get(key);
        vo = JSON.parseObject(voStr, StrategyStatisVO.class);
    }
    return vo;
}

@Override
public void setStrategyStatisVo(StrategyStatisVO vo) {
    String key = RedisKeys.STRATEGY_STATIS_VO.join(vo.getStrategyId());
    template.opsForValue().set(key, JSON.toJSONString(vo));
}

评论数分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8tJE74Py-1651834690354)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402151016924.png)]

评论数+1

@Override
public void replynumIncrease(String strategyId, int num) {
    StrategyStatisVO vo = this.getStrategyStatisVo(strategyId);
    vo.setReplynum(vo.getReplynum() + num);
    this.setStrategyStatisVo(vo);
}
@RequireLogin
@PostMapping("/addComment")
public Object addComment(StrategyComment comment, @UserParam UserInfo user){
    //设置用户信息
    /*
        bean属性赋值  org.springframework.beans.BeanUtils
        前提:属性名一致时可以进行赋值
        注意:comment里面有id属性,user里面也有id属性,也会进行赋值,所以save方法里面要setId(null)
        操作原理:JavaBean的内省机制
     */
    BeanUtils.copyProperties(user, comment);
    comment.setUserId(user.getId()); //属性名不一致的还需要手动赋值

    //评论数+1
    strategyStatisVORedisService.replynumIncrease(comment.getStrategyId(), 1);

    strategyCommentService.save(comment);
    return JsonResult.success();
}

收藏分析

因为需要显示用户收藏的内容,所以建议在用户的角度,实现记号缓存

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YbGtofp7-1651834690355)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402151222519.png)]

收藏数+1

页面:

favor:function(){
    ajaxPost("/strategies/favor",{sid:vue.strategy.id}, function (data) {
        if(data.data){
            popup("收藏成功");
        }else{
            popup("已取消收藏");
        }
        vue.queryStatisVo(vue.strategy.id);  //显示统计数据
        if(user){   //显示用户收藏攻略id集合
            vue.queryUserFavor(vue.strategy.id,user.id);
        }
    })
}

收藏接口:

//攻略收藏
@RequireLogin
@PostMapping("/favor")
public Object favor(String sid, @UserParam UserInfo user){
    //攻略收藏 : ret true 收藏成功, false表示取消收藏
    boolean ret = strategyStatisVORedisService.favor(sid, user.getId());
    return JsonResult.success(ret);
}

设计key

//用户攻略收藏
USER_STRATEGY_FAVOR("user_strategy_favor", -1L)
@Override
public boolean favor(String strategyId, String userId) {
    //获取记号(攻略id集合)
    String signkey = RedisKeys.USER_STRATEGY_FAVOR.join(userId);
    //记号不存在:初始化
    List<String> sidList = new ArrayList<>();
    if (template.hasKey(signkey)){
        //记号存在:直接获取
        String sidListStr = template.opsForValue().get(signkey);
        sidList = JSON.parseArray(sidListStr, String.class);
    }
    //通过记号判断当前操作是收藏还是取消收藏
    StrategyStatisVO vo = this.getStrategyStatisVo(strategyId);
    if (sidList.contains(strategyId)){
        //取消收藏:获取vo,收藏数-1,将strategyId移出记号集合中
        vo.setFavornum(vo.getFavornum() - 1);
        sidList.remove(strategyId);
    }else {
        //收藏:获取vo,收藏数+1,将strategyId加入记号集合中
        vo.setFavornum(vo.getFavornum() + 1);
        sidList.add(strategyId);
    }
    //更新记号、更新vo对象
    template.opsForValue().set(signkey, JSON.toJSONString(sidList));
    this.setStrategyStatisVo(vo);
    return sidList.contains(strategyId);
}

收藏按钮变色

页面:

<a href="javascript:void(0);" title="收藏" class="bs_btn btn-collect" @click="favor">
    
    <!--判断有无收藏的攻略id-->
    <i class="collect_icon i02 "    
       :class="sids.indexOf(strategy.id) == -1?'':'on-i02'"></i>
    
    <em class="favorite_num ">{{vo.favornum}}</em>
</a>
queryUserFavor:function (sid,userId) {
    ajaxGet("/users/strategies/favor",{sid:sid, userId:userId}, function (data) {
        vue.sids = data.data;
    })
}

接口:

//查询某个用户收藏的攻略id集合
@GetMapping("/strategies/favor")
public Object strategiesFavor(String userId){
    return JsonResult.success(
        strategyStatisVORedisService.getUsersStrategyFavor(userId));
}
@Override
public List<String> getUsersStrategyFavor(String userId) {
    //获取记号(攻略id集合)
    String signkey = RedisKeys.USER_STRATEGY_FAVOR.join(userId);
    //记号不存在:初始化
    List<String> sidList = new ArrayList<>();
    if (template.hasKey(signkey)){
        //记号存在:直接获取
        String sidListStr = template.opsForValue().get(signkey);
        sidList = JSON.parseArray(sidListStr, String.class);
    }
    return sidList;
}

点赞数分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mdgJxRGz-1651834690355)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402170007638.png)]

点赞数+1

页面:

strategyThumbup:function(){
    ajaxPost("/strategies/strategyThumbup",{sid:vue.strategy.id}, function (data) {
        if(data.data){
            popup("顶成功啦");
        }else{
            popup("今天你已经定过了");
        }
        vue.queryStatisVo(vue.strategy.id);
    })
}

设计key: Ctrl+Shift+X 切换大小写

//用户攻略顶
USER_STRATEGY_THUMB("user_strategy_thumb", -1L)

接口:

//攻略点赞
@RequireLogin
@PostMapping("/strategyThumbup")
public Object strategyThumbup(String sid, @UserParam UserInfo userInfo){
    //攻略点赞 : ret true 点赞成功, false表示今天已经点过
    boolean ret = strategyStatisVORedisService.strategyThumbup(sid, userInfo.getId());
    return JsonResult.success(ret);
}

需要获取时间间隔,引入工具类

public class DateUtil {
    /**
     * 获取两个时间的间隔(秒)
     * @param d1
     * @param d2
     * @return
     */
    public static long getDateBetween(Date d1, Date d2){
       return Math.abs((d1.getTime()-d2.getTime())/1000);//取绝对值
    }
    public static Date getEndDate(Date date) {
        if (date == null) {
            return null;
        }
        Calendar c = Calendar.getInstance();
        c.setTime(date);
        c.set(Calendar.HOUR_OF_DAY,23);
        c.set(Calendar.MINUTE,59);
        c.set(Calendar.SECOND,59);
        return c.getTime();
    }
}

service 实现类:

@Override
public boolean strategyThumbup(String strategyId, String userId) {
    //拼接记号 key
    String key = RedisKeys.USER_STRATEGY_THUMB.join(userId, strategyId);
    //判断key是否存在
    if (!template.hasKey(key)){
        //如果不存在,获取 vo,点赞数+1,缓存记号到redis中,设置过期时间
        StrategyStatisVO vo = this.getStrategyStatisVo(strategyId);
        vo.setThumbsupnum(vo.getThumbsupnum() + 1);
        this.setStrategyStatisVo(vo);
        //设置过期时间:今天最后一秒 - 当前时间
        Date now =new Date();
        Date end =DateUtil.getEndDate(now);
        Long time = DateUtil.getDateBetween(now, end);
        template.opsForValue().set(key,"1", time);
        return true;
    }
    //如果存在,不作任何操作
    return false;
}

完整 redis 缓存操作步骤

初始化 redis 数据

分析

数据预热,从 MongoDB 初始化进入 Redis

初始化处理逻辑放置在 listener 监听器中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jf0xrTT7-1651834690356)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402211054871.png)]

Spring 事件监听

Spring框架中有哪些不同类型的事件 ?

Spring 提供了以下 5 种标准的事件:

  1. 上下文更新事件(ContextRefreshedEvent)

    在调用ConfigurableApplicationContext 接口中的 refresh() 方法时被触发

    是容器启动之后,所有 IOC、DI、等操作执行完之后,开始监听

  2. 上下文开始事件(ContextStartedEvent)

    当容器调用ConfigurableApplicationContext的 Start() 方法开始/重新开始容器时触发该事件

    刚开始启动,容器准备创建的时候,开始监听

  3. 上下文停止事件(ContextStoppedEvent)

    当容器调用 ConfigurableApplicationContext 的 Stop() 方法停止容器时触发该事件

    容器死掉了之后,开始监听

  4. 上下文关闭事件(ContextClosedEvent)

    当 ApplicationContext 被关闭时触发该事件。容器被关闭时,其管理的所有单例Bean都被销毁

    死掉之后,并且销毁关灯了之后,开始监听

  5. 请求处理事件(RequestHandledEvent)

    在Web应用中,当一个http请求(request)结束触发该事件

如果一个bean实现了 ApplicationListener 接口,当一个 ApplicationEvent 被发布以后,bean会自动被通知

定义监听器,实现接口:ApplicationListener<ContextRefreshedEvent>

redis缓存数据初始化监听器

配置文件配置 redis

#redis
spring.redis.host=127.0.0.1

定义监听器,实现接口:ApplicationListener<ContextRefreshedEvent>

注意: 一般不会查询 MongoDB 所有攻略,一般选择近一个月、近三个月、等等有范围的数据,以防内存不足的情况

并且在业务逻辑中,获取缓存对象 vo 的时候,判断如果 vo 不存在,就进行初始化,也是因为一般 redis 中不会缓存数据库中所有的数据

/**
 *  @Component:使用了此注解注入容器管理之后才会开始工作
 *  redis缓存数据初始化监听器
 *
 *  MongoDB ---> redis
 *  1、攻略统计对象 vo
 *  2、用户攻略收藏列表 [拓展]
 */
@Component
public class RedisDataInitListener implements ApplicationListener<ContextRefreshedEvent> {
    @Autowired
    private IStrategyService strategyService;
    @Autowired
    private IStrategyStatisVORedisService strategyStatisVORedisService;

    //当 Spring 容器启动并初始化之后马上执行该方法
    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        System.out.println("--------------攻略vo对象初始化-begin---------------------");
        //1、查询 MongoDB 所有攻略
        List<Strategy> list = strategyService.list();
        //2、遍历所有攻略,封装成 vo 对象
        for (Strategy strategy : list) {
            /**
             *  存在问题:
             *      如果第一次初始化后,前端进行vo对象统计,redis中统计数据跟数据库中数据不一致了
             *      如果第二次再进行初始化,后面操作会覆盖之前redis缓存的vo对象
             *  解决方案:如果 vo 已经存在了,直接跳过,不需要初始化
             */
            if (strategyStatisVORedisService.isStrategyVoExist(strategy.getId())){
                continue;
            }
            StrategyStatisVO vo = new StrategyStatisVO();
            BeanUtils.copyProperties(strategy, vo);
            vo.setStrategyId(strategy.getId());
            //3、将 vo 缓存到 redis 中
            strategyStatisVORedisService.setStrategyStatisVo(vo);
        }
        System.out.println("--------------攻略vo对象初始化-end---------------------");
    }
}
//判断指定攻略id的vo对象是否存在redis缓存中
@Override
public boolean isStrategyVoExist(String strategyId) {
    String key = RedisKeys.STRATEGY_STATIS_VO.join(strategyId);
    return template.hasKey(key);
}

redis 数据持久化

分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A81JglAQ-1651834690357)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402220244199.png)]

spring定时器

SpringBoot 三种方式实现定时任务:

  1. Timer:

    这是java自带的java.util.Timer类,这个类允许你调度一个 java.util.TimerTask 任务。使用这种方式可以让你的程序按照某一个频度执行(每 3 秒执行一次),但不能在指定时间运行。一般用的较少

  2. ScheduledExecutorService:

    也jdk自带的一个类,是基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行,也就是说,任务是并发执行,互不影响

  3. Spring Task:

    Spring3.0以后自带的 task,可以将它看成一个轻量级的Quartz,而且使用起来比Quartz简单许多

https://www.cnblogs.com/javahr/p/8318728.html

http://cron.qqe2.com/

定义任务执行器

/**
 *  @Component:使用了此注解注入容器管理之后才会开始工作
 *  redis 缓存数据持久化定时任务
 */
//@Component
public class RedisDataPersistenceJob {
    @Autowired
    private IStrategyService strategyService;
    @Autowired
    private IStrategyStatisVORedisService strategyStatisVORedisService;

    /**
     *  cron:cron表达式 -> 任务计划
     *  秒  分  小时  几号  月份  周几  年份 [spring支持的没有年]
     *  0   0    2     1    *     ?     *  表示每年每月1号的凌晨2点
     *  0   15  10     ?    *    MON-FRI  表示每月周一至周五每天上午10:15
     *  0/10 表示从0开始,每隔10触发一次
     *  "0/10 * * * * ?" 表示每10秒执行一次,从0秒(现在)开始
     */
    @Scheduled(cron="0/10 * * * * ?")  //定时任务标签
    public void redisDataPersistence(){
        System.out.println("--------------攻略vo对象持久化-begin---------------------");
        //1、从 redis 中获取所有 vo 对象
        List<StrategyStatisVO> list = strategyStatisVORedisService.queryStrategyStatisVOs();
        //遍历vo对象集合,执行攻略统计数据更新,更新至 MongoDB
        for (StrategyStatisVO vo : list) {
            strategyService.updateStrategyStatisVO(vo);
        }
        System.out.println("--------------攻略vo对象持久化-end---------------------");
    }
}

需要获取 redis 中所有缓存数据,因为 redis 中的 key 都是规定好的格式,可以通过 指定字符 * 实现查询所有,如图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ahaqdcAF-1651834690357)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220402220209778.png)]

所以在业务实现类中,也可以使用此字符串,来进行所有 redis 缓存数据的查询

@Override
public List<StrategyStatisVO> queryStrategyStatisVOs() {
    // keys strategy_statis_vo:*
    String pattern = RedisKeys.STRATEGY_STATIS_VO.join("*");
    //获取所有vo对象的key值
    Set<String> voKeys = template.keys(pattern);
    List<StrategyStatisVO> list = new ArrayList<>();
    if (voKeys != null && voKeys.size() > 0){
        //循环通过vo的key获取vo对象
        for (String vo : voKeys) {
            String voStr = template.opsForValue().get(vo);
            list.add(JSON.parseObject(voStr, StrategyStatisVO.class));
        }
    }
    return list;
}

将指定的攻略vo统计数据对象,更新至MongoDB中

@Override
public void updateStrategyStatisVO(StrategyStatisVO vo) {
    Query query = new Query();
    query.addCriteria(Criteria.where("_id").is(vo.getStrategyId()));
    Update update = new Update();
    update.set("viewnum",vo.getViewnum());
    update.set("favornum",vo.getFavornum());
    update.set("replynum",vo.getReplynum());
    update.set("thumbsupnum",vo.getThumbsupnum());
    update.set("sharenum",vo.getSharenum());
    mongoTemplate.updateMulti(query, update, Strategy.class);
}

启动springboot 定时任务支持:在启动类上,贴注解 @EnableScheduling

@SpringBootApplication
@EnableScheduling     // SpringBoot开启定时任务功能
public class MgrSite {
	
    public static void main(String[] args) {
        SpringApplication.run(MgrSite.class,args);
    }
    
 	//....
}

10、网站首页

后端

banner 表设计

性名称属性类型属性说明
IdString主键,自增长
refIdString关联的id
titleString标题
subTitleString副标题
coverUrlString封面
stateInt状态
sequenceint序号
typeint约定关联的id是游记id还是攻略id

CRUD

domain、QueryObject、repository、service、controller

/**
 * 游记推荐
 */
@Setter
@Getter
@Document("banner")
public class Banner  extends BaseDomain {
    public static final int STATE_NORMAL = 0;   //正常
    public static final int STATE_DISABLE = 1;  //禁用
    public static final int TYPE_TRAVEL = 1;  //游记
    public static final int TYPE_STRATEGY = 2;  //攻略
    private String refId;  //关联id
    private String title;  //标题
    private String subTitle; //副标题
    private String coverUrl; //封面
    private int state = STATE_NORMAL; //状态
    private int sequence; //排序
    private int type;
    public String getJsonString(){
        Map<String, Object> map = new HashMap<>();
        map.put("id",id);
        map.put("title",title);
        map.put("subTitle",subTitle);
        map.put("coverUrl",coverUrl);
        map.put("state",state);
        map.put("sequence",sequence);
        map.put("refId",refId);
        map.put("type",type);
        return JSON.toJSONString(map);
    }
    public String getStateDisplay(){
        return state == STATE_NORMAL?"正常":"禁用";
    }
    public String getTypeDisplay(){
        return type == TYPE_STRATEGY?"攻略":"游记";
    }
}

banner 添加

在类型、关联文章的两个下拉框中,当类型选择 攻略 、或者游记 时,关联文章应该显示对应的 攻略、 或者游记

页面:

先查询出所有的攻略、游记,缓存放进变量

var sts;
var ts;
$(function () {
    $.get("/banner/getAllType", function (data) {
        if(data.code == 200){
            var map = data.data;
            sts = map.sts;  //攻略
            ts = map.ts;  //游记

            console.log(sts);
            console.log(ts);
        }
    })
})

当类型的下拉框选择 1 时,遍历刚刚游记的变量。当选择 2 时,遍历刚刚攻略的变量

$("#typeId").change(function () { //当类型的下拉框内容改变的时候执行
    $("#refId").html('');
    var html = '<option value="-1">--请选择--</option>';
    if(this.value == 1){
        //游记
        $.each(ts, function (index, item) {
            html += '<option value="'+item.id+'">'+item.title+'</option>'
        })
    }else if(this.value == 2){
        //攻略
        $.each(sts, function (index, item) {
            html += '<option value="'+item.id+'">'+item.title+'</option>'
        })
    }
    $("#refId").html(html);
})

接口:

@RequestMapping("/getAllType")
@ResponseBody
public Object getAllType(){
    List<Strategy> sts = strategyService.list();
    List<Travel> ts = travelService.list();
    Map<String, Object> map = new HashMap<>();
    map.put("sts",sts);
    map.put("ts",ts);
    return JsonResult.success(map);
}

前端首页推荐 banner

首页封面类似banner的组件,使用推荐游记前4篇进行列表

接口:

//首页推荐游记5篇
@GetMapping("/banners/travel")
public Object bannersTravel(){
    List<Banner> list = bannerService.queryBannerByType(Banner.TYPE_TRAVEL);
    return JsonResult.success(list);
}

//首页推荐攻略1篇
@GetMapping("/banners/strategy")
public Object bannersStrategy(){
    List<Banner> list = bannerService.queryBannerByType(Banner.TYPE_STRATEGY);
    return JsonResult.success(list.get(0));
}

service 实现类:

@Override
public List<Banner> queryBannerByType(int type) {
    //注意,查询出的内容状态应是正常
    Pageable pageable = PageRequest.of(0,5, Sort.Direction.ASC,"sequence");
    return bannerRepository.findByTypeAndState(type, Banner.STATE_NORMAL, pageable);
}

持久化接口:

//Banner持久化操作接口,类似 Mapper 接口
@Repository
public interface BannerRepository extends MongoRepository<Banner, String> {
    /**
     * 根据类型和状态查询 banner
     * @param type
     * @param state
     * @param pageable
     * @return
     */
    List<Banner> findByTypeAndState(int type, int state, Pageable pageable);
}

使用es

依赖、配置文件

trip-core 中添加依赖

<!--elasticsearch-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.3</version>
</dependency>

trip-website-api 、trip-mgrsite 中都要添加以下配置

# 配置集群名称,名称写错会连不上服务器,默认elasticsearch
spring.data.elasticsearch.cluster-name=elasticsearch
# 节点的地址,注意api模式下端口号是9300,千万不要写成9200
# 配置集群节点。集群时,用逗号隔开,es会自动寻找节点
spring.data.elasticsearch.cluster-nodes=127.0.0.1:9300
#是否开启本地存储
spring.data.elasticsearch.repositories.enable=true

注意,两个模块中都要添加 es 配置,否则可能会报错

实体类、CRUD

数据初始化

数据初始化:即将 MongoDB 的数据 初始化进 elasticsearch 库中

一般初始化的操作放在后台管理的模块,即 trip-mgrsite 模块

此处为了方便控制数据初始化,放在 trip-website-api 模块

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qv0grIow-1651834690358)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220406205344244.png)]

需要注意一个问题

注意: 因为当添加数据的时候,如果没有对应的索引存在,es 会推测索引及类型,自己去创建索引。就会与我们要求需要的索引类型不一致,在后续操作中可能会出错

所以在项目启动之后,一定要等 索引、类型 加载好(根据domain生成好)之后,在进行数据初始化操作

@RestController
public class DataInitController {
    //...
}

关键字搜索

页面:

//搜索相关
function searchByType(type, keyword) {
    if(!keyword){
        popup("请先输入搜索关键字");
        return;
    }
    var html = '';
    if(type == 0){
        html = 'searchDest.html';
    }else if(type == 1){
        html = 'searchStrategy.html';
    }else if(type == 2){
        html = 'searchTravel.html';
    }else if(type == 3){
        html = 'searchUser.html';
    }else{
        html = 'searchAll.html';
    }
    window.location.href="/views/search/"+html+"?type=" +type+ "&keyword=" + keyword;
}
mounted:function () {
    var params = getParams();
    ajaxGet("/q", params, function (data) {
        var map = data.data;
        vue.result = map.result;
        vue.qo = map.qo;
        $("#_j_search_input").val(vue.qo.keyword);
        $("#searchType").val(-1);  //所有
    })
}

定义查询参数类 SearchQueryObject

@Setter
@Getter
public class SearchQueryObject extends QueryObject {
    public static final int TYPE_ALL = -1;
    public static final int TYPE_DEST = 0;
    public static final int TYPE_STRATEGY = 1;
    public static final int TYPE_TRAVEL = 2;
    public static final int TYPE_USER = 3;
    private int type = -1;
}

定义结果封装vo对象 SearchResultVO

@Setter
@Getter
public class SearchResultVO implements Serializable {
    private Long total = 0L;
    private List<Strategy> strategys = new ArrayList<>();
    private List<Travel> travels = new ArrayList<>();
    private List<UserInfo> users = new ArrayList<>();
    private List<Destination> dests = new ArrayList<>();
}

接口:

window.location.href =  "/views/search/"+html+"?type=" + type +"&keyword=" + keyword;

注意: 因为最开始的地方是将用户输入的关键字,传入了 url 地址中,后面获取参数都是从 url 上进行获取,就会有浏览器的编码,所以在接口中获取的关键字是编码后的

所以在使用之前需要先解码

@RestController
public class SearchController {
    
    @GetMapping("/q")
    public Object search(SearchQueryObject qo){
        //url解码
        String kw = URLDecoder.decode(qo.getKeyword(), "UTF_8");
        qo.setKeyword(kw);
        
        switch (qo.getType()){
            case  SearchQueryObject.TYPE_DEST :
                //目的地
                return searchDest(qo);
            case  SearchQueryObject.TYPE_STRATEGY :
                //攻略
                return searchStrategy(qo);
            case  SearchQueryObject.TYPE_TRAVEL :
                //游记
                return searchTravel(qo);
            case  SearchQueryObject.TYPE_USER :
                //用户
                return searchUser(qo);
            default:
                //全部
                return searchAll(qo);
        }
    }
    
    //...
}

目的地搜索

需求: 查询目的地,如果找到, 显示该目的地下所有 攻略, 游记, 用户

页面:

mounted:function () {
    var params = getParams();
    ajaxGet("/q", params, function (data) {
        var map = data.data;
        vue.result = map.result;
        vue.dest = map.dest;
        vue.qo = map.qo;
        $("#_j_search_input").val(vue.qo.keyword);
        $("#searchType").val(0);  //目的地
    })
}

接口:

//查询目的地
private Object searchDest(SearchQueryObject qo) {
    //1、查询keyword对应的目的地是否存在
    Destination dest = destinationService.queryByName(qo.getKeyword());
    SearchResultVO vo = new SearchResultVO();
    if(dest != null){
        //2、如果存在,查询该目的地下所有攻略、游记、用户。 约定前5篇
        List<Strategy> sts = strategyService.queryByDestId(dest.getId());
        List<Travel> ts = travelService.queryByDestId(dest.getId());
        List<UserInfo> us = userInfoService.queryByCity(dest.getName());
        vo.setStrategys(sts);
        vo.setTravels(ts);
        vo.setUsers(us);
        vo.setTotal(sts.size() + ts.size() + us.size() + 0L);
    }
    //3、返回
    Map<String,Object> map = new HashMap<>();
    map.put("result",vo);
    map.put("dest",dest);
    map.put("qo",qo);
    return JsonResult.success(map);
}

数据封装对象

定义结果封装vo对象 SearchResultVO

@Setter
@Getter
public class SearchResultVO implements Serializable {
    private Long total = 0L;
    private List<Strategy> strategys = new ArrayList<>();
    private List<Travel> travels = new ArrayList<>();
    private List<UserInfo> users = new ArrayList<>();
    private List<Destination> dests = new ArrayList<>();
}

攻略查询(高亮)

仅仅对攻略进行全文搜索,攻略: 标题(title),副标题(subTitle),概要(summary)

需要高亮,需要分页

高亮服务类

高亮显示,需要先引入 service 高亮服务类

/**
 * 所有es公共服务
 */
public interface ISearchService {
    /**
     * 全文搜索 + 高亮显示
     * @param index 索引
     * @param type  类型
     * @param clz  返回的对象类型
     * @param qo  
     * @param fields  需要高亮的字段
     * @param <T>
     * @return 带有分页的全文搜索(高亮显示)结果集
     */
    <T> Page<T>  searchWithHighlight(String index, String type, Class<T> clz,
                                     SearchQueryObject qo, String... fields);
}

参数查询对象 QueryObject 里面添加分页参数属性

private Pageable pageable;  //分页设置对象
public Pageable getPageable(){
    if(pageable == null){
        //没有指定分页对象值, 默认id倒序
        return  PageRequest.of(currentPage - 1, pageSize,
                Sort.Direction.ASC, "_id");
    }
    return pageable;
}

页面:

var params = getParams();
ajaxGet("/q", params, function (data) {
    var map = data.data;
    vue.page = map.page;
    vue.qo = map.qo;
    $("#_j_search_input").val(vue.qo.keyword);
    $("#searchType").val(1);  //攻略
    buildPage(vue.page.number, vue.page.totalPages, vue.doPage)
})

接口:

注意: 这里能实现高亮的列,必须和 elasticsearch 的索引中的字段名一致

//搜索攻略
private Object searchStrategy(SearchQueryObject qo) {
    //攻略的全文搜索+高亮
    Page<Strategy> spage = searchService.searchWithHighlight(
                            StrategyEs.INDEX_NAME, StrategyEs.TYPE_NAME,
                            Strategy.class, qo, "title", "subTitle", "summary"
    );
    //返回
    Map<String,Object> map = new HashMap<>();
    map.put("page",spage);
    map.put("qo",qo);
    return JsonResult.success(map);
}

抽取

public class ParamMap extends HashMap<String ,Object> {
    //链
    public ParamMap put(String key, Object value) {
        super.put(key, value);
        return this;
    }

    public static ParamMap newInstance() {
        return new ParamMap();
    }
}
//攻略的全文搜索+高亮
private Page<Strategy> queryStrategyPage(SearchQueryObject qo) {
    return searchService.searchWithHighlight(StrategyEs.INDEX_NAME, StrategyEs.TYPE_NAME, Strategy.class, qo, "title", "subTitle", "summary");
}
//用户的全文搜索+高亮
private Page<UserInfo> queryUserInfoPage(SearchQueryObject qo) {
    return searchService.searchWithHighlight(UserInfoEs.INDEX_NAME, UserInfoEs.TYPE_NAME, UserInfo.class, qo, "info", "city");
}
//游记的全文搜索+高亮
private Page<Travel> queryTravelPage(SearchQueryObject qo) {
    return searchService.searchWithHighlight(TravelEs.INDEX_NAME, TravelEs.TYPE_NAME, Travel.class, qo, "title","summary");
}
//目的地的全文搜索+高亮
private Page<Destination> queryDestinationPage(SearchQueryObject qo) {
    return searchService.searchWithHighlight(DestinationEs.INDEX_NAME, DestinationEs.TYPE_NAME, Destination.class, qo, "name", "info");
}

游记、用户查询

同攻略:发现有重复,所以抽取工具类

用户: 简介(info), 城市(city)

游记:标题(title), 概要(summary)

//搜索用户
private Object searchUser(SearchQueryObject qo) {
    return JsonResult.success(new ParamMap()
                              .put("page",this.queryUserInfoPage(qo))
                              .put("qo",qo));
}

//搜索游记
private Object searchTravel(SearchQueryObject qo) {
    return JsonResult.success(ParamMap.newInstance()
                              .put("page",this.queryTravelPage(qo))
                              .put("qo",qo));
    //return JsonResult.success(new ParamMap().put("page",page).put("qo",qo));
}

全部搜索(高亮)

//查询所有
private Object searchAll(SearchQueryObject qo) {
    //1、全文搜索+高亮
    Page<UserInfo> us = this.queryUserInfoPage(qo);
    Page<Travel> ts = this.queryTravelPage(qo);
    Page<Strategy> sts = this.queryStrategyPage(qo);
    Page<Destination> ds = this.queryDestinationPage(qo);
    //2、将每个部分的第1页数据,封装为结果对象
    SearchResultVO vo = new SearchResultVO();
    vo.setDests(ds.getContent());
    vo.setStrategys(sts.getContent());
    vo.setTravels(ts.getContent());
    vo.setUsers(us.getContent());
    vo.setTotal(sts.getTotalElements() + ts.getTotalElements() 
                + us.getTotalElements() + ds.getTotalElements());
    //3、返回
    //链式编程
    return JsonResult.success(new ParamMap().put("result",vo).put("qo",qo));
}

高亮显示分析

高亮就是在查询出的结果的基础上,对关键字部分加上前后标签,然后进行返回。

如果没有指定前后标签,则默认会将关键字进行加粗显示

数据同步与更新【拓展】

数据同步问题

使用消息中间件:kafka / 各类MQ

分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YKXMnAxp-1651834690359)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220406205230276.png)]

问题

解决 netty 冲突

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bUAmnPhT-1651834690360)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220406205427058.png)]

因为使用的 redis、es,二者都会使用到 netty ,导入依赖时可能存在版本不一致,就使得使用的时候,底层创建了两个 netty ,产生冲突

解决:

在启动类里面加上代码

public static void main(String[] args) {
    //解决netty冲突
    System.setProperty("es.set.netty.runtime.available.processors", "false");
    SpringApplication.run(WebSite.class,args);
}

11、接口

接口文档 swagger2

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OVT7rt1t-1651834690361)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220408092649291.png)]

作用

  1. api一定需要开发文档配合,移动端只需要根据开发文档进行开发即可;
  2. 传统的开发文档问题:格式随意,更新不及时;

https://www.jianshu.com/p/d7b13670e0eb

ShowDoc :https://www.showdoc.cc/

swagger2

Swagger能够根据代码中的注解自动生成api文档,并且提供测试接口;

依赖

依赖放在 trip-website-api 模块,因为使用的是这里的接口

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.9.2</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>

配置文件

配置类

@Configuration
@EnableSwagger2
public class SwaggerConfig implements WebMvcConfigurer {
    @Bean
    public Docket productApi() {
        //添加head参数start
        ParameterBuilder tokenPar = new ParameterBuilder();
        List<Parameter> pars = new ArrayList<Parameter>();
        tokenPar.name("token").description("令牌").modelRef(new ModelRef("string")).parameterType("header").required(false).build();
        pars.add(tokenPar.build());
        return new Docket(DocumentationType.SWAGGER_2).select()
                // 扫描的包路径
                .apis(RequestHandlerSelectors.basePackage("com.controller"))
                // 定义要生成文档的Api的url路径规则
                .paths(PathSelectors.any())
                .build()
                .globalOperationParameters(pars)
                // 设置swagger-ui.html页面上的一些元素信息。
                .apiInfo(metaData());
    }
    private ApiInfo metaData() {
        return new ApiInfoBuilder()
                // 标题
                .title("SpringBoot集成Swagger2")
                // 描述
                .description("项目接口文档")
                // 文档版本
                .version("1.0.0")
                .license("Apache License Version 2.0")
                .licenseUrl("https://www.apache.org/licenses/LICENSE-2.0")
                .build();
    }
    //ui页面
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

访问

http://localhost:8080/swagger-ui.html

常见标签

@Api/@ApiOperation
  • @Api:用在类上,说明该类的作用

    @Api(value = "用户资源",description = "用户资源控制器")
    public class UserInfoController { ... }
    
  • @ApiOperation:用在方法上,说明方法的作用

    @ApiOperation(value = "注册功能",notes = "其实就是新增用户")
    @PostMapping("/regist")
    public Object regist( ... ){ ... }
    
@ApiImplicitParams/@ApiImplicitParam 参数说明

@ApiImplicitParams:用在方法上包含一组参数说明

@ApiImplicitParam:用在@ApiImplicitParams注解中,指定一个请求参数的各个方面

  • paramType:参数放在哪个地方
  • header–>请求参数的获取
  • query–>请求参数的获取
  • path–>请求参数的获取(用于restful接口):
  • body–>请求实体中
@ApiOperation(value = "注册功能",notes = "其实就是新增用户")
@ApiImplicitParams({
     @ApiImplicitParam(value="昵称",name="nickName",dataType ="String",required=true),
  @ApiImplicitParam(value="验证码",name="verifyCode",dataType="String",required=true), 
      @ApiImplicitParam(value="密码",name="password",dataType="String",required= true)
})
@PostMapping("/regist")
public Object regist(String phone, String nickname,String password,String rpassword,String verifyCode){
    //...
}
@ApiModel/@ApiModelProperty 实体类上

需要配合 get、set 方法

@ApiModel:描述一个 Model 的信息

(这种一般用在 post 创建的时候,使用@RequestBody这样的场景,请求参数无法使用@ApiImplicitParam注解进行描述的时候)

@ApiModelProperty:描述一个 model 的属性

@Setter
@Getter
@ApiModel(value="用户",description="平台注册用户模型")
public class UserInfo extends BaseDomain{
    
    @ApiModelProperty(value="昵称",name="nickName",dataType = "String",required = true)
    private String nickname;  //昵称
}
@ApiResponses/@ApiResponse

@ApiResponses:用于表示一组响应

@ApiResponse:用在@ApiResponses中,一般用于表达一个错误的响应信息(200相应不写在这里面)

  • code:数字,例如400
  • message:信息,例如"请求参数没填好"
  • response:抛出的异常类
@ApiResponses({
	@ApiResponse(code=200,message="用户注册成功")
})
@ApiIgnore 不显示

有些接口不想显示,就贴上去,可以贴在类上,也可以贴在方法上。

接口安全

api接口分类:

1> 公共接口:你查快递,你查天气预报,你查飞机,火车班次等,这些都是有公共的接口

2>私密接口:需要登录访问或者公司内部接口

接口安全要求:

  1. 防伪装攻击(案例:在公共网络环境中,第三方 有意或恶意 的调用我们的接口):接口防刷

  2. 防篡改攻击(案例:在公共网络环境中,请求头/查询字符串/内容 在传输过程被修改):接口防篡改(签名机制)

  3. 防重放攻击(案例:在公共网络环境中,请求被截获,稍后被重放或多次重放):接口时效性

  4. 防数据信息泄漏(案例:截获用户登录请求,截获到账号、密码等):接口加密(https/对称加解密)

接口防刷

分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X0TYLteN-1651834690361)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220407130729700.png)]

思路:

  1. 设计一个redis临时key, 有效时间是1分钟,1分钟内只允许10访问

    key> url : ip

    value>访问的次数

  2. 设置拦截器,拦截需要防刷的接口url

  3. 拦截逻辑

    1. 拦截 url,拼接 key 查询 redis 中是否存在
    2. 如果不存在 setnx url:ip 10
    3. 如果存在 derc url:ip
    4. 如果次数减到0,拦截返回:请勿频繁访问
    5. 其他情况直接放行。

代码:

因为操作是针对接口,所以放在 trip-website-api 模块中

引入一个工具类,用于获取用户的 ip

public class RequestUtil {
    public static String getIPAddress() {
        //...
    }
}    

因为需要存放在 redis 中,所以设计 RedisKey

//接口防刷key,单位秒
BRUSH_PROOF("brush_proof", 10L)

拦截器:

/**
 * 防刷拦截器
 */
public class BrushProofInterceptor implements HandlerInterceptor {

    @Autowired
    private ISecurityRedisService securityRedisService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //解决拦截器跨域问题
        if(!(handler instanceof HandlerMethod)){
            return  true;
        }
        //request.getRequestURL()  : http://localhost:8088/users/xxx
        //request.getRequestURI()  : /users/xxx
        //防刷验证
        String url = request.getRequestURI().substring(1);  //去掉开头斜杠
        String ip = RequestUtil.getIPAddress();
        String key = RedisKeys.BRUSH_PROOF.join(url, ip);
        if(!securityRedisService.isAllowBrush(key)){
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSON.toJSONString(JsonResult.error(500, "请勿频繁访问","谢谢咯")));
            return false;
        }
        return true;
    }
}

redis 服务:

@Service
public class SecurityRedisServiceImpl implements ISecurityRedisService {
    @Autowired
    private StringRedisTemplate template;
    @Override
    public boolean isAllowBrush(String key) {
        /* 方式一:不推荐
            判断key是否存在
            如果存在,对应的value -1,更新
            如果不存在,创建 key,设置value为 10-1,有效性1分钟
         */

        /**
         *  如果有不做 任何操作,如果没有添加
         *      setIfAbsent:若key不存在,执行创建key操作,若存在不做任何操作
         *      类似于redis命令中的 setnx:表示如果key不存在就创建并执行,若存在不做任何操作
         *      setnx key1 value1
         */
        template.opsForValue().setIfAbsent(key, "10", RedisKeys.BRUSH_PROOF.getTime(), TimeUnit.SECONDS);
        Long decrement = template.opsForValue().decrement(key);
        return decrement >= 0;
    }
}

拦截器配置:启动类中

@SpringBootApplication
public class WebSite implements WebMvcConfigurer{
    
    //接口防刷拦截器
    @Bean
    public BrushProofInterceptor brushProofInterceptor(){
        return  new BrushProofInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //防刷
        registry.addInterceptor(brushProofInterceptor())
                .addPathPatterns("/**");
    }
    //...
}    

接口防篡改

签名机制

分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JWDzW8ep-1651834690362)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220407130809453.png)]

思路:

  1. 前端传参数时,对参数的名进行字典排序,然后按照参数名的属性对参数值进行有序拼接

    ​ 比如:

    ​ 参数列表: c=参数1, f=参数2, a=参数3, b=参数4

    ​ 参数排名: a b c f

    ​ 参数值拼接:a=参数3 & b=参数4 & c=参数1& f=参数2

  2. 使用 MD5 对拼接参数值串进行加密得到参数签名 sign_client

  3. 将所有参数与 sign 一同发送到后端

  4. 后端获取到所有参数, 按照同样的逻辑,MD5加密得到sign_server

    ​ 参数列表: c=参数1, f=参数2, a=参数3, b=参数4 sign_client

    ​ 参数排名: a b c f

    ​ 参数值拼接:a=参数3 & b=参数4 & c=参数1& f=参数2

  5. 对比 sign_server 跟 sign_client 2个签名是否一致, 一致表示参数没变篡改,否则提示参数被改,不合法

注意:

  1. 签名算法不能被泄露——js 混淆与加密
  2. 需要传人很多参数/大数据量参数(比如上传),不能使用这种方式——针对性处理

页面:

页面改动:common.js

//执行前端参数加密操作 返回sign前面
//{aa:1, cc:2, bb:3}
function getSignString(param) {
    //1:参数排序:字典顺序
    var sdic=Object.keys(param).sort();
    //2:参数拼接
    var signStr = "";
    for(var i in sdic){
        if(i == 0){
            signStr +=sdic[i]+"="+param[sdic[i]];
        }else{
            signStr +="&"+sdic[i]+"="+param[sdic[i]];
        }
    }
    //signStr = aa=1&bb=3&cc=2
    console.log(signStr);
    //3:md5加密
    console.log(hex_md5(signStr));
    return hex_md5(signStr).toUpperCase();
}

请求方法:ajaxGet 底层调用 数据防篡改函数

//通过js操作将加密之后的签名手动添加到参数中去
param.sign = getSignString(param);  //使用逻辑处理

所有页面添加md5.js

 <script src="js/md5/md5.js"></script>

请求测试:

//防篡改操作
ajaxGet("/test2", {aa:1, cc:2, bb:3}, function (data) {
    console.log(data);
})

后端:

提供工具类,使得传入的参数,进行排序、MD5加密、大写 操作

/**
 * Md5工具类
 */
public class Md5Utils {
    /**
     * @Description: 签名:请求参数排序并后面补充key值,最后进行MD5加密,返回大写结果
     * @param params 参数内容
     */
    public static String signatures(Map<String, Object> params){
        //...
    }
}   

签名拦截器:

/**
 * 签名拦截(防篡改)
 */
public class SignInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if(!(handler instanceof HandlerMethod)){
            return  true;
        }
        /**
         *  此处接收参数,value位置是数组String[]
         *  因为前端可能有多选下拉框等,就会是是多个数据
         *  aa:["1"]
         *  bb:["2"]
         */
        Map<String, String[]> map = request.getParameterMap();
        Set<String> keys = map.keySet();
        Map<String, Object> param = new HashMap<>();
        for (String s : map.keySet()) {
            if("sign".equalsIgnoreCase(s)){
                continue;
            }
            param.put(s, arrayToString(map.get(s)));
        }
        //签名验证
        String signatures = Md5Utils.signatures(param);  //sign_server
        String sign = request.getParameter("sign");   //sign_client
        if(sign == null || !sign.equalsIgnoreCase(signatures)){
            response.setContentType("text/json;charset=UTF-8");
            response.getWriter().write(JSON.toJSONString(new JsonResult(501,"签名校验失败","不好意思咯")));
            return false;
        }
        return true;
    }
    private String arrayToString(String [] array){
        StringBuilder sb = new StringBuilder(10);
        for (String s : array) {
            sb.append(s);
        }
        return sb.toString();
    }
}

配置拦截器:

@SpringBootApplication
public class WebSite implements WebMvcConfigurer{
    //接口防篡改拦截器
    @Bean
    public SignInterceptor signInterceptor(){
        return  new SignInterceptor();
    }
    //接口防刷拦截器
    @Bean
    public BrushProofInterceptor brushProofInterceptor(){
        return  new BrushProofInterceptor();
    }
    //将拦截器注入Spring容器中,交给SpringBoot管理
    @Bean
    public CheckLoginInterceptor checkLoginInterceptor(){
        return new CheckLoginInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录权限验证
        registry.addInterceptor(checkLoginInterceptor())
                .addPathPatterns("/**")
        //防刷
        registry.addInterceptor(brushProofInterceptor())
                .addPathPatterns("/**");
        //签名
        InterceptorRegistration it = registry.addInterceptor(signInterceptor())
                .addPathPatterns("/**");
    }
    //...
}    

接口时效性

分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4Q7CAoAV-1651834690363)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220407130825266.png)]

签名机制+有效时间

思路:

  1. 前端传参数时,对参数的名进行字典排序,然后按照参数名的属性对参数值进行有序拼接

    ​ 比如:

    ​ 参数列表: c=参数1, f=参数2, a=参数3, b=参数4 timestamp=188888

    ​ 参数排名: a b c f timestamp

    ​ 参数值拼接:a=参数3&数4& c=参数1& f=参数2&timestamp=188888

  2. 使用MD5对拼接参数值串进行加密得到参数签名sign_client

  3. 将所有参数与sign一同发送到后端

  4. 后端获取到所有参数, 按照同样的逻辑,MD5加密得到sign_server

    ​ 参数列表: c=参数1, f=参数2, a=参数3, b=参数4 sign_client

    ​ 参数排名: a b c f

    ​ 参数值拼接:a=参数3&数4& c=参数1& f=参数2&timestamp=188888

  5. 获取timestamp与当前时间对比是否在有效时间内, 比如1分钟, 在执行下一步, 不在,提示接口访问失效

  6. 对比sign_server 跟sign_client 2个签名是否一致, 一致表示参数没变篡改,否则提示参数被改,不合法

接口加密

https 分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eNOKNcYb-1651834690363)(C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20220408092611395.png)]

https 密文传输 http 明文传输

阿里云服务申请https加密证书

https://www.cnblogs.com/shibaolong/p/9837247.html

https 本地证书

SpringBoot 整合 SSL 证书

12、mongodb事务

MongoDB复制集

复制集概念

Mongodb复制集由一组Mongod实例(进程)组成,包含一个Primary节点和多个Secondary节点,Mongodb Driver(客户端)的所有数据都写入Primary,Secondary从Primary同步写入的数据,以保持复制集内所有成员存储相同的数据集,提供数据的高可用。

为什么存在:

  • 高可用
  • 数据备份
  • 读写分离

主从选举机制

一个典型的复制集,有3个以上 (一般是2n+1个) 具有投票权的节点组成:

  • 一个主节点(Primary): 接收写入操作和选择时投票 【主写】
  • 2个(或多个)从节点(secondary):复制主节点上的新数据和选举是投票【主读】

选举细节:

1>集群中大部分节点是活的

2>称为主节点必须能跟从节点建立连接

3>具有较新的 oplog 文件

4>具有较高的优先级

主从复制

  1. 主节点主写

    当在主节点执行一个DML操作时,主节点会对数据的操作过程做记录, 这些记录称为oplog

    oplog详解: https://www.cnblogs.com/Joans/p/7723554.html

  2. 从节点主读

    从节点通过在主节点上打开一个tailable 游标不断读取主节点的oplog,执行上面的数据命令(命令回放),以此保持跟主节点的数据一致。

配置

看文档 mongodb的复制集配置.docx

事务测试(java)

操作前注意:必须要先创建集合再操作

配置文件

spring.data.mongodb.uri=mongodb://127.0.0.1:27017,127.0.0.1:27018,127.0.0.1:27019/monodemo?replicaSet=rs

启动类

	//mongodb事务
    @Bean
    public MongoTransactionManager transactionManager(MongoDbFactory dbFactory) {
        return new MongoTransactionManager(dbFactory);
    }

服务类添加事务注解

服务类
@Transactional
------------------------------------------------------------------------------------------
@Setter
@Getter
@Document(collection = "user")
@ToString
public class User implements Serializable{
    @Id
    private String id;
    private String name;
    private int age;
}
@Setter
@Getter
@Document(collection = "person")
@ToString
public class Person {
    @Id
    private String id;
    private String name;
    private int age;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值