Solon + EasyQuery + ElementPlus 实现后台管理系统之 03-登录、注销认证与异常处理
AI 摘要
登录与注销接口
登录接口
核心注意事项:
- 根据前端传递给后端的账户账号查询用户对象,用户对象为空,即账户账号不存在。
- 前端传递给后端的密码是明文密码,需要结合数据库中存储的账户盐值进行 MD5Salts 加密,加密结果与数据库中存储的密文账户密码进行判断,一致则密码正确,否则密码错误。
- 账户状态校验,数据库中存储的账户状态不为 1 时即被禁用,禁止登录。
- 各项校验通过即登录成功,保存登录日志,调用上下文对象 Context、客户端 IP 地址工具类获取 IP 信息、IP 地址。
- 由 SaToken 执行登录操作,并生成 Token 返回给前端,前端将 Token 保存下来,后续前端发送请求后端接口的时候需要在请求头上携带 Token 以验证登录状态。
- 登录成功后,SaToken 会写入 Token 信息到 Redis 数据库中。
编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),提供登录方法:
/**
* 权限认证控制器
* @visduo
*/
@Mapping("/passport")
@Controller
public class PassportController {
@Db
EasyEntityQuery easyEntityQuery;
/**
* 登录
* @visduo
*
* @param ctx 上下文对象
* @param username 账户账号
* @param password 账户密码
* @return 登录结果
*/
@Post
@Mapping("/login")
public ResponseResult login(Context ctx, String username, String password) {
// 根据账户账号查询用户对象
UsersEntity usersEntity = easyEntityQuery.queryable(UsersEntity.class)
.where(u -> {
// 条件:u.username = #{username}
u.username().eq(username);
})
.firstOrNull();
if (usersEntity == null) {
// 用户对象不存在,返回失败信息
return ResponseResult.failure("用户不存在", null);
}
// 密码校验
// 判断条件:前端传递的明文密码+盐值加密结果 ?= 数据库存储的密文密码
String md5SaltsPassword = MD5SaltsUtil.md5(password, usersEntity.getSalts());
if (!md5SaltsPassword.equals(usersEntity.getPassword())) {
// 密码错误,返回失败信息
return ResponseResult.failure("密码错误", null);
}
// 账户状态校验,status = 1正常/0禁用
if (usersEntity.getStatus() != 1) {
return ResponseResult.failure("用户被禁用", null);
}
// 全部校验通过,登录成功
// 保存登录日志
LoginlogEntity loginlogEntity = new LoginlogEntity();
loginlogEntity.setUserId(usersEntity.getId());
loginlogEntity.setIp(ctx.realIp());
// 调用客户端IP地址工具类根据IP信息获取IP地址
loginlogEntity.setAddress(ClientipUtil.parse(ctx.realIp()));
// 调用HuTool工具类获取当前时间戳
loginlogEntity.setTimestamp(DateUtil.currentSeconds());
// 插入登录日志
easyEntityQuery.insertable(loginlogEntity).executeRows();
// SaToken执行登录并生成Token
StpUtil.login(usersEntity.getId());
String token = StpUtil.getTokenValue();
// 返回结果,并将Token返回给前端
return ResponseResult.success("登录成功", token);
}
}在 Apifox 中测试:
Redis 中写入 SaToken 数据:
注销接口
核心注意事项:
- 由 SaToken 执行注销操作,注销操作执行后,Token 失效。
- 在 Apifox 中测试时,请求头 Headers 需要携带 Token 参数,参数名与在 Solon 项目配置文件(classpath:/app.yml)中配置的 Token 名字一致,参数值为登录后后端返回的 Token。
- 在 Apifox 中,需要关闭全局 Cookie 以模拟前后端分离操作。
编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),提供注销方法:
/**
* 权限认证控制器
* @visduo
*/
@Mapping("/passport")
@Controller
public class PassportController {
// ...
/**
* 注销
* @visduo
*
* @return 注销结果
*/
@Post
@Mapping("/logout")
public ResponseResult logout() {
// 注销
StpUtil.logout();
return ResponseResult.success("注销成功", null);
}
}在 Apifox 中测试:
登录状态认证
原则上,未登录的情况下不能访问其他要求登录后才能访问的后端接口(如注销接口、获取数据接口等),可以借助 SaToken 提供的路由拦截鉴权能力实现相关需求。
核心注意事项:
- 参考文档:https://solon.noear.org/article/110
- 在 SaToken 配置类中配置权限认证拦截器对象。
- 设置拦截全部路径,并排除登录接口的路径,登录接口不需要登录状态认证。
- 未登录时 SaToken 会抛出异常,根据异常类型封装不同的状态和结果返回给前端。
- 在 SaToken 的前置拦截中,可以配置允许跨域。
编写 SaToken 配置类(cn.duozai.sadmin.config.SaTokenConfig),提供权限认证拦截方法:
/**
* SaToken配置类
* @visduo
*/
@Configuration
public class SaTokenConfig {
// ...
/**
* 构建权限认证拦截器对象
* @visduo
*
* 参考文档:https://solon.noear.org/article/110
* 参考文档:https://sa-token.cc/doc.html#/use/route-check
*
* @return 权限认证拦截器对象
*/
@Bean
public SaTokenInterceptor saTokenInterceptor() {
return new SaTokenInterceptor()
// 设置拦截路径
.addInclude("/**")
// 认证函数:每次访问进入
.setAuth(r -> {
// 登录认证
SaRouter
// 设置需要登录认证的路径
.match("/**")
// 设置不需要登录认证的路径
.notMatch("/passport/login")
// 认证方式 StpUtil::checkLogin 未登录会报错
.check(StpUtil::checkLogin);
})
// 异常处理函数
.setError(e -> {
// 判断异常类型
if(e instanceof NotLoginException) {
// 错误类型是未登录NotLoginException,封装返回401状态
return new ResponseResult(401, "未登录", null);
} else if(e instanceof NotRoleException || e instanceof NotPermissionException) {
// 错误类型是无权限NotRoleException/NotPermissionException,封装返回403状态
return new ResponseResult(403, "暂无权限", null);
}
// 异常类型无法匹配,直接返回异常对象
return e;
})
// 前置函数:在路由之前调用,配置跨域
.setBeforeAuth(obj -> {
SaHolder.getResponse()
.setHeader("Access-Control-Allow-Origin", "*")
.setHeader("Access-Control-Allow-Methods", "*")
.setHeader("Access-Control-Allow-Headers", "*");
});
}
}在 Apifox 中测试:
获取登录用户信息
核心注意事项:
- 根据当前登录的用户 ID 获取用户信息实体,包含关联部门实体和关联角色实体。
- 当前登录的用户信息实体,可以存储在 SaToken 会话(Session)中,需要使用时直接从 SaToken 会话中取出。
- 在登录方法中,可以把用户信息实体连带关联实体查询出来,登录成功后存入 SaToken 会话中。由于前置操作中,SaToken 整合了 Redis,会话数据会被存入 Redis 中。
- 返回用户信息实体给前端时,需要忽略账户密码、账户盐值这两个属性,使用 Solon 的序列化插件 Snack3 中的 @ONodeAttr 注解忽略序列化属性。
编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),完善登录方法:
/**
* 权限认证控制器
* @visduo
*/
@Mapping("/passport")
@Controller
public class PassportController {
@Db
EasyEntityQuery easyEntityQuery;
/**
* 登录
* @visduo
*
* @param ctx 上下文对象
* @param username 账户账号
* @param password 账户密码
* @return 登录结果
*/
@Post
@Mapping("/login")
public ResponseResult login(Context ctx, String username, String password) {
// 根据账户账号查询用户对象
UsersEntity usersEntity = easyEntityQuery.queryable(UsersEntity.class)
.where(u -> {
// 条件:u.username = #{username}
u.username().eq(username);
})
// 关联查询部门、角色实体
.include(UsersEntityProxy::dept)
.include(UsersEntityProxy::role)
.firstOrNull();
// ...
// SaToken执行登录并生成Token
StpUtil.login(usersEntity.getId());
String token = StpUtil.getTokenValue();
// 将用户对象存储在SaToken的会话Session中
StpUtil.getSession().set("currentUser", usersEntity);
// 返回结果,并将Token返回给前端
return ResponseResult.success("登录成功", token);
}
// ...
}编写用户表实体类(cn.duozai.sadmin.repository.UsersEntity),忽略属性:
/**
* 账户密码
*/
@ONodeAttr(ignore = true) // 忽略序列化
private String password;
/**
* 账户盐值
*/
@ONodeAttr(ignore = true) // 忽略序列化
private String salts;编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),提供获取登录用户信息方法:
/**
* 权限认证控制器
* @visduo
*/
@Mapping("/passport")
@Controller
public class PassportController {
// ...
/**
* 获取登录用户信息
* @visduo
*
* @return 登录用户信息实体
*/
@Get
@Mapping("/currentUser")
public ResponseResult currentUser() {
// 获取当前登录用户对象
UsersEntity usersEntity = (UsersEntity) StpUtil.getSession().get("currentUser");
return ResponseResult.success("查询成功", usersEntity);
}
}在 Apifox 中测试:
Redis 中写入 SaToken 数据:
后端优化进阶
请求参数校验
在业务的实现过程中,尤其是对外接口开发,我们需要对请求进行大量的验证并返回错误状态码和描述。
后端做请求参数校验是保障系统稳定性、安全性、数据一致性的核心环节,前端校验仅能提升用户体验,无法替代后端校验,因为前端逻辑可被绕过(如直接调用接口、篡改请求等),后端必须作为最后一道防线。
核心注意事项:
- 参考文档:https://solon.noear.org/article/49
- 控制器上添加 @Valid 注解,以开启参数校验能力。
- 控制器方法接收的参数前,根据需要添加校验注解。
- 参数校验未通过,会抛出 ValidatorException 异常,需要自行捕获异常并进行友好返回。
编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),完善请求参数校验:
/**
* 权限认证控制器
* @visduo
*
* @Valid:请求参数校验
* 参考文档:https://solon.noear.org/article/49
*/
@Valid
@Mapping("/passport")
@Controller
public class PassportController {
@Db
EasyEntityQuery easyEntityQuery;
/**
* 登录
* @visduo
*
* @param ctx 上下文对象
* @param username 账户账号
* @param password 账户密码
* @return 登录结果
*/
@Post
@Mapping("/login")
public ResponseResult login(Context ctx,
@NotBlank(message = "账户账号不能为空") String username,
@NotBlank(message = "账户密码不能为空") String password) {
// ...
}
}在 Apifox 中测试:
统一异常处理
统一异常处理是后端开发中规范错误反馈、降低系统风险、提升可维护性的关键机制,核心是将分散在各层(如控制器、DAO 层、参数校验异常)的异常、未知的异常进行捕获、处理逻辑集中管理,避免 到处 try-catch 的混乱,同时解决异常处理不一致、错误信息泄露、排查困难等问题。
核心注意事项:
- 参考文档:https://solon.noear.org/article/765
- 使用 Solon 过滤器机制,控制异常信息渲染,避免直接抛出异常信息。
编写统一异常处理过滤器(cn.duozai.sadmin.filter.ExceptionFilter),提供统一异常处理过滤方法:
/**
* 统一异常处理过滤器
* @visduo
*
* 参考文档:https://solon.noear.org/article/765
*/
@Component
public class ExceptionFilter implements Filter {
/**
* 统一异常处理过滤
*
* @param ctx 上下文对象
* @param chain 过滤器链
* @throws Throwable 抛出异常对象
*/
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
try {
// 放行操作
chain.doFilter(ctx);
} catch (ValidatorException e){
// ValidatorException:请求参数校验异常
// 参考文档:https://solon.noear.org/article/49
// 封装返回400状态
ctx.render(new ResponseResult(400, e.getMessage(), null));
} catch (StatusException e){
// StatusException:请求状态异常
// 参考文档:https://solon.noear.org/article/871
// 封装返回对应状态
ctx.render(new ResponseResult(e.getCode(), e.getMessage(), null));
} catch (Throwable e) {
// 封装返回500状态
ctx.render(new ResponseResult(500, e.getMessage(), null));
}
}
}在 Apifox 中测试:
前端布局
根组件
App.vue 作为整个应用的根组件,只需要保留 router-view。
编写根组件(App.vue),提供视图占位符:
<template>
<router-view/>
</template>登录组件
核心注意事项:
- 编写路由配置文件,提供登录路由信息。
- 登录表单需要结合 ElementPlus 提供表单数据校验,可通过 AI 进行调整。
- 完善登录处理函数,在表单数据校验通过后发送 Axios 请求给后端接口,并判断登录结果,登录成功则跳转到后台首页。
- 发送 Axios 请求时,请求参数使用 QS 转换为表单格式字符串。
编写路由配置文件(router/index.js),提供登录路由信息:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
// 登录
path: '/login',
component: () => import('@/views/Login.vue'),
}
],
})AI 生成组件提示词:
基于ElementPlus,生成登录页面,要求如下:
1、登录表单项包含账户账号、账户密码。
2、登录表单提供登录按钮、重置按钮。
3、登录表单提交前,需要进行表单验证,验证规则为账户账号和账户密码不能为空,且账户账号长度为6-18,账户密码长度为6-18。
4、严格遵循ElementPlus布局,不需要额外添加多余的样式。编写登录组件(views/Login.vue),提供登录视图:
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
// 表单数据
const loginForm = reactive({
username: '',
password: ''
})
// 表单引用
const loginFormRef = ref()
// 表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入账户账号', trigger: 'blur' },
{ min: 6, max: 18, message: '账户账号长度应为6-18个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入账户密码', trigger: 'blur' },
{ min: 6, max: 18, message: '账户密码长度应为6-18个字符', trigger: 'blur' }
]
}
// 登录处理函数
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate((valid) => {
if (valid) {
// 这里执行实际的登录逻辑
ElMessage.success('登录成功')
console.log('登录信息:', loginForm)
} else {
ElMessage.error('请正确填写表单信息')
return false
}
})
}
// 重置表单
const resetForm = () => {
loginFormRef.value.resetFields()
}
</script>
<template>
<el-container>
<el-main>
<el-row justify="center">
<el-col :span="8">
<el-card>
<template #header>
<div class="card-header">
<span>用户登录</span>
</div>
</template>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="80px" label-position="left">
<el-form-item label="账户账号" prop="username">
<el-input v-model="loginForm.username" placeholder="请输入账户账号"/>
</el-form-item>
<el-form-item label="账户密码" prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入账户密码" show-password/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
</el-main>
</el-container>
</template>
<style scoped>
.card-header {
text-align: center;
font-size: 18px;
font-weight: bold;
}
</style>在浏览器中测试:
视图组件样式,可根据实际需求进行调整,或借助 AI 工具优化。
完善登录组件(views/Login.vue),在执行实际的登录逻辑处,发送 Axios 请求进行登录:
// 登录处理函数
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate((valid) => {
if (valid) {
// 这里执行实际的登录逻辑
// 发送Axios请求
axios.post('/passport/login', qs.stringify(loginForm)).then((res) => {
if (res.data.code === 200) {
ElMessage.success('登录成功')
// 跳转到后台首页
router.push('/admin/index')
} else {
ElMessage.error(res.data.message)
}
}).catch((error) => {
ElMessage.error(error.message)
})
} else {
ElMessage.error('请正确填写表单信息')
return false
}
})
}在浏览器中测试:
后台首页组件
核心注意事项:
- 编写路由配置文件,提供后台首页路由信息。
- 后台的路由信息,使用嵌套路由模式,核心适配布局复用 + 子页面切换的场景,侧边栏 + 顶部导航的公共布局不变,只刷新中间的内容区,同时让路由结构和页面结构、提升开发和维护效率。
- 后台首页、顶部页面显示的欢迎提示词,需要发送 Axios 请求给后端接口,获取当前登录的用户的信息,保存到数据仓库中,并在需要显示的组件处调用展示。
- 发送 Axios 请求时,需要在请求头上携带 Token,因此登录完成后,需要先将 Token 存储到 localStorage 中,再次发送请求时,从 localStorage 中取出 Token 并放入请求头上。
编写路由配置文件(router/index.js),提供后台首页路由信息:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
// 登录
path: '/login',
component: () => import('@/views/Login.vue'),
},
{
// 后台
path: '/admin',
// 使用嵌套路由
children: [
{
// 后台首页
path: 'index',
component: () => import('@/views/Index.vue'),
}
]
},
],
})AI 生成组件提示词:
基于ElementPlus,生成后台页面,要求如下:
1、左侧菜单栏,展示多级菜单列表。
2、顶部导航栏,展示欢迎您xxx,下拉框跳转到修改信息、修改密码、登录日志、操作日志、注销。
3、底部展示版权信息。
4、中间部分显示欢迎您,xxx。编写后台首页组件(views/Index.vue),提供后台首页视图:
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
// 模拟用户信息
const userInfo = ref({
name: '管理员'
})
// 模拟菜单数据
const menuData = ref([
{
id: 1,
title: '系统管理',
children: [
{
id: 11,
title: '用户管理',
path: '/user'
},
{
id: 12,
title: '角色管理',
path: '/role'
},
{
id: 13,
title: '菜单管理',
path: '/menu'
}
]
},
{
id: 2,
title: '业务管理',
children: [
{
id: 21,
title: '订单管理',
path: '/order'
},
{
id: 22,
title: '产品管理',
path: '/product'
}
]
},
{
id: 3,
title: '数据统计',
children: [
{
id: 31,
title: '销售统计',
path: '/sales'
},
{
id: 32,
title: '用户统计',
path: '/user-statistics'
}
]
}
])
</script>
<template>
<el-container class="layout-container">
<!-- 左侧菜单栏 -->
<el-aside width="200px">
<el-menu default-active="1" class="el-menu-vertical" :router="true" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b">
<el-menu-item index="0" disabled>
<span>后台管理系统</span>
</el-menu-item>
<template v-for="item in menuData" :key="item.id">
<el-sub-menu v-if="item.children && item.children.length > 0" :index="String(item.id)">
<template #title>
<span>{{ item.title }}</span>
</template>
<el-menu-item v-for="child in item.children" :key="child.id" :index="String(child.id)">
{{ child.title }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="String(item.id)">
<template #title>{{ item.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 右侧内容区 -->
<el-container>
<!-- 顶部导航栏 -->
<el-header class="layout-header">
<div class="header-right">
<el-dropdown>
<template #default>
欢迎您,{{ userInfo.name }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</template>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>修改信息</el-dropdown-item>
<el-dropdown-item>修改密码</el-dropdown-item>
<el-dropdown-item>登录日志</el-dropdown-item>
<el-dropdown-item>操作日志</el-dropdown-item>
<el-dropdown-item>注销</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主要内容区 -->
<el-main class="layout-main">
<div class="welcome-container">
<h2>欢迎您,{{ userInfo.name }}!</h2>
<p>祝您工作愉快!</p>
</div>
</el-main>
<!-- 底部版权信息 -->
<el-footer class="layout-footer">
<div class="footer-content">
<p>© 2025 后台管理系统. All Rights Reserved.</p>
</div>
</el-footer>
</el-container>
</el-container>
</template>
<style scoped>
.layout-container {
height: 100vh;
}
.el-aside {
background-color: #545c64;
}
.el-menu-vertical {
height: 100%;
border: none;
}
.layout-header {
background-color: #ffffff;
color: #333;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 20px;
}
.header-right {
display: flex;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
color: #333;
display: flex;
align-items: center;
}
.layout-main {
background-color: #f5f7fa;
padding: 20px;
}
.welcome-container {
background-color: #ffffff;
padding: 40px;
border-radius: 4px;
text-align: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.layout-footer {
background-color: #ffffff;
color: #666;
text-align: center;
padding: 20px;
border-top: 1px solid #ebeef5;
}
.footer-content p {
margin: 0;
}
</style>
在浏览器中测试:
编写登录组件(views/Login.vue),完善登录请求:
ElMessage.success('登录成功')
// 将Token存储到localStorage中
localStorage.setItem('authtoken', res.data.data)
// 跳转到后台首页
router.push('/admin/index')创建当前登录用户数据仓库(store/currentUser.js),存储当前登录的登录用户信息:
export const useCurrentUserStore = defineStore('currentUser', () => {
// 登录用户信息实体
let currentUser = reactive({})
// 设置登录用户信息实体
function setCurrentUser(currentUser) {
this.currentUser = currentUser
}
return { currentUser, setCurrentUser }
})编写后台首页组件(views/Index.vue),在组件创建完成后,发送 Axios 请求获取当前登录的用户的信息:
// 从数据仓库中获取响应式对象currentUser
let { currentUser } = storeToRefs(useCurrentUserStore)
// 发送请求,获取登录用户信息实体
axios.get('/passport/currentUser', {
// 发送请求时,添加请求头Header,传递Token
headers: {
'authtoken': localStorage.getItem('authtoken')
}
}).then((res) => {
if (res.data.code === 200) {
// 将登录用户信息实体存储到数据仓库中
useCurrentUserStore().setCurrentUser(res.data.data)
} else {
ElMessage.error(res.data.message)
}
}).catch((error) => {
ElMessage.error(error.message)
})编写后台首页组件(views/Index.vue),调用数据仓库展示当前登录的用户的信息:
欢迎您,{{ useCurrentUserStore().currentUser.realname }}在浏览器中测试:
文章中可能会存在些许错别字内容描述不完整、表述不准确、排版布局异常等问题,文章中提及的软件、依赖、框架等程序可能随其版本更新迭代而产生变化,文章中的相关代码片段、例图、文本等内容仅供参考。
如若转载,请注明出处:https://www.duox.dev/post/121.html