从0到1构建MES系统4-登录模块

223 阅读3分钟

今天我们来讲一下MES系统的登录模块。

后端

前言:为什么选择Sa-Token?

作为经历过Shiro、Spring Security等框架"折磨"的老架构师,当我发现Sa-Token这个国产轻量级权限框架时,眼前一亮:

  • API设计优雅:相比Spring Security的复杂配置,5分钟即可完成登录功能
  • 功能强大:会话管理/权限认证/单点登录/OAuth2.0一应俱全
  • 性能优异:在我的压力测试中,单机QPS可达10万+
  • 文档友好:中文文档+示例代码,对国内开发者极其友好

下面分享我在实际项目中的最佳实践。

1. 基础登录(含核心代码)

1.1 依赖引入

<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->  
<dependency>  
    <groupId>cn.dev33</groupId>  
    <artifactId>sa-token-spring-boot3-starter</artifactId>  
</dependency>  
<dependency>  
    <groupId>cn.dev33</groupId>  
    <artifactId>sa-token-redis-jackson</artifactId>  
</dependency>

1.2 登录接口实现

Controller

@RequestMapping(value = "/login", method = RequestMethod.POST)  
public Result<UserInfoVO> doLogin(@RequestBody LoginVO loginVO) {  
    sysUserService.login(loginVO);  
    UserInfoVO userInfo = sysUserService.getUserInfo();  
  
    return Result.success("登录成功", userInfo);  
}

@RequestMapping("/logout")  
public SaResult logout() {  
    StpUtil.logout();  
    return SaResult.ok();  
}

Service

public void login(LoginVO loginVO) {  
    SysUser sysUser = this.getByAccount(loginVO.getUsername());  
    if (sysUser == null) {  
        throw new RuntimeException("用户不存在");  
    } else {  
        if (!sysUser.getStatus().equals(CommonStatus.ENABLE.getValue())) {  
            throw new RuntimeException("帐号已停用");  
        }  
    }  
  
    try {  
        String submitPassword = RsaUtil.decrypt(loginVO.getPassword());  
        String userPassword = RsaUtil.decrypt(sysUser.getPassword());  
  
        // 解密密码对比是否正确  
        if (!userPassword.equals(submitPassword)) {  
            throw new AuthException("密码错误");  
        }  
    } catch (Exception e) {  
        throw new AuthException("登录失败!"+e.getMessage());  
    }  
    StpUtil.login(sysUser.getId());  
}

思考

  • 为什么选择StpUtil.login(id) 而不是直接存储用户对象?
    这符合"最小权限原则",后续可通过StpUtil.getSession() 按需获取用户信息
  • 登录性能优化:在高并发场景下,建议配合缓存使用

1.3 扩展

记住我功能

// 登录时指定超时时间(7天) 
StpUtil.login(user.getId(), "remember-me=true");

踢人下线(并发登录控制)

// 强制指定设备下线(适用于APP场景) 
StpUtil.kickout(10001, "PC"); 
// 所有设备下线 
StpUtil.logout(10001);

权限校验注解

@SaCheckLogin 
@SaCheckRole("admin") 
@SaCheckPermission("user:delete") 
public void deleteUser(Long id) { 
	// 方法自动进行权限校验 
}

1.4 配置文件

############## Sa-Token 配置 (文档: https://sa-token.cc) ##############  
sa-token:  
  # token 名称(同时也是 cookie 名称)  
  token-name: satoken  
  # token 有效期(单位:秒) 默认30天,-1 代表永久有效  
  timeout: 2592000  
  # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结  
  active-timeout: -1  
  # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)  
  is-concurrent: true  
  # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)  
  is-share: true  
  # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)  
  token-style: uuid  
  # 是否输出操作日志  
  is-log: true

前端

1. 使用zustand存储用户信息

//代码路径: /src/store/userStore.ts
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import userService, { type SignInReq } from "@/api/services/system/userService";
import { toast } from "sonner";
import type { UserInfo, UserToken } from "#/entity";
import { StorageEnum } from "#/enum";

const { VITE_APP_HOMEPAGE: HOMEPAGE } = import.meta.env;

type UserStore = {
	// 把UserInfo的属性设置为可选的
	userInfo: Partial<UserInfo>;
	userToken: UserToken;
	// 使用 actions 命名空间来存放所有的 action
	actions: {
		setUserInfo: (userInfo: UserInfo) => void;
		setUserToken: (token: UserToken) => void;
		clearUserInfoAndToken: () => void;
	};
}; 

const useUserStore = create<UserStore>()(
	persist(
		(set) => ({
				userInfo: {},
				userToken: {},
				actions: {
					setUserInfo: (userInfo) => {
						set({ userInfo });
					},
	
					setUserToken: (userToken) => {
						set({ userToken });
					},
	
					clearUserInfoAndToken() {
						set({ userInfo: {}, userToken: {} });
					},
				},
			}),
			{
				name: "userStore", // name of the item in the storage (must be unique)
				storage: createJSONStorage(() => localStorage), // (optional) by default, 'localStorage' is used
				partialize: (state) => ({
					[StorageEnum.UserInfo]: state.userInfo,
					[StorageEnum.UserToken]: state.userToken,
				}),
			},
		),
	);

	export const useUserInfo = () => useUserStore((state) => state.userInfo);
	export const useUserToken = () => useUserStore((state) => state.userToken);
	export const useUserMenu = () => useUserStore((state) => state.userInfo.menus);
	export const useUserPermissions = () => useUserStore((state) => state.userInfo.permissions);
	export const useUserActions = () => useUserStore((state) => state.actions);
	export const useSignIn = () => {
		const navigatge = useNavigate();
		const { setUserToken, setUserInfo } = useUserActions();
		// 使用useMutation创建登录请求的mutation
		const signInMutation = useMutation({
			mutationFn: userService.signin, // 指定登录请求的API函数
	});

	// 登录处理函数
	const signIn = async (data: SignInReq) => {
		try {
			// 执行登录请求
			const res = await signInMutation.mutateAsync(data);
			console.log("登录返回", res);
			const { tokenName, tokenValue } = res;
			setUserToken({ tokenName, tokenValue });
			setUserInfo(res);
			navigatge(HOMEPAGE, { replace: true });
			toast.success("登录成功!");
		} catch (err) {
			toast.error(err.message, {
				position: "top-center",
			});
		}
	};
	
	return signIn;
};

export default useUserStore;

在其他页面中就可以直接使用

//获取用户信息
useUserInfo();
//获取权限
useUserPermissions();
//获取token
useUserToken()

开源项目地址

欢迎在评论区分享你的技术选型经验,或对本文方案的改进建议!