流程
用户登录的基本流程如图所示:
设计
表单设计
根据实际需要去设计表单,为了规范化和逻辑化,可以也尝试着先将业务逻辑整理成流程图的形式,这样方便后续开发逻辑判断。
根据我的流程图,它是一定需要用户名和密码的,这也是所有用户表必备的,所以本表的设计如下,它不仅包含了登录要用的用户名和密码,还包含了常见的:昵称、手机、头像地址、状态、创建时间、更新时间、逻辑删除标志、状态这些属性,可以根据实际情况再继续扩充。
此外为了验证,还新增了一条记录,为了后续验证。
DROP TABLE IF EXISTS `t_sys_user`;
CREATE TABLE `t_sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户名',
`password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '密码',
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机',
`head_url` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像地址',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '描述',
`status` tinyint(3) NULL DEFAULT 1 COMMENT '状态(1:正常 0:停用)',
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
`is_deleted` tinyint(3) NOT NULL DEFAULT 0 COMMENT '删除标记(0:不可用 1:可用)',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `idx_username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of t_sys_user
-- ----------------------------
INSERT INTO `t_sys_user` VALUES (1, 'chengyunlai', 'e10adc3949ba59abbe56e057f20f883e', NULL, NULL, NULL, NULL, 1, '2023-04-16 00:53:16', '2023-04-16 00:55:44', 0);
SET FOREIGN_KEY_CHECKS = 1;
后端设计
后端的设计思路通常分这几步:
- 选择一个后端框架,我的技术栈是
SpringBoot
,即Java-Web。 - 根据表单设计创建对应的
bean
,也称为实体类。我会使用Lombok
简化一下get And set
,然后顺便用一下它提供的日志类sl4j
。 - 选择一个持久层框架,负责对数据表的增删改查工作。即写
Mapper
。我会用mybatis-plus
。 - 写一下对应的
service
,业务逻辑写在这。 - 写
controller
对外的API,在这里调用service
的方法封装响应体。整体采用RESTFul
风格API,并且后端只负责数据传递,即RestController
。
实体类
需要的依赖:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
配置数据源
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ail510?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=utf-8
username: root
password: root
实体类的注解分别就用到了lombok
和mybatis-plus
,不作具体解释。一个是简化get and set
,另外一个是将属性和表列名对应。
注意:你需要正确的在yml
中配置数据源。
/**
* @ClassName
* @Description
* @Author:chengyunlai
* @Date
* @Version 1.0
**/
@Data
@TableName("t_sys_user")
public class SysUser extends BaseEntity {
private static final long serialVersionUID = 1L;
@TableField("username")
private String username;
@TableField("password")
private String password;
@TableField("name")
private String name;
@TableField("phone")
private String phone;
@TableField("head_url")
private String headUrl;
@TableField("dept_id")
private Long deptId;
@TableField("post_id")
private Long postId;
@TableField("description")
private String description;
@TableField("open_id")
private String openId;
@TableField("status")
private Integer status;
}
小Tips:根据Mp的习惯,它可以做一些公共属性自动封装,所以我们可以将每个bean的公共部分抽出来,变成一个
BaseEntity
@Data
public class BaseEntity implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("create_time")
private Date createTime;
@TableField("update_time")
private Date updateTime;
@TableLogic
@TableField("is_deleted")
private Integer isDeleted;
}
Mapper
直接继承Mp
的单表操作即可。
public interface SysUserMapper extends BaseMapper<SysUser> {
}
Service
Service层需要处理逻辑,底层的增删改查都使用Mp
的即可,我们通过对现有的sql
进行整合。
思考:
后端会以一个登录类,对用户名和密码进行封装。由于用户名是唯一的,所以我们可以通过用户名去查询数据库对应的记录并返回。而Mp
是没有根据用户名去查询数据的接口,所以我们需要创建该方法。
接口如下,extends IService<SysUser>
是对操作类的固定写法。
public interface SysUserService extends IService<SysUser> {
SysUser getByUsername(String username);
}
实现类如下:
主要是用到了getOne
方法,构建了一个LambdaQuery
去封装对应的查询条件。
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
@Override
public SysUser getByUsername(String username) {
LambdaQueryWrapper<SysUser> lambdaQueryWrapper = new LambdaQueryWrapper();
lambdaQueryWrapper.eq(SysUser::getUsername,username);
return this.getOne(lambdaQueryWrapper);
}
}
Controller
我先直接带出代码,一般的API开发我就不多介绍了,而调用Service的方法也不难。这里我主要再说一下Token
的思路,以及为什么。
/**
* @ClassName
* @Description
* @Author:chengyunlai
* @Date
* @Version 1.0
**/
@RestController
@Slf4j
public class IndexController {
@Autowired
SysUserService sysUserService;
@PostMapping("/login")
@CrossOrigin(value = "http://localhost:3000")
public Result login(@RequestBody LoginVo loginUser, HttpServletResponse response){
SysUser sysUser = sysUserService.getByUsername(loginUser.getUsername());
if(null == sysUser) {
return Result.fail().message("用户不存在");
}
if(!MD5.encrypt(loginUser.getPassword()).equals(sysUser.getPassword())) {
return Result.fail().message("密码错误");
}
if(sysUser.getStatus() == 0) {
return Result.fail().message("用户被禁用");
}
Map<String, Object> map = new HashMap<>();
map.put("token", JwtHelper.createToken(sysUser.getId(), sysUser.getUsername()));
return Result.success(map).message("登录成功");
}
}
token的作用
了解什么是token,参考 JSON Web Token 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)
代码中调用了一个JwtHelper
类来生成token
。token的作用其实很简单,即验证用户身份。
后续前端发送相关请求API只需要带着这个token即可,这个token就是用户的标识。
那么token就可以简单理解为用户的名片,所以token应该具备:
- 可以拿到用户的基本信息(不能查询到私密信息,例如密码)。
- 应该需要有过期时间,判断token的有效期,会自动过期。
token的优点是,服务器不必再存储它了,减轻服务器的压力,token只在客户端存储。
缺点是:作为消息体返回以及客户端存储,安全压力上来了,毕竟有这玩意就表示是用户本身。
保障安全性这里我简单说几点:
- 网站通讯应该采取
https
协议 - token中不能存放隐私内容
- token应该被非对称算法加密,且服务器要做好保护私钥安全
- 客户端可以尝试用安全的存储方式(必应一下,我还没涉及到)
- ...(没有绝对的安全)
应用点:
- 免密登录
- 根据token操作用户相关的API操作
生成token
这里我使用一个工具包:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
工具类:
//jwt工具类
public class JwtHelper {
//天 24 小时 60 分 60秒 1000毫秒
private static long tokenExpiration = 3 * 1000;
private static String tokenSignKey = "123456";
//根据用户id和用户名称生成token字符串
public static String createToken(Long userId, String username) {
String token = Jwts.builder()
//分类
.setSubject("AUTH-USER")
//设置token有效时长
.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) {
try {
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();
} catch (Exception e) {
throw new CusException(ResultCodeEnum.FAIL.getCode(),"用户token失效");
}
}
//从生成token字符串获取用户名称
public static String getUsername(String token) {
try {
if (StringUtils.isEmpty(token)) return "";
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Claims claims = claimsJws.getBody();
return (String) claims.get("username");
} catch (Exception e) {
throw new CusException(ResultCodeEnum.FAIL.getCode(),"用户token失效");
}
}
}
通用返回类
前端后端不要打架,统一一下数据返回格式和处理格式,非常有必要。
- 后端返回的内容是json,都以响应体返回。
- 数据格式要包含一下响应状态码,了解接口请求状态。
- 提供一下提示信息。
- 最后将相应内容封装在
data
中。
一般来说类似这种格式:
{ "code":"200", "message":"操作成功", "data":"..." }
Result
/**
* @ClassName
* @Description
* @Author:chengyunlai
* @Date
* @Version 1.0
**/
@Data
public class Result<T> {
//返回码
private Integer code;
//返回消息
private String message;
//返回数据
private T data;
public Result(){}
// 返回数据
protected static <T> Result<T> build(T data) {
Result<T> result = new Result<T>();
if (data != null)
result.setData(data);
return result;
}
public static <T> Result<T> build(T body, Integer code, String message) {
Result<T> result = build(body);
result.setCode(code);
result.setMessage(message);
return result;
}
public static <T> Result<T> build(T body, ResultCodeEnum resultCodeEnum) {
Result<T> result = build(body);
result.setCode(resultCodeEnum.getCode());
result.setMessage(resultCodeEnum.getMessage());
return result;
}
public static<T> Result<T> success(){
return Result.success(null);
}
/**
* 操作成功
* @param data baseCategory1List
* @param <T>
* @return
*/
public static<T> Result<T> success(T data){
Result<T> result = build(data);
return build(data, ResultCodeEnum.SUCCESS);
}
public static<T> Result<T> fail(){
return Result.fail(null);
}
/**
* 操作失败
* @param data
* @param <T>
* @return
*/
public static<T> Result<T> fail(T data){
Result<T> result = build(data);
return build(data, ResultCodeEnum.FAIL);
}
public Result<T> message(String msg){
this.setMessage(msg);
return this;
}
public Result<T> code(Integer code){
this.setCode(code);
return this;
}
}
响应状态码
package top.chengyunlai.utils.R;
import lombok.Getter;
@Getter
public enum ResultCodeEnum {
SUCCESS(200,"成功"),
FAIL(201, "失败"),
SERVICE_ERROR(2012, "服务异常"),
DATA_ERROR(204, "数据异常"),
LOGIN_AUTH(208, "未登陆"),
PERMISSION(209, "没有权限");
private Integer code;
private String message;
private ResultCodeEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
前端设计
最后我们来构思一下前端该大致怎么做,注意这并不完善,你还需要根据实际业务进行完善,这对你自己有所提升,毕竟没有什么东西都是现成的,思想这东西计算机也好,人也好。(内涵一下GPT)
我使用的是
Nuxt
工程,很方便。大致思路是不变的,只是请求方式的工具可能不一样。
接口
我的接口还没做统一的封装,因为工程还不大,方便记录学习。看一下里面的思路跟着注解还是很容易理解的。
async function login() {
// 封装表单数据
const userJson = JSON.stringify(user.value)
// 发送请求
const {data: res, pending, error, refresh} = await useFetch(() => '/login', {
baseURL: config.public.baseUrl,
method: 'post',
body: userJson,
server: false
})
if (pending) {
// 请求还在进行中,可以显示加载中的提示
message.success('正在验证中',{
duration:1000
})
}
// 请求成功
if (res && res.value.code==200) {
message.success(res.value.message, {
duration: 5000
})
// 存储token
token.value = res.value.data.token
// 页面跳转
router.push({ path: "/room" });
} else if (res && res.value.code==201) {
message.error(res.value.message, {
duration: 5000
})
}else{
message.error("网络问题请稍后重试", {
duration: 5000
})
}
}
存储Token
我使用两个工具来实现这个存储
- pinia(用vuex也行,或者浏览器本地也行):做的事就是存储。
- pinia-plugin-persistedstate:做的事就是解决页面刷新状态丢失的问题。
安装:
yarn add -D @pinia-plugin-persistedstate/nuxt
及
yarn add @pinia/nuxt
配置:
modules:[
'@pinia-plugin-persistedstate/nuxt',
// 引入 Pinia
[
"@pinia/nuxt",
{
autoImports: [
// 自动引入 `defineStore(), storeToRefs()`
"defineStore",
"storeToRefs"
],
},
]
],
写存储:
在工程根路径/composables/auth.ts
中写入存储代码,persist: true
即持久化Token。
export const is_Login = defineStore("user", {
state: () => ({
token: null
}),
persist: true
});
免密登录的思路
已经将token存储在客户端了,所以客户端可以在需要登录的地方或者页面先加上判断,如果token存在,先验证token的合法性和时效性,如果一切正常,直接放行。如果token不存在或者token认证失败(错误或过期),则用户还是要去重新登录。
其他思路
后端验证token时如果出现token过期或者错误的情况,需要提示前端要做相应处理。包括退出登录时的举措,需要指定token为过期,或者提醒前端删除token操作。
其他补充
整体思路如果你有问题的话可以在评论区中留言。这里我推荐一下学习的途径
- Nuxt中文站 - 易懂的Web框架 Nuxt3文档,主要看配置文件、
composables
文件的说明、useFetch
的使用。 - 在 Nuxt 3 中使用 | pinia-plugin-persistedstate (prazdevs.github.io)