1. 阿里云OSS和百度人脸识别
1.1 需求分析
在登录判断的时候,我们需要根据用户的手机号查询用户是否为新用户,如果为新用户,那么在登录完成后需要跳转到完善用户信息界面,用户需要设置性别,头像和昵称等信息。具体的流程图如下:
客户端检测首次登录需要完善用户信息
- 填写用户基本信息
- 上传用户头像(需要人脸认证)
此外还需要考虑将用户头像保存的位置,目前主流的解决方案有三种:
- 直接将图片保存到服务器的硬盘中
- 优点:开发便捷,成本低
- 缺点:扩容困难,且数据的安全性不能保证
- 使用分布式的文件存储系统
- 优点:方便扩容,且数据安全性高
- 缺点:开发成本高
- 第三方存储服务
- 优点:开发简单,不需要维护,数据安全有保证
- 缺点:付费
综合以上考虑,本项目中采用阿里云的第三方存储服务,具体的架构如下:
1.2 阿里云OSS的使用
阿里云OSS服务需要注册和购买套餐,这里就不在演示。直接来带在Java中如何使用。我们从官网上得到了实例代码,首先先在项目中建立一个测试类进行测试。
@Test
public void testOss() throws FileNotFoundException {
//1、配置图片路径
String path = "C:\\Users\\李宇轩\\Pictures\\unsplash--BZc9Ee1qo0.png";
//2、构造FileInputStream
FileInputStream inputStream = new FileInputStream(new File(path));
//3、拼写图片路径
String filename = new SimpleDateFormat("yyyy/MM/dd").format(new Date())
+"/"+ UUID.randomUUID().toString() + path.substring(path.lastIndexOf("."));
// yourEndpoint填写Bucket所在地域对应的Endpoint。以华东1(杭州)为例,Endpoint填写为https://oss-cn-hangzhou.aliyuncs.com。
String endpoint = "oss-cn-qingdao.aliyuncs.com";
// 阿里云主账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建RAM账号。
String accessKeyId = "dsadasdsadsacodc";
String accessKeySecret = "8L3khmdsfewfw08s3ua3Wr8rMhjf";
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId,accessKeySecret);
// 填写Byte数组。
// 填写Bucket名称和Object完整路径。Object完整路径中不能包含Bucket名称。
ossClient.putObject("tanhuaossservice", filename, inputStream);
// 关闭OSSClient。
ossClient.shutdown();
String url = "https://tanhuaossservice.oss-cn-qingdao.aliyuncs.com/" + filename;
System.out.println(url);
}
阿里云文件上传后,需要我们拼接一个图片的URL地址,以后我们如果想要获取图像,那么只需要访问这个URL即可。
1.3 封装阿里云OSS服务的自动装配类
和之前发送短信验证码一样,我们需要将这个方法封装成一个可以自动装配的类,以后我们在其他工程中调用,只需要从IOC容器中即可获取。
创建OSSProperties
@Data
@ConfigurationProperties(prefix = "tanhua.oss")
public class OssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucket;
private String url;
}
在配置文件中编写相关的配置信息
oss:
endpoint: oss-cn-qingdao.aliyuncs.com
accessKeyId: LTAI5t8eyt6zxHZuE4wqewQTcodc
accessKeySecret: 8L3khm40RRbhZGn0dddww8s3ua3Wr8rMhjf
bucket: tanhuddsadasaossservice
url: https://tanhuaossservice.oss-cn-qingdao.aliyuncs.com/
创建OSSTemplate
public class OssTemplate {
private OssProperties ossProperties;
public OssTemplate(OssProperties ossProperties) {
this.ossProperties = ossProperties;
}
public String uploadFile(String path, InputStream inputStream) {
//3、拼写图片路径
String filename = new SimpleDateFormat("yyyy/MM/dd").format(new Date())
+ "/" + UUID.randomUUID().toString() + path.substring(path.lastIndexOf("."));
// yourEndpoint填写Bucket所在地域对应的Endpoint。以华东1(杭州)为例,Endpoint填写为https://oss-cn-hangzhou.aliyuncs.com。
String endpoint = "oss-cn-qingdao.aliyuncs.com";
// 阿里云主账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建RAM账号。
String accessKeyId = "LTAI5t8eyt6zxHZuE4QTcodc";
String accessKeySecret = "8L3khm40RRbhZGn08s3ua3Wr8rMhjf";
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 填写Byte数组。
// 填写Bucket名称和Object完整路径。Object完整路径中不能包含Bucket名称。
ossClient.putObject("tanhuaossservice", filename, inputStream);
// 关闭OSSClient。
ossClient.shutdown();
return "https://tanhuaossservice.oss-cn-qingdao.aliyuncs.com/" + filename;
}
}
在自动装配类中注册OSSTemplate
@Bean
public OssTemplate ossTemplate(OssProperties ossProperties) {
return new OssTemplate(ossProperties);
}
编写测试类测试
@Test
public void testTemplateUpload() throws IOException {
String path = "C:\\Users\\李宇轩\\Desktop\\1.jpg";
InputStream inputStream = Files.newInputStream(new File(path).toPath());
String url = ossTemplate.uploadFile(path, inputStream);
System.out.println(url);
}
1.4 百度人脸识别
探花交友APP定位社交场景,因此我们要求用户上传的头像必须是人像才可以,否则会提示用户头像非法。为了实现这个功能,我们借助百度的人脸识别API。
登录官网完成相关注册后,我们编写一个测试类来测试
@Test
public void testFace() {
//设置APPID/AK/SK
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
// 初始化一个AipFace
AipFace client = new AipFace(APP_ID, API_KEY, SECRET_KEY);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
// 调用接口
String image = "https://tanhuaossservice.oss-cn-qingdao.aliyuncs.com/2022/12/21/9dd44f73-1570-4706-afe3-20cb89d81aa6.png";
String imageType = "URL";
// 人脸检测
JSONObject res = client.detect(image, imageType, options);
System.out.println(res.toString(2));
String error_code = res.get("error_code").toString();
System.out.println(error_code);
}
1.5 封装人脸识别自动装配类
创建AipFaceProperties
@Data
@ConfigurationProperties(prefix = "tanhua.face")
public class AipFaceProperties {
private String appId;
private String apiKey;
private String secretKey;
}
编写相关配置
face:
appId: 2932df58
apiKey: ptSnMDA2321sbiMidBZ4we
secretKey: lAaU81AxY9BxrewrwP1ql4XdSGe2MF8ND0YK5
编写AipFaceTemplate
public boolean faceCheck(String imageUrl) {
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
// 初始化一个AipFace
AipFace client = new AipFace(aipFaceProperties.getAppId(), aipFaceProperties.getApiKey(), aipFaceProperties.getSecretKey());
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
// 调用接口
String image = "https://tanhuaossservice.oss-cn-qingdao.aliyuncs.com/2022/12/21/9dd44f73-1570-4706-afe3-20cb89d81aa6.png";
String imageType = "URL";
// 人脸检测
JSONObject res = client.detect(image, imageType, options);
return "0".equals(res.get("error_code").toString()) ? true : false;
}
编写测试类
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class FaceTest {
@Autowired
private AipFaceTemplate template;
@Test
public void detectFace() {
String image = "https://tanhua001.oss-cn-beijing.aliyuncs.com/2021/04/19/a3824a45-70e3-4655-8106-a1e1be009a5e.jpg";
boolean detect = template.detect(image);
}
}
2. 完善用户信息
2.1 需求分析
2.1.1 流程分析
完善用户信息的流程
- 首先用户需要选择性别和昵称以及出生年月。完成后会向UserInfo表中写入相关数据。
- 接下来跳转到设置头像界面,用户从手机中选择一张图像,服务端接收到图像,保存到阿里云,并且调用百度人脸识别,判断头像是否合法。如果合法,则将头像的url保存到UserInfo表中的avatar字段中。
2.1.2 接口分析
用户完善信息的流程上面已经分析过了,这里就不在赘述。下面是完善用户信息的接口
2.1.3 数据库分析
- 用户表和用户信息表是一对一的关系,两者采用主键关联的形式配置
- 主键关联:用户表主键和用户资料表主键要保持一致(如:用户表id=1,此用户的资料表id=1)
2.2 代码实现
编写UserInfo类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo implements Serializable {
/**
* 由于userinfo表和user表之间是一对一关系
* userInfo的id来源于user表的id
*/
@TableId(type = IdType.INPUT)
private Long id; //用户id
private String nickname; //昵称
private String avatar; //用户头像
private String birthday; //生日
private String gender; //性别
private Integer age; //年龄
private String city; //城市
private String income; //收入
private String education; //学历
private String profession; //行业
private Integer marriage; //婚姻状态
private String tags; //用户标签:多个用逗号分隔
private String coverPic; // 封面图片
private Date created;
private Date updated;
//用户状态,1为正常,2为冻结
@TableField(exist = false)
private String userStatus = "1";
}
编写Mapper UserInfoApi UserInfoApiImpl
package com.tanhua.dubbo.mappers;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tanhua.model.domain.UserInfo;
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
public interface UserInfoApi {
/**
* 保存用户详细信息
* @param userInfo
*/
void save(UserInfo userInfo);
/**
* 更新用户详细信息
* @param userInfo
*/
void update(UserInfo userInfo);
UserInfo getUserInfoById(Long userID);
}
@DubboService
public class UserInfoApiImpl implements UserInfoApi {
@Resource
private UserInfoMapper userInfoMapper;
@Override
public void save(UserInfo userInfo) {
this.userInfoMapper.insert(userInfo);
}
@Override
public void update(UserInfo userInfo) {
this.userInfoMapper.updateById(userInfo);
}
@Override
public UserInfo getUserInfoById(Long userID) {
return this.userInfoMapper.selectById(userID);
}
}
编写Controller
/**
* 新用户登录后完善个人信息
* @param userInfo
* @param token
* @return
*/
@PostMapping("/loginReginfo")
public ResponseEntity loginReginfo(@RequestBody UserInfo userInfo) {
// 3. 将ID设置到userInfo中
userInfo.setId(UserHolder.getUserId());
// 4. 调用service方法保存数据
this.userInfoService.save(userInfo);
// 5. 返回结果
return ResponseEntity.ok(null);
}
/**
* 完善个人信息之上传头像
* @param headPhoto
* @return
*/
@PostMapping("/loginReginfo/head")
public ResponseEntity head(MultipartFile headPhoto) {
// 3. 调用service将头像上传并进行人像判断
this.userInfoService.uploadAvatar(UserHolder.getUserId(), headPhoto);
// 4. 返回结果
return ResponseEntity.ok(null);
}
编写Service中的方法
public void uploadAvatar(Long id, MultipartFile headPhoto) {
String imageUrl = this.verifyFace(headPhoto);
// 3. 更新用户信息
UserInfo userInfo = new UserInfo();
userInfo.setId(id);
userInfo.setAvatar(imageUrl);
this.userInfoApi.update(userInfo);
}
public UserInfoVo getUserInfoById(Long userID) {
UserInfo userInfo = this.userInfoApi.getUserInfoById(userID);
UserInfoVo userInfoVo = new UserInfoVo();
BeanUtils.copyProperties(userInfo, userInfoVo); // 只会拷贝名字相同且类型相同的属性
userInfoVo.setAge(userInfo.getAge().toString());
return userInfoVo;
}
3. 用户信息管理
3.1 查询用户详细资料
3.1.1 需求分析
用户在APP界面中点击我的,然后点击详细信息,可以看到自己账户的详细信息。
查询用户的接口如下
需要注意的是:如果请求中不带UserId参数,那么就标明用户查询的是当前自己的详细信息。如果携带参数,则查询指定id的用户详细信息。
3.1.2 代码实现
编写Controller
/**
* 根据id查询用户的详细信息
* @param userID
* @param token
* @return
*/
@GetMapping
public ResponseEntity getUserInfoById(Long userID, @RequestHeader("Authorization") String token) {
// 3. 判断ID是否为空
if (userID == null) {
// 查询当前用户的资料
userID = UserHolder.getUserId();
}
// 4. 调用service方法查询用户信息
UserInfoVo userInfoVo = this.userInfoService.getUserInfoById(userID);
return ResponseEntity.ok(userInfoVo);
}
编写Service
public UserInfoVo getUserInfoById(Long userID) {
UserInfo userInfo = this.userInfoApi.getUserInfoById(userID);
UserInfoVo userInfoVo = new UserInfoVo();
BeanUtils.copyProperties(userInfo, userInfoVo); // 只会拷贝名字相同且类型相同的属性
userInfoVo.setAge(userInfo.getAge().toString());
return userInfoVo;
}
注意 这里用到了VO,是因为前端接收的年龄信息是String类型的,而UserInfo中年龄是int类型,为了保证数据类型的一致性使用了Vo。
下面是一些常用的用来传递数据的实体类:
VO 值对象:通常用于服务端和界面之间的数据传递。VO对象通常是后端给前端传递数据
DTO对象:数据传输对象。DTO对象通常是用来封装前端传递的参数给后端
Entity:我们最常见的实体类。通常情况下一个表对应一个实体类
在UserInfoApi中添加根据ID查询用户详细信息方法
@Override
public UserInfo getUserInfoById(Long userID) {
return this.userInfoMapper.selectById(userID);
}
3.2 更新用户详细资料
3.2.1 需求分析
用户可以在自己的用户详细信息界面更新自己的个人资料。其接口如下。
3.2.2 代码实现
在Controller中添加一个方法用来更新
/**
* 用户在我的界面中修改自己的个人信息
* @param userInfo
* @param token
* @return
*/
@PutMapping
public ResponseEntity updateUserInfo(@RequestBody UserInfo userInfo, @RequestHeader("Authorization") String token) {
// 3. 设置userId
userInfo.setId(UserHolder.getUserId());
// 4. 调用service方法 更新用户信息
this.userInfoService.updateUserInfo(userInfo);
// 5. 返回响应信息
return ResponseEntity.ok(null);
}
在Service中编写更新用户信息的方法
public void updateUserInfo(UserInfo userInfo) {
this.userInfoApi.update(userInfo);
}
userInfoApi
中的update
方法之前在编写上传用户头像的时候就已经完成
3.3 更新头像
3.3.1 需求分析
用户可以在个人信息页面修改自己的头像。修改头像的流程和新用户注册上传头像一致。
接口如下:
3.3.2 代码实现
在Controller中添加更新头像的方法
/**
* 更新头像
* @param headPhoto
* @return
*/
@PostMapping("/header")
public ResponseEntity updateHeader(MultipartFile headPhoto) {
this.userInfoService.updateHeader(headPhoto);
return ResponseEntity.ok(null);
}
在Service中添加更新头像方法
/**
* 更新用户头像
*
* @param headPhoto
*/
public void updateHeader(MultipartFile headPhoto) {
// 1. 验证头像是否合法
String imageUrl = this.verifyFace(headPhoto);
// 2. 将信息保存到UserInfo表中
UserInfo userInfo = new UserInfo();
userInfo.setId(UserHolder.getUserId());
userInfo.setAvatar(imageUrl);
this.userInfoApi.update(userInfo);
}
由于在上传头像和更新头像中都需要验证头像是否合法,因此抽取成一个独立的方法
/**
* 验证人脸是否合法
*
* @param headPhoto
* @return
*/
private String verifyFace(MultipartFile headPhoto) {
// 1. 将文件上传到阿里云OSS
String imageUrl = null;
try {
imageUrl = ossTemplate.uploadFile(headPhoto.getOriginalFilename(), headPhoto.getInputStream());
} catch (IOException e) {
throw new BusinessException(ErrorResult.error());
}
// 2. 调用百度人脸识别判断是否是人脸,如果不是抛出异常
boolean check = aipFaceTemplate.faceCheck(imageUrl);
if (false) {
throw new BusinessException(ErrorResult.faceError());
}
return imageUrl;
}
4. 代码优化
4.1 统一用户登陆状态验证
4.1.1 存在的问题
在之前的代码中,我们每次都需要从Controller中获取到用户登录的Token,然后验证Token是否合法,如果不合法则返回异常;如果合法则从token中解析中用户的信息。每一个Controller都需要这样做导致代码冗余,因此我们需要借助拦截器的方式来对我们的代码进行优化。
我们的解决方案是
- 使用SpringMVC中的拦截器来对所有请求进行拦截,在拦截器中判断用户的登录信息
- 我们在拦截器中对用户的token进行解析,并将解析到的信息存储到ThreadLocal
4.1.2 代码实现
创建UserHolder用来保存登录用户的信息
public class UserHolder {
private static ThreadLocal<User> threadLocal = new ThreadLocal<>();
/**
* 将用户保存到threadLocal
* @param user
*/
public static void setUser(User user) {
threadLocal.set(user);
}
/**
* 从threadLocal获取用户
* @return
*/
public static User getUser() {
return threadLocal.get();
}
public static Long getUserId() {
return threadLocal.get().getId();
}
public static String getUserPhone() {
return threadLocal.get().getMobile();
}
/**
* 请求执行完毕后将用户信息用threadLocal中删除
*/
public static void remove() {
threadLocal.remove();
}
}
创建TokenInterceptor,完成用户登录信息验证和token解析
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取token
String token = request.getHeader("Authorization");
// 2. 判断token是否合法 如果不合法 返回401错误
boolean flag = JwtUtils.verifyToken(token);
if (!flag) {
// token不合法
response.setStatus(401);
return false;
}
// 3. 解析token,将用户信息保存到threadLocal
Claims claims = JwtUtils.getClaims(token);
Integer id = (Integer) claims.get("id");
String mobile = claims.get("mobile").toString();
User user = new User();
user.setId(Long.valueOf(id));
user.setMobile(mobile);
UserHolder.setUser(user);
// 4. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.remove();
}
}
需要注意的是,随着登录用户越来越多,ThreadLocal也会占用大量的内存空间,因此最好当一个请求执行完毕后,就释放掉ThreadLocal中的内容。如
afterCompletion
方法中所示
编写SpringMVC配置类,配置拦截器的相关属性
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TokenInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/user/login", "/user/loginVerification");
}
}
4.2 统一异常处理
4.2.1 存在的问题
在开发过程中,不可避免的要处理很多的异常,常见的异常处理形式是向上抛出,在Controller层进行处理。但是这样的处理方式导致异常处理代码和业务逻辑代码混在一起,降低代码的可读性。
为了解决这个问题,我们利用SpringMVC中的统一异常处理机制
所有的异常都进行抛出,SpringMVC会在核心控制器中进行捕获,并调用我们编写的异常处理器进行处理。
4.2.2 代码实现
编写一个类将一些常见的业务错误封装到ErrorResult对象中
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResult {
private String errCode = "999999";
private String errMessage;
public static ErrorResult error() {
return ErrorResult.builder().errCode("999999").errMessage("系统异常稍后再试").build();
}
public static ErrorResult fail() {
return ErrorResult.builder().errCode("000001").errMessage("发送验证码失败").build();
}
public static ErrorResult loginError() {
return ErrorResult.builder().errCode("000002").errMessage("验证码失效").build();
}
public static ErrorResult faceError() {
return ErrorResult.builder().errCode("000003").errMessage("图片非人像,请重新上传!").build();
}
public static ErrorResult mobileError() {
return ErrorResult.builder().errCode("000004").errMessage("手机号码已注册").build();
}
public static ErrorResult contentError() {
return ErrorResult.builder().errCode("000005").errMessage("动态内容为空").build();
}
public static ErrorResult likeError() {
return ErrorResult.builder().errCode("000006").errMessage("用户已点赞").build();
}
public static ErrorResult disLikeError() {
return ErrorResult.builder().errCode("000007").errMessage("用户未点赞").build();
}
public static ErrorResult loveError() {
return ErrorResult.builder().errCode("000008").errMessage("用户已喜欢").build();
}
public static ErrorResult disloveError() {
return ErrorResult.builder().errCode("000009").errMessage("用户未喜欢").build();
}
}
编写我们自定义的业务异常
/**
* 自定义异常类
*/
@Data
public class BusinessException extends RuntimeException {
private ErrorResult errorResult;
public BusinessException(ErrorResult errorResult) {
super(errorResult.getErrMessage());
this.errorResult = errorResult;
}
}
编写全局异常处理类
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务逻辑异常
* @param businessException
* @return
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity handleBusinessException(BusinessException businessException) {
businessException.printStackTrace();
ErrorResult result = businessException.getResult();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleOtherExceptions(Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResult.error());
}
}
修改原来的抛出异常的代码
if (StringUtils.isEmpty(redisCode) || !redisCode.equals(code)) {
// 验证码无效或者验证码错误
throw new BusinessException(ErrorResult.loginError());
}
if (false) {
throw new BusinessException(ErrorResult.faceError());
}