需求分析
1,登录采取弹出层的形式
2,登录方式:
(1)手机号码+手机验证码
(2)微信扫描
3,无注册界面,第一次登录根据手机号判断系统是否存在,如果不存在则自动注册
4,微信扫描登录成功必须绑定手机号码,即:第一次扫描成功后绑定手机号,以后登录扫描直接登录成功
5,网关统一判断登录状态,如何需要登录,页面弹出登录层
搭建环境
创建模块 service-user
配置文件
# 服务端口
server.port=8150
# 服务名
spring.application.name=service-user
# 环境设置dev,test,prod
spring.profiles.active=dev
# mysql数据库连接
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/yygh_user?characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=sugon666
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database= 0
spring.redis.timeout=1800000
#返回json的全局时间格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
# nacos服务地址
spring.cloud.nacos.discovery.server-addr=localhost:8848
# 配置mapper xml文件的路径
#mybatis-plus.mapper-locations=classpath:com/atguigu/yygh/user/mapper/xml/*.xml
配置网关
# 设置路由 id
spring.cloud.gateway.routes[2].id=service-user
# 设置路由uri
spring.cloud.gateway.routes[2].uri=lb://service-user
# 设置路由断言,代理servicerId为auth-service的/user/路径
spring.cloud.gateway.routes[2].predicates=Path=/*/user/**
主要创建下 UserInfo 相关的 类,这里不赘述
手机登录基本实现
// 用户手机号登录接口
@PostMapping("login")
@ApiOperation("用户手机号登录接口")
public Result login(@RequestBody LoginVo loginVo) {
Map<String, Object> info = userInfoService.loginUser(loginVo);
return Result.ok(info);
}
这块比较常规,用的 token + jwt
// 手机号登录
public Map<String, Object> loginUser(LoginVo loginVo) {
// 从 loginVo 获取到 输入的手机号和验证码
String phone = loginVo.getPhone();
String code = loginVo.getCode();
// 非空判断
if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(code)) {
throw new HospitalException(ResultCodeEnum.PARAM_ERROR);
}
// TODO 判断手机验证码和输入的验证码是否一致
//String redisCode = redisTemplate.opsForValue().get(phone);
//if (!code.equals(redisCode)) {
// throw new HospitalException(ResultCodeEnum.CODE_ERROR);
//}
// 判断是否第一次登录:查询数据库
QueryWrapper<UserInfo> wrapper = new QueryWrapper<>();
wrapper.eq("phone", phone);
UserInfo userInfo = baseMapper.selectOne(wrapper);
if (userInfo == null) {
// 添加信息到数据库
userInfo = new UserInfo();
userInfo.setName("");
userInfo.setPhone(phone);
userInfo.setStatus(1);
baseMapper.insert(userInfo);
}
// 不是第一次,直接登录
//校验是否被禁用
if (userInfo.getStatus() == 0) {
throw new HospitalException(ResultCodeEnum.LOGIN_DISABLED_ERROR);
}
// 返回登录信息
Map<String, Object> map = new HashMap<>();
// 返回登录用户名称
String name = userInfo.getName();
if (StringUtils.isEmpty(name)) {
name = userInfo.getNickName();
}
if (StringUtils.isEmpty(name)) {
name = userInfo.getPhone();
}
map.put("name", name);
// TODO 生成 jwt 的 token
// 返回 token 信息
map.put("token", JwtHelper.createToken(userInfo.getId(), name));
return map;
}
jwt 集成
依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
工具类
public class JwtHelper {
private static long tokenExpiration = 24 * 60 * 60 * 1000; //token1过期时间设置 单位:毫秒
private static String tokenSignKey = "123456"; //签名秘钥
//根据参数生成token
public static String createToken(Long userId, String userName) {
String token = Jwts.builder()
.setSubject("YYGH-USER")
.setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
.claim("userId", userId)
.claim("userName", userName)
.signWith(SignatureAlgorithm.HS512, tokenSignKey)
.compressWith(CompressionCodecs.GZIP)
.compact();
return token;
}
//根据token字符串得到用户id
public static Long getUserId(String token) {
if (StringUtils.isEmpty(token)) return null;
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Claims claims = claimsJws.getBody();
Integer userId = (Integer) claims.get("userId");
return userId.longValue();
}
//根据token字符串得到用户名称
public static String getUserName(String token) {
if (StringUtils.isEmpty(token)) return "";
Jws<Claims> claimsJws
= Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Claims claims = claimsJws.getBody();
return (String) claims.get("userName");
}
public static void main(String[] args) {
String token = JwtHelper.createToken(1L, "lucy");
System.out.println(token);
System.out.println(JwtHelper.getUserId(token));
System.out.println(JwtHelper.getUserName(token));
}
}
调用
map.put("token", JwtHelper.createToken(userInfo.getId(), name));
Swagger 测试,输入一个不存在的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rZHpTvhv-1669282098210)(https://yixinglian.oss-cn-hangzhou.aliyuncs.com/blogimage-20221122140725584.png)]
看到已经写入 user_info 表了
整合 阿里云短信服务
首先创建个msm的模块
我用的是这个测试,点进来
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>alibabacloud-dysmsapi20170525</artifactId>
<version>2.0.22</version>
</dependency>
我用的最新版本的异步的sdk
aliyun.sms.regionId=default
aliyun.sms.accessKeyId=xxxx
aliyun.sms.secret=xxx
把配置信息读取到 配置类
@Component
public class ConstantPropertiesUtils implements InitializingBean {
@Value("${aliyun.sms.regionId}")
private String regionId;
@Value("${aliyun.sms.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.sms.secret}")
private String secret;
public static String REGION_Id;
public static String ACCESS_KEY_ID;
public static String SECRECT;
@Override
public void afterPropertiesSet() throws Exception {
REGION_Id=regionId;
ACCESS_KEY_ID=accessKeyId;
SECRECT=secret;
}
}
controller 发送验证码接口实现
// 发送手机验证码
@GetMapping("send/{phone}")
public Result sendCode(@PathVariable String phone) throws ExecutionException, InterruptedException {
//从redis获取验证码,如果获取获取到,返回ok
// key 手机号 value 验证码
String code = redisTemplate.opsForValue().get(phone);
if (!StringUtils.isEmpty(code)) {
return Result.ok();
}
// 如果从redis获取不到
// 生成验证码
code = RandomUtil.getSixBitRandom();
redisTemplate.opsForValue().set(phone, code);
//调用service方法,通过整合短信服务进行发送
boolean isSend = msmService.send(phone, code);
//生成验证码放到redis里面,设置有效时间
if (isSend) {
redisTemplate.opsForValue().set(phone, code, 2, TimeUnit.MINUTES);
return Result.ok();
} else {
return Result.fail().message("发送短信失败");
}
}
@Override
public boolean send(String phone, String code) {
DefaultProfile profile = DefaultProfile.getProfile(ConstantPropertiesUtils.REGION_Id,
ConstantPropertiesUtils.ACCESS_KEY_ID, ConstantPropertiesUtils.SECRECT);
IAcsClient client = new DefaultAcsClient(profile);
SendSmsRequest request = new SendSmsRequest();
request.setSignName("阿里云短信测试");
request.setTemplateCode("SMS_154950909");
request.setPhoneNumbers(phone);
Map<String, String> param = new HashMap<>();
param.put("code", code);
request.setTemplateParam(JSONObject.toJSONString(param));
try {
SendSmsResponse response = client.getAcsResponse(request);
System.out.println(response.getCode());
System.out.println(new Gson().toJson(response));
return response.getCode().equals("OK");
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
这里给出我的实现,注意点
- 设置两分钟验证码的过期时间,如果已经发送过直接返回 true,否则把验证码存储到 redis ,并且发送验证码
我们放开前面登录的注释
String redisCode = redisTemplate.opsForValue().get(phone);
if (!code.equals(redisCode)) {
throw new HospitalException(ResultCodeEnum.CODE_ERROR);
}
前端整合
前端两个api 文件
import request from '@/utils/request'
const api_name = `/api/user`
export default {
//手机登录接口
login(userInfo) {
return request({
url: `${api_name}/login`,
method: `post`,
data: userInfo
})
}
}
import request from '@/utils/request'
const api_name = `/api/msm`
export default {
sendCode(mobile) {
return request({
url: `${api_name}/send/${mobile}`,
method: `get`
})
}
}
myHeader
<template>
<div class="header-container">
<div class="wrapper">
<!-- logo -->
<div class="left-wrapper v-link selected">
<img
style="width: 50px"
width="50"
height="50"
src="~assets/images/logo.png"
/>
<span class="text">尚医通 预约挂号统一平台</span>
</div>
<!-- 搜索框 -->
<div class="search-wrapper">
<div class="hospital-search animation-show">
<el-autocomplete
class="search-input small"
prefix-icon="el-icon-search"
v-model="state"
:fetch-suggestions="querySearchAsync"
placeholder="点击输入医院名称"
@select="handleSelect"
>
<span
slot="suffix"
class="search-btn v-link highlight clickable selected"
>搜索
</span>
</el-autocomplete>
</div>
</div>
<!-- 右侧 -->
<div class="right-wrapper">
<span class="v-link clickable">帮助中心</span>
<span
v-if="name == ''"
class="v-link clickable"
@click="showLogin()"
id="loginDialog"
>登录/注册</span
>
<el-dropdown v-if="name != ''" @command="loginMenu">
<span class="el-dropdown-link">
{{ name }}<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu class="user-name-wrapper" slot="dropdown">
<el-dropdown-item command="/user">实名认证</el-dropdown-item>
<el-dropdown-item command="/order">挂号订单</el-dropdown-item>
<el-dropdown-item command="/patient">就诊人管理</el-dropdown-item>
<el-dropdown-item command="/logout" divided
>退出登录</el-dropdown-item
>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<!-- 登录弹出层 -->
<el-dialog
v-if="dialogUserFormVisible"
:visible.sync="dialogUserFormVisible"
style="text-align: left"
top="50px"
:append-to-body="true"
width="960px"
@close="closeDialog()"
>
<div class="container">
<!-- 手机登录 #start -->
<div class="operate-view" v-if="dialogAtrr.showLoginType === 'phone'">
<div class="wrapper" style="width: 100%">
<div class="mobile-wrapper" style="position: static; width: 70%">
<span class="title">{{ dialogAtrr.labelTips }}</span>
<el-form>
<el-form-item>
<el-input
v-model="dialogAtrr.inputValue"
:placeholder="dialogAtrr.placeholder"
:maxlength="dialogAtrr.maxlength"
class="input v-input"
>
<span
slot="suffix"
class="sendText v-link"
v-if="dialogAtrr.second > 0"
>{{ dialogAtrr.second }}s
</span>
<span
slot="suffix"
class="sendText v-link highlight clickable selected"
v-if="dialogAtrr.second == 0"
@click="getCodeFun()"
>重新发送
</span>
</el-input>
</el-form-item>
</el-form>
<div class="send-button v-button" @click="btnClick()">
{{ dialogAtrr.loginBtn }}
</div>
</div>
<div class="bottom">
<div class="wechat-wrapper" @click="weixinLogin()">
<span class="iconfont icon"></span>
</div>
<span class="third-text"> 第三方账号登录 </span>
</div>
</div>
</div>
<!-- 手机登录 #end -->
<!-- 微信登录 #start -->
<div class="operate-view" v-if="dialogAtrr.showLoginType === 'weixin'">
<div class="wrapper wechat" style="height: 400px">
<div>
<div id="weixinLogin"></div>
</div>
<div class="bottom wechat" style="margin-top: -80px">
<div class="phone-container">
<div class="phone-wrapper" @click="phoneLogin()">
<span class="iconfont icon"></span>
</div>
<span class="third-text"> 手机短信验证码登录 </span>
</div>
</div>
</div>
</div>
<!-- 微信登录 #end -->
<div class="info-wrapper">
<div class="code-wrapper">
<div>
<img
src="//img.114yygh.com/static/web/code_login_wechat.png"
class="code-img"
/>
<div class="code-text">
<span class="iconfont icon"></span>微信扫一扫关注
</div>
<div class="code-text">“快速预约挂号”</div>
</div>
<div class="wechat-code-wrapper">
<img
src="//img.114yygh.com/static/web/code_app.png"
class="code-img"
/>
<div class="code-text">扫一扫下载</div>
<div class="code-text">“预约挂号”APP</div>
</div>
</div>
<div class="slogan">
<div>xxxxxx官方指定平台</div>
<div>快速挂号 安全放心</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import cookie from "js-cookie";
import Vue from "vue";
import userInfoApi from "@/api/userInfo";
import smsApi from "@/api/msm";
import hospitalApi from "@/api/hosp";
import weixinApi from "@/api/weixin";
const defaultDialogAtrr = {
showLoginType: "phone", // 控制手机登录与微信登录切换
labelTips: "手机号码", // 输入框提示
inputValue: "", // 输入框绑定对象
placeholder: "请输入您的手机号", // 输入框placeholder
maxlength: 11, // 输入框长度控制
loginBtn: "获取验证码", // 登录按钮或获取验证码按钮文本
sending: true, // 是否可以发送验证码
second: -1, // 倒计时间 second>0 : 显示倒计时 second=0 :重新发送 second=-1 :什么都不显示
clearSmsTime: null, // 倒计时定时任务引用 关闭登录层清除定时任务
};
export default {
data() {
return {
userInfo: {
phone: "",
code: "",
openid: "",
},
dialogUserFormVisible: false,
// 弹出层相关属性
dialogAtrr: defaultDialogAtrr,
name: "", // 用户登录显示的名称
};
},
created() {
this.showInfo();
},
mounted() {
// 注册全局登录事件对象
window.loginEvent = new Vue();
// 监听登录事件
loginEvent.$on("loginDialogEvent", function () {
document.getElementById("loginDialog").click();
});
// 触发事件,显示登录层:loginEvent.$emit('loginDialogEvent')
//初始化微信js
const script = document.createElement("script");
script.type = "text/javascript";
script.src =
"https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js";
document.body.appendChild(script);
// 微信登录回调处理
let self = this;
window["loginCallback"] = (name, token, openid) => {
self.loginCallback(name, token, openid);
};
},
methods: {
//微信回调方法
loginCallback(name, token, openid) {
// 打开手机登录层,绑定手机号,改逻辑与手机登录一致
if(openid != '') {
this.userInfo.openid = openid
this.showLogin()
} else {
this.setCookies(name, token)
}
},
// 绑定登录或获取验证码按钮
btnClick() {
// 判断是获取验证码还是登录
if (this.dialogAtrr.loginBtn == "获取验证码") {
this.userInfo.phone = this.dialogAtrr.inputValue;
// 获取验证码
this.getCodeFun();
} else {
// 登录
this.login();
}
},
// 绑定登录,点击显示登录层
showLogin() {
this.dialogUserFormVisible = true;
// 初始化登录层相关参数
this.dialogAtrr = { ...defaultDialogAtrr };
},
// 登录
login() {
this.userInfo.code = this.dialogAtrr.inputValue;
if (this.dialogAtrr.loginBtn == "正在提交...") {
this.$message.error("重复提交");
return;
}
if (this.userInfo.code == "") {
this.$message.error("验证码必须输入");
return;
}
if (this.userInfo.code.length != 6) {
this.$message.error("验证码格式不正确");
return;
}
this.dialogAtrr.loginBtn = "正在提交...";
userInfoApi
.login(this.userInfo)
.then((response) => {
console.log(response.data);
// 登录成功 设置cookie
this.setCookies(response.data.name, response.data.token);
})
.catch((e) => {
this.dialogAtrr.loginBtn = "马上登录";
});
},
setCookies(name, token) {
cookie.set("token", token, { domain: "localhost" });
cookie.set("name", name, { domain: "localhost" });
window.location.reload();
},
// 获取验证码
getCodeFun() {
if (!/^1[34578]\d{9}$/.test(this.userInfo.phone)) {
this.$message.error("手机号码不正确");
return;
}
// 初始化验证码相关属性
this.dialogAtrr.inputValue = "";
this.dialogAtrr.placeholder = "请输入验证码";
this.dialogAtrr.maxlength = 6;
this.dialogAtrr.loginBtn = "马上登录";
// 控制重复发送
if (!this.dialogAtrr.sending) return;
// 发送短信验证码
this.timeDown();
this.dialogAtrr.sending = false;
smsApi
.sendCode(this.userInfo.phone)
.then((response) => {
this.timeDown();
})
.catch((e) => {
this.$message.error("发送失败,重新发送");
// 发送失败,回到重新获取验证码界面
this.showLogin();
});
},
// 倒计时
timeDown() {
if (this.clearSmsTime) {
clearInterval(this.clearSmsTime);
}
this.dialogAtrr.second = 60;
this.dialogAtrr.labelTips = "验证码已发送至" + this.userInfo.phone;
this.clearSmsTime = setInterval(() => {
--this.dialogAtrr.second;
if (this.dialogAtrr.second < 1) {
clearInterval(this.clearSmsTime);
this.dialogAtrr.sending = true;
this.dialogAtrr.second = 0;
}
}, 1000);
},
// 关闭登录层
closeDialog() {
if (this.clearSmsTime) {
clearInterval(this.clearSmsTime);
}
},
showInfo() {
let token = cookie.get("token");
if (token) {
this.name = cookie.get("name");
}
},
loginMenu(command) {
if ("/logout" == command) {
cookie.set("name", "", { domain: "localhost" });
cookie.set("token", "", { domain: "localhost" });
//跳转页面
window.location.href = "/";
} else {
window.location.href = command;
}
},
handleSelect(item) {
window.location.href = "/hospital/" + item.hoscode;
},
weixinLogin() {
this.dialogAtrr.showLoginType = "weixin";
//初始化微信相关参数
weixinApi.getLoginParam().then((response) => {
var obj = new WxLogin({
self_redirect: true,
id: "weixinLogin", // 需要显示的容器id
appid: response.data.appid, // 公众号appid wx*******
scope: response.data.scope, // 网页默认即可
redirect_uri: response.data.redirectUri, // 授权成功后回调的url
state: response.data.state, // 可设置为简单的随机数加session用来校验
style: "black", // 提供"black"、"white"可选。二维码的样式
href: "", // 外部css文件url,需要https
});
});
},
phoneLogin() {
this.dialogAtrr.showLoginType = "phone";
this.showLogin();
},
},
};
</script>
Cookie 记录登录成功的用户信息
npm install js-cookie
这块参考上面前端代码即可
用户登录网关整合
网关层处理登录部门校验
添加 filter
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
System.out.println("==="+path);
//内部服务接口,不允许外部访问
if(antPathMatcher.match("/**/inner/**", path)) {
ServerHttpResponse response = exchange.getResponse();
return out(response, ResultCodeEnum.PERMISSION);
}
//api接口,异步请求,校验用户必须登录
if(antPathMatcher.match("/api/**/auth/**", path)) {
Long userId = this.getUserId(request);
if(StringUtils.isEmpty(userId)) {
ServerHttpResponse response = exchange.getResponse();
return out(response, ResultCodeEnum.LOGIN_AUTH);
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
/**
* api接口鉴权失败返回数据
* @param response
* @return
*/
private Mono<Void> out(ServerHttpResponse response, ResultCodeEnum resultCodeEnum) {
Result result = Result.build(null, resultCodeEnum);
byte[] bits = JSONObject.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
//指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
/**
* 获取当前登录用户id
* @param request
* @return
*/
private Long getUserId(ServerHttpRequest request) {
String token = "";
List<String> tokenList = request.getHeaders().get("token");
if(null != tokenList) {
token = tokenList.get(0);
}
if(!StringUtils.isEmpty(token)) {
return JwtHelper.getUserId(token);
}
return null;
}
}
前端 request.js 统一处理
- 请求拦截器:处理发出请求不带 token
- 响应拦截器:处理返回值 208 (gateway 返回),跳转登录页
import axios from "axios";
import { MessageBox, Message } from "element-ui";
import cookie from "js-cookie"
// 创建axios实例
const service = axios.create({
baseURL: "http://localhost:81",
timeout: 15000, // 请求超时时间
});
// http request 拦截器
service.interceptors.request.use(
(config) => {
//判断cookie是否有token值
if (cookie.get("token")) {
//token值放到cookie里面
config.headers["token"] = cookie.get("token");
}
return config;
},
(err) => {
return Promise.reject(err);
}
);
// http response 拦截器
service.interceptors.response.use(
(response) => {
//状态码是208
if (response.data.code === 208) {
//弹出登录输入框
loginEvent.$emit("loginDialogEvent");
return;
} else {
if (response.data.code !== 200) {
Message({
message: response.data.message,
type: "error",
duration: 5 * 1000,
});
return Promise.reject(response.data);
} else {
return response.data;
}
}
},
(error) => {
return Promise.reject(error.response);
}
);
export default service;