1、项目概述
目前电商类 App 应该占有市场最大氛围。但是目前单纯类的销售型电商平台越来越少,逐渐进入了内容为主的模式
内容类 App 主要分为三类:
-
UGC:user generated content,用户生产内容,要求平台普通用户都能参与内容的生产, 比如社区类平台,短视频平台都是典型的 UGC 类型平台;
-
PGC:professional generated content,专业生产内容。如果所有由用户参与平台的内容生产,造成的最大问题是内容质量的参差不齐,所以要求平台的内容只由部分平台指定用户生产,比如平台认证专家等;例如优酷的专家栏目,喜马拉雅的专家声音等;
-
OGC:occupational generated content,职业生产内容。内容由专职人员生产,并以此支付对应的报酬
和 PGC 最大的区别在于,PGC 是以免费生产内容为主
而 OGC 以收费作为输出内容的回报。一般企业网站就是典型的 OGC
因为内容类 App 涵盖的面很广,包括知识内容分享,购物体验分享,新闻资讯分享,纯社区类,社区偏内容,内容偏社区等等。在本项目中,我们主要针对典型的 OGC+UGC 场景, 做一个点评类内容 App
点评类内容 App 针对的行业非常多,常见的美食,旅游,等等,包含了生活中的吃喝玩乐 住行都有。在本项目中,主要针对旅游行业,做一个点评内容 App
在本项目中,我们把关注点聚焦,来完成一个针对旅游类的攻略,点评,分享旅游日记的 App
技术路线
项目技术路线:
-
数据库:mongodb + elasticsearch
-
持久化层:mongodb+Redis (缓存)
-
业务层:Springboot;
-
Web:SpringMVC;
-
前端:
管理后台:jQuery+Bootstrap3
前端展示:vue +jquery + css;
项目组成结构
trip-parent 项目怎么管理依赖:
-
如果 所有子项目都需要 某个依赖,将该依赖,添加到父项目pom. xml文件的
<dependencies>
标签里面,表示所有项目共享 -
如果 部分子项目都需要 某个依赖,将该依赖,添加到父项目pom. xml文件的
<dependencyManagement>
标签里面,父项目对这个依赖进行版本管理其他需要用该依赖的子项目在pom. xml的
<dependencies>
标签里面引入依赖,此时不需要引入依赖的版本 -
如果 某个子项目需要 某个依赖,将该依赖,添加到子项目pom. xml文件的
<dependencies>
标签里面,自己用即可
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);
}
}
修改一下启动类的名称
测试一下
运行启动类
访问 http://localhost:8080/users/get?id=5e295f01a00e265228f963ea
有数据,表示环境搭建成功
4、trip-website
创建 静态的 web 项目
拷贝文件
配置 Tomcat
搭建环境的问题集合
-
如果搭建错误,但是不想重新搭建
打开项目所在文件夹,删除里面 .idea 等所有文件,只保留 src 目录、pom.xml 文件 两个内容
然后打开 idea ,选择导入项目工程 ,注意 勾选search for projects recursively
对于静态的web模块,需要在导入项目之后,单独导入模块
-
在右侧的 Maven Projects 里面,呈现灰色
点击上面的 + ,选择磁盘中 pom.xml 文件导入
-
在 pom.xml 文件中爆红
-
网络不行,下载慢
-
可能以前下载一半,或者下载错误。导致现在找不到对应的依赖
进入磁盘中 maven 仓库的文件夹,找到对应爆红依赖的目录,删除,然后回到 idea 重新下载
-
可能是 idea 版本问题
-
可能是 SpringBoot 版本问题,换个版本
-
-
还是不行。清空并重启 idea
码云准备
-
先在 码云 上新建仓库
-
进入磁盘中项目所在目录,放入忽略文件
-
右击打开 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"
-
码云上可以查看项目
-
如果有更新,本地更新完毕之后,执行以下,即可推送
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
查看注册页面,进行注册
出现跨域问题,在启动类中进行配置即可
@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 :
但是 session 在 安卓 等平台不是很好使用,所以推荐使用 redis
下面是使用 redis :
启动 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);
}
细节优化
统一异常处理
现在的异常只有后台可以看到,用户体验不到。所以需要将异常信息反馈给用户,需要捕获异常
但是存在用户提示异常、系统异常,系统异常需要美化,所以使用自定义异常类来区分两种异常
-
可以选择直接修改后端控制器方法(不推荐)
这样很麻烦,所以选择统一异常处理
@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(); }
-
自定义异常处理类
这个类是为了 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);
}
短信硬编码优化
因为上面代码将 短信内容、网关接口 写死了,后续修改不方便
可以将信息放入配置文件
配置文件:
#短信
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、用户登录
互联网项目登录
逻辑分析
传统登录逻辑分析:
互联网登录方式:令牌方式(token)+redis
逻辑分析:
首次登陆代码实现
枚举类 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
代码实现
因为现在的登录控制,控制的是前端用户的登录,所以拦截器放在 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);
}
}
为什么要二次解决跨域?
明明之前在启动类里面已经处理了跨域问题,为什么现在在自定义拦截器类里面又要处理一次跨域问题
跨域请求原理:
拦截器放行:
本例中,首次预请求的时候,没有携带 cookie 的参数,在拦截器的位置就被拦截,导致第二步浏览器没有收到允许的指令,就直接报错。访问失败
所以需要在拦截器对跨域请求进行放行
if (!(handler instanceof HandlerMethod)){
//说明访问是静态的,或者请求是跨域的
return true;
}
登录控制——请求区分拦截
对于旅游网页,就算用户不登录应该也可以看到一部分的网页信息,所以此处有新的需求
分析
**需求:**要求部分请求必须登录之后才可以访问,但是部分请求就算不登录也可以访问
此处就可以想到 RBAC 项目中权限控制 的操作:
使用注解来控制,需要控制权限的就加上注解,不需要控制的就不加上注解
代码实现
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 中获取的,有两种方式:
-
若父类中是 private 修饰:
不可以直接获取,会报错。使用 super.getId()
-
若父类中是 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)
属性名称 | 属性类型 | 属性说明 |
---|---|---|
Id | String | 主键 |
parentId | String | 父级id |
parentName | String | 父级名称 |
name | String | 目的地名 |
english | String | 英文 |
coverUrl | String | 封面 |
info | String | 简介,描述 |
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);
}
导航吐司
从国家位置开始,一直往下探, 根>>中国>>广东>>广州
数据库表里面设计了列 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
进入前端目的地展示页面
分析
- 鼠标移动到不同区域,显示该区域下挂载的目的地集合
- 同时将挂载目的地的所有子目的地集合进行列表显示
热门目的地一
因为现在写的是前端页面的数据,所以需要在 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、旅游攻略
攻略对象分析
一般表数据量比较少的时候才需要单独 序号 列来进行排序
导入数据库数据:在文件所在目录下执行以下操作 mongo localhost/wolf2w luowowoDemo.js
攻略分类(strategy_catalog)表设计 :
属性名称 | 属性类型 | 属性说明 |
---|---|---|
id | String | 主键 |
name | String | 分类名称 |
destId | String | 目的地id |
destName | String | 目的地名称 |
state | Int | 状态 |
sequence | Int | 排序 |
攻略主题(strategy_theme)表设计 :
属性名称 | 属性类型 | 属性说明 |
---|---|---|
Id | String | 主键 |
name | String | 攻略主题名字 |
state | Int | 状态 |
sequence | int | 排序 |
攻略明细(strategy_detail)表设计 :
属性名称 | 属性类型 | 属性说明 |
---|---|---|
id | String | 主键 |
destId | String | 目的地id |
destName | String | 目的地名称 |
themeId | String | 攻略主题id |
themeName | String | 攻略主题name |
catalogId | String | 分类id |
catalogName | String | 分类名称 |
title | String | 攻略标题 |
subTitle | String | 攻略副标题 |
summary | String | 摘要,文章正文的前100个字 |
coverUrl | String | 攻略封面 |
createtime | Date | 创建时间 |
isabroad | boolean | 是否国外 |
viewnum | Int | 阅读人数 |
replynum | Int | 回复人数 |
favornum | int | 收藏人数 |
sharenum | Int | 分享人数 |
thumbsupnum | int | 点赞人数 |
state | int | 状态 正常, 发布 |
content | String | 内容 |
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)
-
进入阿里云官网,搜索阿里对象存储
-
创建 Bucket
-
注意域名,即访问时文件路径
-
上传文件时如果有目录,访问文件时还需要在域名后加目录路径
java使用步骤:
-
导入依赖
<!-- 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>
-
拷贝工具类
/** * 文件上传工具 */ 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; } }
-
后端控制器方法使用
@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、分组下拉框
分析
代码实现
定义 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));
}
攻略分类
此处分类的情况和前面 分类分组下拉框的 情况 一致
此处可以使用 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) 表设计 :
属性名称 | 属性类型 | 属性说明 |
---|---|---|
Id | String | 主键 |
destid | String | 目的地id |
destName | String | 目的地名 |
userId | String | 作者id |
title | String | 游记标题 |
coverUrl | String | 游记封面 |
travelTime | Date | 旅游时间 |
perExpend | String | 人均消费 |
day | Int | 出行天数 |
person | int | 和谁旅游 |
createTime | Date | 创建时间 |
releaseTime | Date | 发布时间 |
lastUpdateTime | Data | 最后更新时间 |
isPublic | boolean | 是否公开 |
viewnum | Int | 阅读人数 |
replynum | Int | 回复人数 |
favernum | int | 收藏人数 |
sharenum | Int | 分享人数 |
thumbsupnum | int | 点赞人数 |
state | Int | 状态 |
content | String | 内容 |
CRUD准备
domain
QueryObject
repository
service
trip-mgrsite 模块的 controller
游记审核
注意审核的步骤:
- 判断是否满足审核条件
- 审核通过之后做什么
- 审核拒绝之后做什么
页面:
//发布/拒绝
$(".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)
代码
新增游记查询条件,并且需要在 静态代码块中,初始化查询条件
/**
* 游记条件
*/
@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;
}
前端游记首页
因为游记首页也是使用分页查询,且查询条件与上面一致,所以直接调用后端控制器方法,无需改动
前端游记首页添加游记
要求:
- 封面图片上传(这里简单起见没有使用插件, 可以大家可以自己实现)
- 富文本框, 这里选用的是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 支持的方式
-
贴入工具类
-
贴入上传图片的方法
目的地下拉框显示: 编辑页面需要选择目的地,所以需要在下拉框回显目的地数据
//查询目的地
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 是不行的,还需要自定义参数解析器
操作原理
自定义参数解析器
自定义参数解析器:
/**
* 用户参数解析器,
* 将请求映射方法中的 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、内容评论
评论分为 盖楼式 和 微信评论式
分析
对于评论,需要记录用户信息,以及点赞信息
对于点赞的用户,能看到点赞的特效,以及增加后的点赞数
换成其他用户之后,没有点赞的特效,但是可以看到增加后的点赞数
如何区分不同用户有不同的点赞特效?将点了赞的用户与评论绑定。点过赞的用户会留下印记给此评论
前端攻略评论
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>
<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);
}
分析
需要实现 评论的回复 ,会引用被回复评论的信息及内容(没有点赞需求)
所以 评论对象属性 里面要包含 被回复评论的对象
前端游记评论
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、数据统计
分析
攻略的数据统计
阅读数分析
阅读数+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));
}
评论数分析
评论数+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();
}
收藏分析
因为需要显示用户收藏的内容,所以建议在用户的角度,实现记号缓存
收藏数+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;
}
点赞数分析
点赞数+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 监听器中
Spring 事件监听
Spring框架中有哪些不同类型的事件 ?
Spring 提供了以下 5 种标准的事件:
-
上下文更新事件(ContextRefreshedEvent)
在调用ConfigurableApplicationContext 接口中的 refresh() 方法时被触发
是容器启动之后,所有 IOC、DI、等操作执行完之后,开始监听
-
上下文开始事件(ContextStartedEvent)
当容器调用ConfigurableApplicationContext的 Start() 方法开始/重新开始容器时触发该事件
刚开始启动,容器准备创建的时候,开始监听
-
上下文停止事件(ContextStoppedEvent)
当容器调用 ConfigurableApplicationContext 的 Stop() 方法停止容器时触发该事件
容器死掉了之后,开始监听
-
上下文关闭事件(ContextClosedEvent)
当 ApplicationContext 被关闭时触发该事件。容器被关闭时,其管理的所有单例Bean都被销毁
死掉之后,并且销毁关灯了之后,开始监听
-
请求处理事件(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 数据持久化
分析
spring定时器
SpringBoot 三种方式实现定时任务:
-
Timer:
这是java自带的java.util.Timer类,这个类允许你调度一个 java.util.TimerTask 任务。使用这种方式可以让你的程序按照某一个频度执行(每 3 秒执行一次),但不能在指定时间运行。一般用的较少
-
ScheduledExecutorService:
也jdk自带的一个类,是基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行,也就是说,任务是并发执行,互不影响
-
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 都是规定好的格式,可以通过 指定字符 *
实现查询所有,如图:
所以在业务实现类中,也可以使用此字符串,来进行所有 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 表设计
性名称 | 属性类型 | 属性说明 |
---|---|---|
Id | String | 主键,自增长 |
refId | String | 关联的id |
title | String | 标题 |
subTitle | String | 副标题 |
coverUrl | String | 封面 |
state | Int | 状态 |
sequence | int | 序号 |
type | int | 约定关联的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 模块
需要注意一个问题
注意: 因为当添加数据的时候,如果没有对应的索引存在,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
分析:
问题
解决 netty 冲突
因为使用的 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
作用
- api一定需要开发文档配合,移动端只需要根据开发文档进行开发即可;
- 传统的开发文档问题:格式随意,更新不及时;
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>私密接口:需要登录访问或者公司内部接口
接口安全要求:
-
防伪装攻击(案例:在公共网络环境中,第三方 有意或恶意 的调用我们的接口):接口防刷
-
防篡改攻击(案例:在公共网络环境中,请求头/查询字符串/内容 在传输过程被修改):接口防篡改(签名机制)
-
防重放攻击(案例:在公共网络环境中,请求被截获,稍后被重放或多次重放):接口时效性
-
防数据信息泄漏(案例:截获用户登录请求,截获到账号、密码等):接口加密(https/对称加解密)
接口防刷
分析:
思路:
-
设计一个redis临时key, 有效时间是1分钟,1分钟内只允许10访问
key> url : ip
value>访问的次数
-
设置拦截器,拦截需要防刷的接口url
-
拦截逻辑
- 拦截 url,拼接 key 查询 redis 中是否存在
- 如果不存在 setnx url:ip 10
- 如果存在 derc url:ip
- 如果次数减到0,拦截返回:请勿频繁访问
- 其他情况直接放行。
代码:
因为操作是针对接口,所以放在 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("/**");
}
//...
}
接口防篡改
签名机制
分析:
思路:
-
前端传参数时,对参数的名进行字典排序,然后按照参数名的属性对参数值进行有序拼接
比如:
参数列表: c=参数1, f=参数2, a=参数3, b=参数4
参数排名: a b c f
参数值拼接:a=参数3 & b=参数4 & c=参数1& f=参数2
-
使用 MD5 对拼接参数值串进行加密得到参数签名 sign_client
-
将所有参数与 sign 一同发送到后端
-
后端获取到所有参数, 按照同样的逻辑,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
-
对比 sign_server 跟 sign_client 2个签名是否一致, 一致表示参数没变篡改,否则提示参数被改,不合法
注意:
- 签名算法不能被泄露——js 混淆与加密
- 需要传人很多参数/大数据量参数(比如上传),不能使用这种方式——针对性处理
页面:
页面改动: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("/**");
}
//...
}
接口时效性
分析:
签名机制+有效时间
思路:
-
前端传参数时,对参数的名进行字典排序,然后按照参数名的属性对参数值进行有序拼接
比如:
参数列表: c=参数1, f=参数2, a=参数3, b=参数4 timestamp=188888
参数排名: a b c f timestamp
参数值拼接:a=参数3&数4& c=参数1& f=参数2×tamp=188888
-
使用MD5对拼接参数值串进行加密得到参数签名sign_client
-
将所有参数与sign一同发送到后端
-
后端获取到所有参数, 按照同样的逻辑,MD5加密得到sign_server
参数列表: c=参数1, f=参数2, a=参数3, b=参数4 sign_client
参数排名: a b c f
参数值拼接:a=参数3&数4& c=参数1& f=参数2×tamp=188888
-
获取timestamp与当前时间对比是否在有效时间内, 比如1分钟, 在执行下一步, 不在,提示接口访问失效
-
对比sign_server 跟sign_client 2个签名是否一致, 一致表示参数没变篡改,否则提示参数被改,不合法
接口加密
https 分析:
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>具有较高的优先级
主从复制
-
主节点主写
当在主节点执行一个DML操作时,主节点会对数据的操作过程做记录, 这些记录称为oplog
oplog详解: https://www.cnblogs.com/Joans/p/7723554.html
-
从节点主读
从节点通过在主节点上打开一个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;
}