mirror of
https://gitee.com/xiaonuobase/snowy.git
synced 2026-10-06 06:52:58 +08:00
【底座】锁屏机制跟平台已有纰漏调整
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div :class="['app-wrapper', { 'gray-mode': grayModeOpen }]">
|
||||
<div class="app-wrapper">
|
||||
<a-watermark
|
||||
:content="loginUserWatermarkOpen && userInfo ? [userInfo.name, userInfo.account] : undefined"
|
||||
class="admin-ui-main"
|
||||
@@ -17,17 +17,16 @@
|
||||
<router-view />
|
||||
</a-watermark>
|
||||
</div>
|
||||
<lock-screen />
|
||||
<xn-lock-screen />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup name="App">
|
||||
import { onUnmounted, onMounted, nextTick, watch } from 'vue'
|
||||
import i18n from '@/locales'
|
||||
import { globalStore } from '@/store'
|
||||
import { theme } from 'ant-design-vue'
|
||||
import LockScreen from '@/layout/components/lockScreen.vue'
|
||||
import tool from '@/utils/tool'
|
||||
import { useAutoLock } from '@/hooks/useAutoLock'
|
||||
import { useGrayMode } from '@/hooks/useGrayMode'
|
||||
|
||||
const store = globalStore()
|
||||
store.initTheme()
|
||||
@@ -38,66 +37,11 @@
|
||||
const loginUserWatermarkOpen = computed(() => store.loginUserWatermarkOpen)
|
||||
// 圆角风格
|
||||
const roundedCornerStyleOpen = computed(() => store.roundedCornerStyleOpen)
|
||||
// 灰色模式
|
||||
const grayModeOpen = computed(() => store.grayModeOpen)
|
||||
// 灰色模式类名
|
||||
const GRAY_MODE_CLASS = 'gray-mode'
|
||||
|
||||
const autoLockTime = computed(() => store.autoLockTime)
|
||||
let timer = null
|
||||
|
||||
// 重置定时器
|
||||
const resetTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const token = tool.data.get('TOKEN')
|
||||
if (token && autoLockTime.value > 0 && !store.isLocked) {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
store.setIsLocked(true)
|
||||
},
|
||||
autoLockTime.value * 60 * 1000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听灰色模式,同步到 html 元素
|
||||
watch(
|
||||
grayModeOpen,
|
||||
(isEnabled) => {
|
||||
document.documentElement.classList.toggle(GRAY_MODE_CLASS, isEnabled)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove(GRAY_MODE_CLASS)
|
||||
window.removeEventListener('mousedown', resetTimer)
|
||||
window.removeEventListener('mousemove', resetTimer)
|
||||
window.removeEventListener('keydown', resetTimer)
|
||||
window.removeEventListener('touchstart', resetTimer)
|
||||
window.removeEventListener('scroll', resetTimer)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousedown', resetTimer)
|
||||
window.addEventListener('mousemove', resetTimer)
|
||||
window.addEventListener('keydown', resetTimer)
|
||||
window.addEventListener('touchstart', resetTimer)
|
||||
window.addEventListener('scroll', resetTimer)
|
||||
nextTick(() => {
|
||||
resetTimer()
|
||||
})
|
||||
})
|
||||
|
||||
// 监听自动锁屏时间变化
|
||||
watch(autoLockTime, () => {
|
||||
resetTimer()
|
||||
})
|
||||
// 无操作自动锁屏,事件监听与计时逻辑都收在 hook 内
|
||||
useAutoLock()
|
||||
// 灰色模式,类名同步与清理都收在 hook 内
|
||||
useGrayMode()
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
@@ -18,6 +18,22 @@ const request = (url, ...arg) => baseRequest(`/sys/userCenter/` + url, ...arg)
|
||||
* @date 2022-09-22 22:33:20
|
||||
*/
|
||||
export default {
|
||||
// 开启二级认证
|
||||
userOpenSafe(data) {
|
||||
return request('openSafe', data)
|
||||
},
|
||||
// 锁定屏幕
|
||||
userLock(data) {
|
||||
return request('lock', data)
|
||||
},
|
||||
// 解锁屏幕
|
||||
userUnlock(data) {
|
||||
return request('unlock', data)
|
||||
},
|
||||
// 修改自动锁屏时长
|
||||
userUpdateAutoLockTime(data) {
|
||||
return request('updateAutoLockTime', data)
|
||||
},
|
||||
// 获取图片验证码
|
||||
userGetPicCaptcha(data) {
|
||||
return request('getPicCaptcha', data, 'get')
|
||||
|
||||
+56
-78
@@ -9,121 +9,104 @@
|
||||
<div class="lock-screen-username">{{ userInfo.name }}</div>
|
||||
<div class="lock-screen-form">
|
||||
<a-input-password
|
||||
ref="passwordRef"
|
||||
v-model:value="password"
|
||||
placeholder="请输入登录密码解锁"
|
||||
size="large"
|
||||
:style="{ borderRadius: 0 }"
|
||||
:status="errorMsg ? 'error' : ''"
|
||||
:status="inputStatus"
|
||||
@keyup.enter="handleUnlock"
|
||||
@change="errorMsg = ''"
|
||||
@change="inputStatus = ''"
|
||||
>
|
||||
<template #prefix>
|
||||
<lock-outlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
<div class="lock-screen-error-msg">{{ errorMsg }}</div>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
class="lock-screen-unlock-btn"
|
||||
:loading="loading"
|
||||
:style="{ borderRadius: 0 }"
|
||||
@click="handleUnlock"
|
||||
>
|
||||
解锁
|
||||
</a-button>
|
||||
<a-button type="link" block class="lock-screen-logout-btn" @click="handleBackToLogin"> 返回登录 </a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
<script setup name="lockScreen">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { LockOutlined } from '@ant-design/icons-vue'
|
||||
import { globalStore } from '@/store'
|
||||
import { useMenuStore } from '@/store/menu'
|
||||
import { useDictStore } from '@/store/dict'
|
||||
import tool from '@/utils/tool'
|
||||
import smCrypto from '@/utils/smCrypto'
|
||||
import axios from 'axios'
|
||||
import sysConfig from '@/config/index'
|
||||
import userCenterApi from '@/api/sys/userCenterApi'
|
||||
|
||||
const store = globalStore()
|
||||
const route = useRoute()
|
||||
const isLocked = computed(() => store.isLocked)
|
||||
const router = useRouter()
|
||||
const isDark = computed(() => store.theme === 'realDark')
|
||||
const userInfo = computed(() => store.userInfo)
|
||||
const userInfo = computed(() => store.userInfo || {})
|
||||
const passwordRef = ref()
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const inputStatus = ref('')
|
||||
|
||||
// 锁屏界面是否可见(排除登录页和无Token状态)
|
||||
const visible = computed(() => {
|
||||
const token = tool.data.get('TOKEN')
|
||||
return isLocked.value && token && route.path !== '/login'
|
||||
})
|
||||
// 锁屏界面是否可见,登录页不展示
|
||||
const visible = computed(() => store.isLocked && route.path !== '/login')
|
||||
|
||||
// 解锁
|
||||
const handleUnlock = async () => {
|
||||
// 解锁,失败提示由全局错误处理统一弹出
|
||||
const handleUnlock = () => {
|
||||
if (!password.value) {
|
||||
errorMsg.value = '请输入密码'
|
||||
inputStatus.value = 'error'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const param = {
|
||||
password: smCrypto.doSm2Encrypt(password.value)
|
||||
}
|
||||
const res = await axios.post('/api/sys/userCenter/unlock', param, {
|
||||
headers: {
|
||||
[sysConfig.TOKEN_NAME]: sysConfig.TOKEN_PREFIX + tool.data.get('TOKEN')
|
||||
}
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
inputStatus.value = ''
|
||||
userCenterApi
|
||||
.userUnlock({ password: smCrypto.doSm2Encrypt(password.value) })
|
||||
.then(() => {
|
||||
store.setIsLocked(false)
|
||||
password.value = ''
|
||||
} else if (res.data.code === 401 || res.data.code === 1011007 || res.data.code === 1011008) {
|
||||
handleLogout(true)
|
||||
} else {
|
||||
errorMsg.value = res.data.msg || '密码错误'
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response && (err.response.status === 401 || err.response.status === 403)) {
|
||||
handleLogout(true)
|
||||
} else {
|
||||
errorMsg.value = '解锁失败,请重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录/处理失效
|
||||
const handleLogout = (force = false) => {
|
||||
const doLogout = () => {
|
||||
tool.data.remove('TOKEN')
|
||||
tool.data.remove('USER_INFO')
|
||||
tool.data.remove('MENU')
|
||||
tool.data.remove('PERMISSIONS')
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
store.setIsLocked(false)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
if (force) {
|
||||
doLogout()
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '确定退出登录吗?',
|
||||
onOk: () => {
|
||||
doLogout()
|
||||
}
|
||||
// 锁屏期间刷新过页面的话,菜单与字典的请求会被4012拦下,解锁后补拉一次
|
||||
useMenuStore().refreshApiMenu()
|
||||
useDictStore().refreshDict()
|
||||
})
|
||||
.catch(() => {
|
||||
inputStatus.value = 'error'
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 忘记密码等情况下的兜底出口:清空登录态回到登录页重新登录
|
||||
const handleBackToLogin = () => {
|
||||
tool.clearLoginCache()
|
||||
store.setIsLocked(false)
|
||||
store.setUserInfo(undefined)
|
||||
router.replace({ path: '/login' }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
// 锁屏弹出时自动聚焦密码框,避免还要先点一下输入框
|
||||
watch(visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
nextTick(() => passwordRef.value?.focus())
|
||||
} else {
|
||||
password.value = ''
|
||||
inputStatus.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@@ -205,16 +188,11 @@
|
||||
.ant-input-affix-wrapper-lg {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.lock-screen-error-msg {
|
||||
color: #ff4d4f;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
min-height: 28px;
|
||||
line-height: 28px;
|
||||
padding-left: 2px;
|
||||
.lock-screen-unlock-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ant-btn {
|
||||
margin-top: 4px;
|
||||
.lock-screen-logout-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
import { globalStore } from '@/store'
|
||||
import tool from '@/utils/tool'
|
||||
import userCenterApi from '@/api/sys/userCenterApi'
|
||||
|
||||
// 视为用户活跃的事件,mousemove/scroll 触发频率极高,统一走节流
|
||||
const ACTIVITY_EVENT_LIST = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'scroll', 'wheel']
|
||||
// 活跃事件的节流间隔(毫秒),间隔内的重复触发不再重置计时
|
||||
const ACTIVITY_THROTTLE_INTERVAL = 1000
|
||||
// 活跃时间写入本地缓存的间隔(毫秒),比上面宽是因为写缓存要读写整个聚合对象,开销大于重置定时器
|
||||
const SHARED_ACTIVITY_WRITE_INTERVAL = 5000
|
||||
// tool.data 中 SNOWY_ 前缀键的聚合存储项,用于跨标签页同步锁屏状态
|
||||
const SNOWY_SETTING_KEY = 'SNOWY_SETTING'
|
||||
// 最近活跃时间的缓存键,多个标签页共用同一个 token,活跃时间必须共享
|
||||
const LAST_ACTIVITY_KEY = 'SNOWY_LAST_ACTIVITY'
|
||||
|
||||
const setLastActivityAt = (timestamp) => tool.data.set(LAST_ACTIVITY_KEY, timestamp)
|
||||
|
||||
/**
|
||||
* 读取全部标签页共享的最近活跃时间
|
||||
* 无记录(刚登录)或时间戳晚于当前时刻(改过系统时间)时重置为此刻,避免一进页面就被判定为超时
|
||||
*/
|
||||
const getLastActivityAt = () => {
|
||||
const lastActivityAt = Number(tool.data.get(LAST_ACTIVITY_KEY)) || 0
|
||||
const now = Date.now()
|
||||
if (lastActivityAt <= 0 || lastActivityAt > now) {
|
||||
setLastActivityAt(now)
|
||||
return now
|
||||
}
|
||||
return lastActivityAt
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定屏幕,先本地锁屏保证界面立即遮蔽,再请求服务端锁定会话
|
||||
* 服务端锁定后,除解锁与退出登录外的接口都会返回4012
|
||||
*/
|
||||
export const doLockScreen = () => {
|
||||
globalStore().setIsLocked(true)
|
||||
userCenterApi.userLock()
|
||||
}
|
||||
|
||||
/**
|
||||
* 无操作自动锁屏,计时时长取登录用户的 autoLockTime(分钟),为0时不开启
|
||||
* 已锁屏时停止计时,解锁后自动重新开始计时
|
||||
*
|
||||
* 空闲判定基于全部标签页共享的活跃时间:同源标签页共用一个 token,服务端锁定作用在该 token 的会话上,
|
||||
* 若各标签页各自计时,空闲的那个到点就会把正在使用的那个一起锁掉
|
||||
*/
|
||||
export const useAutoLock = () => {
|
||||
const store = globalStore()
|
||||
let timer = null
|
||||
let lastActivityAt = 0
|
||||
let lastSharedAt = 0
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重置倒计时,未登录、未开启自动锁屏、已锁屏这三种情况都不需要计时
|
||||
const resetTimer = () => {
|
||||
clearTimer()
|
||||
if (!tool.data.get('TOKEN') || store.autoLockTime <= 0 || store.isLocked) {
|
||||
return
|
||||
}
|
||||
const restTime = store.autoLockTime * 60 * 1000 - (Date.now() - getLastActivityAt())
|
||||
if (restTime <= 0) {
|
||||
doLockScreen()
|
||||
return
|
||||
}
|
||||
// 到点后不直接锁屏,而是重新走一遍本方法:期间若其他标签页有过操作,共享活跃时间已被刷新,这里会自动顺延
|
||||
timer = setTimeout(resetTimer, restTime)
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastActivityAt < ACTIVITY_THROTTLE_INTERVAL) {
|
||||
return
|
||||
}
|
||||
lastActivityAt = now
|
||||
// 共享活跃时间按更宽的间隔写入,最坏情况下比真实活跃时间滞后 SHARED_ACTIVITY_WRITE_INTERVAL,
|
||||
// 相对分钟级的锁屏时长可以忽略
|
||||
if (now - lastSharedAt >= SHARED_ACTIVITY_WRITE_INTERVAL) {
|
||||
lastSharedAt = now
|
||||
setLastActivityAt(now)
|
||||
}
|
||||
resetTimer()
|
||||
}
|
||||
|
||||
// 其他标签页锁屏/解锁时同步本标签页,直接赋值以避免再次写回缓存
|
||||
const handleStorageChange = (event) => {
|
||||
if (event.key !== SNOWY_SETTING_KEY) {
|
||||
return
|
||||
}
|
||||
const locked = !!tool.data.get('SNOWY_IS_LOCKED')
|
||||
if (locked !== store.isLocked) {
|
||||
store.isLocked = locked
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ACTIVITY_EVENT_LIST.forEach((eventName) => window.addEventListener(eventName, handleActivity, { passive: true }))
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
nextTick(() => resetTimer())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ACTIVITY_EVENT_LIST.forEach((eventName) => window.removeEventListener(eventName, handleActivity))
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
clearTimer()
|
||||
})
|
||||
|
||||
// 解锁瞬间必须把活跃时间刷到此刻,否则拿锁屏前的旧时间去算,解锁后会立刻又锁上
|
||||
watch(
|
||||
() => store.isLocked,
|
||||
(isLocked, preIsLocked) => {
|
||||
if (preIsLocked && !isLocked) {
|
||||
lastSharedAt = Date.now()
|
||||
setLastActivityAt(lastSharedAt)
|
||||
}
|
||||
resetTimer()
|
||||
}
|
||||
)
|
||||
|
||||
// 时长配置变化时按新时长重新计时
|
||||
watch(() => store.autoLockTime, resetTimer)
|
||||
|
||||
return { resetTimer }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
import { globalStore } from '@/store'
|
||||
|
||||
// 灰色模式类名,对应样式见 src/style/index.less 的 html.gray-mode
|
||||
const GRAY_MODE_CLASS = 'gray-mode'
|
||||
|
||||
/**
|
||||
* 灰色模式,跟随 store.grayModeOpen 在 html 元素上增删类名
|
||||
*
|
||||
* 滤镜必须加在 html 上:一是 Modal、Drawer 等浮层挂在 body 下,加在业务容器上盖不住;
|
||||
* 二是 filter 会创建新的包含块,加在中间层会让内部的 position: fixed 失效
|
||||
*/
|
||||
export const useGrayMode = () => {
|
||||
const store = globalStore()
|
||||
|
||||
watch(
|
||||
() => store.grayModeOpen,
|
||||
(isEnabled) => {
|
||||
document.documentElement.classList.toggle(GRAY_MODE_CLASS, isEnabled)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove(GRAY_MODE_CLASS)
|
||||
})
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
import DevUserMessage from './message.vue'
|
||||
import PanelSearch from './panel-search/index.vue'
|
||||
import { globalStore, viewTagsStore, keepAliveStore } from '@/store'
|
||||
import { doLockScreen } from '@/hooks/useAutoLock'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { locale } = useI18n()
|
||||
|
||||
@@ -127,11 +128,8 @@
|
||||
.logout(param)
|
||||
.then(() => {
|
||||
// 清理掉个人的一些信息
|
||||
tool.data.remove('TOKEN')
|
||||
tool.data.remove('USER_INFO')
|
||||
tool.data.remove('MENU')
|
||||
tool.data.remove('PERMISSIONS')
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
tool.clearLoginCache()
|
||||
store.setIsLocked(false)
|
||||
// 清理标签与缓存,避免同一标签页内换号后残留
|
||||
viewTagsStore().clearViewTags()
|
||||
keepAliveStore().clearKeepLive()
|
||||
@@ -169,9 +167,9 @@
|
||||
screenFull.toggle(element)
|
||||
}
|
||||
}
|
||||
// 锁屏
|
||||
// 锁屏,本地立即遮蔽的同时通知服务端锁定会话
|
||||
const lockScreen = () => {
|
||||
store.setIsLocked(true)
|
||||
doLockScreen()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
|
||||
import './style/index.less'
|
||||
import snowy from './snowy'
|
||||
@@ -9,14 +8,6 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import './tailwind.css'
|
||||
|
||||
// 全局配置 message 和 notification 的 z-index,确保在锁屏等高 z-index 组件之上
|
||||
message.config({
|
||||
zIndex: 10000
|
||||
})
|
||||
notification.config({
|
||||
zIndex: 10000
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(i18n)
|
||||
|
||||
@@ -162,8 +162,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
tool.data.set('LAST_VIEWS_PATH', to.fullPath)
|
||||
// 验证menu或则用户信息是否存在,不存在那么就是被删除或者退出或者清理缓存了
|
||||
if (!tool.data.get('MENU') || !tool.data.get('USER_INFO')) {
|
||||
tool.data.remove('TOKEN')
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
tool.clearLoginCache()
|
||||
store.setIsLocked(false)
|
||||
next({
|
||||
path: '/login'
|
||||
})
|
||||
@@ -172,7 +172,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
// 无登录态时一定不该处于锁屏,避免登录页被残留的锁屏遮罩盖住
|
||||
store.setIsLocked(false)
|
||||
next({
|
||||
path: '/login'
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as antdvIcons from '@ant-design/icons-vue'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import config from './config'
|
||||
import tool from './utils/tool'
|
||||
import { hasPerm } from './utils/permission/index'
|
||||
@@ -11,8 +12,15 @@ import STable from './components/Table/index.vue'
|
||||
import Ellipsis from './components/Ellipsis/index.vue'
|
||||
import DragModal from './components/DragModal/index.vue'
|
||||
|
||||
// 全局提示的层级,需高于锁屏遮罩(@lock-screen-z-index),否则解锁失败的提示会被遮罩盖住
|
||||
const GLOBAL_MESSAGE_Z_INDEX = 10000
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
// 全局提示层级配置
|
||||
message.config({ zIndex: GLOBAL_MESSAGE_Z_INDEX })
|
||||
notification.config({ zIndex: GLOBAL_MESSAGE_Z_INDEX })
|
||||
|
||||
// 挂载全局对象
|
||||
app.config.globalProperties.$CONFIG = config
|
||||
app.config.globalProperties.$TOOL = tool
|
||||
|
||||
@@ -68,10 +68,10 @@ export const globalStore = defineStore('global', () => {
|
||||
const sysBaseConfig = ref(toolDataGet('SNOWY_SYS_BASE_CONFIG') || config.SYS_BASE_CONFIG)
|
||||
// 默认应用
|
||||
const module = ref(getCacheConfig('SNOWY_MENU_MODULE_ID'))
|
||||
// 锁屏状态
|
||||
// 锁屏状态,服务端为准,此处仅作本地缓存以便刷新页面时立即呈现锁屏
|
||||
const isLocked = ref(toolDataGet('SNOWY_IS_LOCKED') || false)
|
||||
// 自动锁屏时间(分钟),0 为不开启
|
||||
const autoLockTime = ref(toolDataGet('SNOWY_AUTO_LOCK_TIME') || 0)
|
||||
// 无操作自动锁屏时长(分钟),0 为不开启,随登录用户信息由服务端下发
|
||||
const autoLockTime = computed(() => Number(userInfo.value?.autoLockTime) || 0)
|
||||
|
||||
// 定义action
|
||||
const setIsMobile = (key) => {
|
||||
@@ -81,10 +81,6 @@ export const globalStore = defineStore('global', () => {
|
||||
isLocked.value = key
|
||||
tool.data.set('SNOWY_IS_LOCKED', key)
|
||||
}
|
||||
const setAutoLockTime = (key) => {
|
||||
autoLockTime.value = key
|
||||
tool.data.set('SNOWY_AUTO_LOCK_TIME', key)
|
||||
}
|
||||
const setLayout = (key) => {
|
||||
layout.value = key
|
||||
}
|
||||
@@ -147,6 +143,15 @@ export const globalStore = defineStore('global', () => {
|
||||
}
|
||||
const setUserInfo = (key) => {
|
||||
userInfo.value = key
|
||||
if (key) {
|
||||
tool.data.set('USER_INFO', key)
|
||||
} else {
|
||||
tool.data.remove('USER_INFO')
|
||||
}
|
||||
}
|
||||
// 修改自动锁屏时长,服务端保存成功后同步到本地用户信息
|
||||
const setAutoLockTime = (key) => {
|
||||
setUserInfo({ ...userInfo.value, autoLockTime: key })
|
||||
}
|
||||
const setSysBaseConfig = (key) => {
|
||||
sysBaseConfig.value = key
|
||||
|
||||
@@ -11,21 +11,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import loginApi from '@/api/auth/loginApi'
|
||||
import { useGlobalStore } from '@/store'
|
||||
import tool from '@/utils/tool'
|
||||
export const useUserStore = defineStore('useUserStore', () => {
|
||||
// 初始化用户信息
|
||||
const initUserInfo = async () => {
|
||||
const data = await loginApi.getLoginUser()
|
||||
const globalStore = useGlobalStore()
|
||||
globalStore.setUserInfo(data)
|
||||
tool.data.set('USER_INFO', data)
|
||||
useGlobalStore().setUserInfo(data)
|
||||
}
|
||||
// 刷新登录用户信息
|
||||
const refreshUserLoginUserInfo = () => {
|
||||
loginApi.getLoginUser().then((data) => {
|
||||
const globalStore = useGlobalStore()
|
||||
globalStore.setUserInfo(data)
|
||||
tool.data.set('USER_INFO', data)
|
||||
useGlobalStore().setUserInfo(data)
|
||||
})
|
||||
}
|
||||
return { initUserInfo, refreshUserLoginUserInfo }
|
||||
|
||||
@@ -68,7 +68,8 @@ export const viewTagsStore = defineStore('viewTags', () => {
|
||||
const target = viewTags.value.find((item) => item.path === route.path)
|
||||
const isName = route.name
|
||||
if (!target && isName) {
|
||||
viewTags.value.push(route)
|
||||
// 必须存快照而不是 useRoute() 返回的活对象:vue-router 的路由对象是用
|
||||
viewTags.value.push({ ...route })
|
||||
}
|
||||
if (target) {
|
||||
updateViewTags(route)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
|
||||
import { Modal, message, Input } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import tool from '@/utils/tool'
|
||||
import smCrypto from '@/utils/smCrypto'
|
||||
// 直接引 store/global,避免经 store/index 间接依赖 user.js -> loginApi -> request.js 形成循环引用
|
||||
import { globalStore } from '@/store/global'
|
||||
// 此处与 userCenterApi 构成循环引用(api -> request -> 本文件),
|
||||
// 但双方对彼此的引用都在函数体内延迟取值,模块加载期不会触发,故安全;
|
||||
// 切勿在 userCenterApi 顶层新增立即执行的代码,否则该环会在加载期断裂
|
||||
import userCenterApi from '@/api/sys/userCenterApi'
|
||||
|
||||
/** 需要重新登录的业务码 */
|
||||
export const RELOGIN_CODES = [401, 1011007, 1011008]
|
||||
/** 触发二级认证 */
|
||||
export const SAFE_AUTH_CODE = 4011
|
||||
/** 会话已被锁屏,除解锁与退出外的接口都会被拦下 */
|
||||
export const LOCKED_CODE = 4012
|
||||
/** 解锁连续失败超限,服务端已强制退出登录 */
|
||||
export const UNLOCK_FAIL_LIMIT_CODE = 4013
|
||||
|
||||
// 重登与二级认证弹窗的层级,需高于锁屏遮罩(z-index 2000),否则锁屏时弹出会被遮罩盖住导致点不到
|
||||
const GLOBAL_MODAL_Z_INDEX = 9000
|
||||
// 重新登录弹窗是否已弹出,用于保持其唯一性
|
||||
const loginBack = ref(false)
|
||||
|
||||
// 弹出重新登录提示,点击确定后清理登录态并刷新,由路由守卫送回登录页
|
||||
const showReloginModal = (content) => {
|
||||
if (loginBack.value) {
|
||||
return
|
||||
}
|
||||
loginBack.value = true
|
||||
Modal.error({
|
||||
title: '提示:',
|
||||
okText: '重新登录',
|
||||
zIndex: GLOBAL_MODAL_Z_INDEX,
|
||||
content: content,
|
||||
onOk: () => {
|
||||
loginBack.value = false
|
||||
tool.clearLoginCache()
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 登录态失效
|
||||
export const handleRelogin = () => {
|
||||
showReloginModal('登录已失效, 请重新登录')
|
||||
}
|
||||
|
||||
// 解锁连续失败超限,服务端已注销会话
|
||||
export const handleUnlockFailLimit = (data) => {
|
||||
showReloginModal(data.msg)
|
||||
return Promise.reject(data)
|
||||
}
|
||||
|
||||
// 服务端会话处于锁屏态,同步本地状态弹出解锁界面,请求本身直接失败
|
||||
export const handleLocked = (data) => {
|
||||
globalStore().setIsLocked(true)
|
||||
return Promise.reject(data)
|
||||
}
|
||||
|
||||
// 开启二级认证的弹窗,认证通过后重试原请求
|
||||
// service 为 axios 实例,由调用方传入:重放原请求要以 config 直接调用实例本身,api 层封装给不了这个能力
|
||||
export const handleSafeAuth = (service, config, safeType) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let password = ''
|
||||
Modal.confirm({
|
||||
title: '二级认证',
|
||||
width: 400,
|
||||
zIndex: GLOBAL_MODAL_Z_INDEX,
|
||||
content: () =>
|
||||
h('div', [
|
||||
h('div', { style: 'margin-bottom: 10px' }, '该操作涉及敏感数据,请验证登录密码'),
|
||||
h(Input.Password, {
|
||||
placeholder: '请输入登录密码',
|
||||
onChange: (e) => {
|
||||
password = e.target.value
|
||||
}
|
||||
})
|
||||
]),
|
||||
onOk: () => {
|
||||
if (!password) {
|
||||
message.warning('请输入密码')
|
||||
// 此处必须返回 rejected promise:onOk 返回 falsy 的非 promise 值会被 antd 判定为直接关闭弹窗
|
||||
return Promise.reject()
|
||||
}
|
||||
const param = {
|
||||
password: smCrypto.doSm2Encrypt(password),
|
||||
safeType: safeType
|
||||
}
|
||||
// 调用开启二级认证接口,认证失败时 rejection 原样透传给 Modal,弹窗保持打开供用户重试
|
||||
return userCenterApi.userOpenSafe(param).then(() => {
|
||||
message.success('认证成功')
|
||||
// 认证成功后重试原请求
|
||||
resolve(service(config))
|
||||
})
|
||||
},
|
||||
onCancel: () => {
|
||||
reject()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -11,15 +11,22 @@
|
||||
// 统一的请求发送
|
||||
import axios from 'axios'
|
||||
import qs from 'qs'
|
||||
import { Modal, message, Input } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import sysConfig from '@/config/index'
|
||||
import tool from '@/utils/tool'
|
||||
import smCrypto from '@/utils/smCrypto'
|
||||
import { convertUrl } from './apiAdaptive'
|
||||
// 认证类业务码的具体处理(重登弹窗、二级认证、锁屏)都在这里,本文件只负责识别与分发
|
||||
import {
|
||||
RELOGIN_CODES,
|
||||
SAFE_AUTH_CODE,
|
||||
LOCKED_CODE,
|
||||
UNLOCK_FAIL_LIMIT_CODE,
|
||||
handleRelogin,
|
||||
handleUnlockFailLimit,
|
||||
handleLocked,
|
||||
handleSafeAuth
|
||||
} from '@/utils/codeHandler'
|
||||
|
||||
// 以下这些code需要重新登录
|
||||
const reloadCodes = [401, 1011007, 1011008]
|
||||
const errorCodeMap = {
|
||||
400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
|
||||
401: '用户没有权限(令牌、用户名、密码错误)。',
|
||||
@@ -34,8 +41,6 @@ const errorCodeMap = {
|
||||
503: '服务不可用,服务器暂时过载或维护。',
|
||||
504: '网关超时。'
|
||||
}
|
||||
// 定义一个重新登录弹出窗的变量
|
||||
const loginBack = ref(false)
|
||||
// 判断是否为直连模式(仅生产环境 + API_URL 配置了完整后端地址时生效)
|
||||
const isDirectMode = !import.meta.env.DEV && sysConfig.API_URL && /^https?:\/\//.test(sysConfig.API_URL)
|
||||
// 创建 axios 实例
|
||||
@@ -63,70 +68,6 @@ service.interceptors.request.use(
|
||||
}
|
||||
)
|
||||
|
||||
// 保持重新登录Modal的唯一性
|
||||
const error = () => {
|
||||
loginBack.value = true
|
||||
Modal.error({
|
||||
title: '提示:',
|
||||
okText: '重新登录',
|
||||
content: '登录已失效, 请重新登录',
|
||||
onOk: () => {
|
||||
loginBack.value = false
|
||||
tool.data.remove('TOKEN')
|
||||
tool.data.remove('USER_INFO')
|
||||
tool.data.remove('MENU')
|
||||
tool.data.remove('PERMISSIONS')
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 开启二级认证的弹窗
|
||||
const handleSafeAuth = (config, safeType) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let password = ''
|
||||
Modal.confirm({
|
||||
title: '二级认证',
|
||||
width: 400,
|
||||
content: () =>
|
||||
h('div', [
|
||||
h('div', { style: 'margin-bottom: 10px' }, '该操作涉及敏感数据,请验证登录密码'),
|
||||
h(Input.Password, {
|
||||
placeholder: '请输入登录密码',
|
||||
onChange: (e) => {
|
||||
password = e.target.value
|
||||
}
|
||||
})
|
||||
]),
|
||||
onOk: () => {
|
||||
if (!password) {
|
||||
message.warning('请输入密码')
|
||||
return Promise.reject()
|
||||
}
|
||||
const param = {
|
||||
password: smCrypto.doSm2Encrypt(password),
|
||||
safeType: safeType
|
||||
}
|
||||
// 调用开启二级认证接口
|
||||
return service
|
||||
.post('/sys/userCenter/openSafe', param)
|
||||
.then(() => {
|
||||
message.success('认证成功')
|
||||
// 认证成功后重试原请求
|
||||
resolve(service(config))
|
||||
})
|
||||
.catch(() => {
|
||||
return Promise.reject()
|
||||
})
|
||||
},
|
||||
onCancel: () => {
|
||||
reject()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP response 拦截器
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
@@ -141,14 +82,17 @@ service.interceptors.response.use(
|
||||
}
|
||||
const data = response.data
|
||||
const code = data.code
|
||||
if (reloadCodes.includes(code)) {
|
||||
if (!loginBack.value) {
|
||||
error()
|
||||
}
|
||||
return
|
||||
if (RELOGIN_CODES.includes(code)) {
|
||||
return handleRelogin()
|
||||
}
|
||||
if (code === 4011) {
|
||||
return handleSafeAuth(response.config, data.data)
|
||||
if (code === UNLOCK_FAIL_LIMIT_CODE) {
|
||||
return handleUnlockFailLimit(data)
|
||||
}
|
||||
if (code === SAFE_AUTH_CODE) {
|
||||
return handleSafeAuth(service, response.config, data.data)
|
||||
}
|
||||
if (code === LOCKED_CODE) {
|
||||
return handleLocked(data)
|
||||
}
|
||||
if (code !== 200) {
|
||||
const customErrorMessage = response.config.customErrorMessage
|
||||
|
||||
@@ -16,11 +16,16 @@
|
||||
*/
|
||||
const tool = {}
|
||||
|
||||
// SNOWY_ 前缀的键统一聚合存放在这个 localStorage 项里
|
||||
const SNOWY_SETTING_KEY = 'SNOWY_SETTING'
|
||||
// 判断某个键是否被聚合存放(SNOWY_SYS_BASE_CONFIG 体积大,仍单独存放)
|
||||
const isSnowySettingKey = (table) => table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
|
||||
|
||||
// localStorage
|
||||
tool.data = {
|
||||
set(table, settings) {
|
||||
const _set = JSON.stringify(settings)
|
||||
const SNOWYSTRING = table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
|
||||
const SNOWYSTRING = isSnowySettingKey(table)
|
||||
if (SNOWYSTRING) {
|
||||
let localSetting = JSON.parse(localStorage.getItem('SNOWY_SETTING')) || {}
|
||||
let newSetting = {}
|
||||
@@ -29,8 +34,8 @@ tool.data = {
|
||||
} else return localStorage.setItem(table, _set)
|
||||
},
|
||||
get(table) {
|
||||
const SNOWYSTRING = table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
|
||||
const SNOWY_SETTING = JSON.parse(localStorage.getItem('SNOWY_SETTING')) || {}
|
||||
const SNOWYSTRING = isSnowySettingKey(table)
|
||||
const SNOWY_SETTING = JSON.parse(localStorage.getItem(SNOWY_SETTING_KEY)) || {}
|
||||
let data = SNOWYSTRING ? SNOWY_SETTING[table] : localStorage.getItem(table)
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
@@ -40,6 +45,12 @@ tool.data = {
|
||||
return data
|
||||
},
|
||||
remove(table) {
|
||||
// SNOWY_ 前缀的键存在聚合对象里,直接 removeItem 是删不掉的,需从聚合对象中摘除
|
||||
if (isSnowySettingKey(table)) {
|
||||
const localSetting = JSON.parse(localStorage.getItem(SNOWY_SETTING_KEY)) || {}
|
||||
delete localSetting[table]
|
||||
return localStorage.setItem(SNOWY_SETTING_KEY, JSON.stringify(localSetting))
|
||||
}
|
||||
return localStorage.removeItem(table)
|
||||
},
|
||||
|
||||
@@ -48,6 +59,16 @@ tool.data = {
|
||||
}
|
||||
}
|
||||
|
||||
// 清理B端登录态缓存,退出登录、登录失效、缓存被破坏时统一调用
|
||||
tool.clearLoginCache = () => {
|
||||
tool.data.remove('TOKEN')
|
||||
tool.data.remove('USER_INFO')
|
||||
tool.data.remove('MENU')
|
||||
tool.data.remove('PERMISSIONS')
|
||||
tool.data.remove('SNOWY_IS_LOCKED')
|
||||
tool.data.remove('SNOWY_LAST_ACTIVITY')
|
||||
}
|
||||
|
||||
// sessionStorage
|
||||
tool.session = {
|
||||
set(table, settings) {
|
||||
|
||||
@@ -13,6 +13,8 @@ export const afterLogin = async (loginToken, targetPath) => {
|
||||
const menuStore = useMenuStore()
|
||||
const userStore = useUserStore()
|
||||
tool.data.set('TOKEN', loginToken)
|
||||
// 重新登录一定不处于锁屏,清掉上次会话残留的锁屏状态(服务端同样会在登录时清除)
|
||||
globalStore().setIsLocked(false)
|
||||
|
||||
// 并行初始化用户信息和获取用户的菜单
|
||||
await Promise.all([userStore.initUserInfo(), menuStore.fetchMenu()])
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
v-if="item.type === 'lock'"
|
||||
v-model:value="autoLockTime"
|
||||
:min="0"
|
||||
:max="5"
|
||||
:max="AUTO_LOCK_TIME_MAX"
|
||||
:precision="0"
|
||||
style="width: 120px"
|
||||
@change="handleLockTimeChange"
|
||||
/>
|
||||
@@ -58,13 +59,40 @@
|
||||
const bindEmailRef = ref()
|
||||
const bindOtpRef = ref()
|
||||
const store = globalStore()
|
||||
// 自动锁屏时长上限(分钟),与后端 SysUserServiceImpl 的校验保持一致
|
||||
const AUTO_LOCK_TIME_MAX = 720
|
||||
// 输入防抖时长(毫秒),避免连续输入或连点步进按钮时频繁提交
|
||||
const SAVE_DEBOUNCE_INTERVAL = 500
|
||||
const autoLockTime = ref(store.autoLockTime)
|
||||
let saveTimer = null
|
||||
|
||||
// 用户信息刷新后同步输入框,保证展示的始终是服务端的值
|
||||
watch(
|
||||
() => store.autoLockTime,
|
||||
(value) => {
|
||||
autoLockTime.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const handleLockTimeChange = (value) => {
|
||||
store.setAutoLockTime(value || 0)
|
||||
message.success('设置成功')
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
const lockTime = Number(value) || 0
|
||||
// 接口名含 update,request.js 会统一弹成功提示,此处不再重复提示
|
||||
userCenterApi.userUpdateAutoLockTime({ autoLockTime: lockTime }).then(() => {
|
||||
store.setAutoLockTime(lockTime)
|
||||
})
|
||||
}, SAVE_DEBOUNCE_INTERVAL)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
})
|
||||
|
||||
const userInfo = computed(() => {
|
||||
if (store.userInfo) {
|
||||
return store.userInfo
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
*
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
*
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
package vip.xiaonuo.auth.core.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 屏幕锁定异常,当前会话处于锁屏状态时访问业务接口抛出
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
@Getter
|
||||
public class AuthLockedException extends RuntimeException {
|
||||
|
||||
/** 屏幕锁定的状态码,前端据此弹出解锁界面,与二级认证的4011区分 */
|
||||
public static final int LOCKED_CODE = 4012;
|
||||
|
||||
/** 屏幕锁定的提示语 */
|
||||
public static final String LOCKED_MESSAGE = "屏幕已锁定,请先解锁";
|
||||
|
||||
private final Integer code;
|
||||
|
||||
private final String msg;
|
||||
|
||||
public AuthLockedException() {
|
||||
super(LOCKED_MESSAGE);
|
||||
this.code = LOCKED_CODE;
|
||||
this.msg = LOCKED_MESSAGE;
|
||||
}
|
||||
}
|
||||
+4
@@ -247,6 +247,10 @@ public abstract class SaBaseLoginUser {
|
||||
@Schema(description = "用户密码hash值")
|
||||
private String password;
|
||||
|
||||
/** 自动锁屏时长,单位分钟,0表示不自动锁屏 */
|
||||
@Schema(description = "自动锁屏时长,单位分钟,0表示不自动锁屏")
|
||||
private Integer autoLockTime;
|
||||
|
||||
/** 是否可登录,由继承类实现 */
|
||||
public abstract Boolean getEnabled();
|
||||
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
*
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
*
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
package vip.xiaonuo.auth.core.util;
|
||||
|
||||
import cn.dev33.satoken.session.SaSession;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import vip.xiaonuo.auth.core.exception.AuthLockedException;
|
||||
|
||||
/**
|
||||
* B端锁屏工具类
|
||||
*
|
||||
* 锁屏状态存放于TokenSession中,即与token一一对应,锁定某台设备不会波及该账号的其他登录设备。
|
||||
* 除{@link #unlockByToken}外,其余方法均需在登录校验通过后调用,否则将抛出未登录异常。
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public class StpLockUtil {
|
||||
|
||||
/** 锁屏状态在TokenSession中的存储键 */
|
||||
private static final String LOCK_STATUS_KEY = "screenLocked";
|
||||
|
||||
private StpLockUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定当前会话
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public static void lock() {
|
||||
StpUtil.getTokenSession().set(LOCK_STATUS_KEY, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁当前会话
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public static void unlock() {
|
||||
SaSession tokenSession = StpUtil.getStpLogic().getTokenSession(false);
|
||||
if (ObjectUtil.isNotNull(tokenSession)) {
|
||||
tokenSession.delete(LOCK_STATUS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁指定token的会话,用于登录成功时清理复用token上残留的锁屏状态
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public static void unlockByToken(String tokenValue) {
|
||||
SaSession tokenSession = StpUtil.getStpLogic().getTokenSessionByToken(tokenValue, false);
|
||||
if (ObjectUtil.isNotNull(tokenSession)) {
|
||||
tokenSession.delete(LOCK_STATUS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会话是否处于锁屏状态,未锁屏时不会创建TokenSession,避免每次请求校验产生额外的缓存写入
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public static boolean isLocked() {
|
||||
SaSession tokenSession = StpUtil.getStpLogic().getTokenSession(false);
|
||||
if (ObjectUtil.isNull(tokenSession)) {
|
||||
return false;
|
||||
}
|
||||
return Convert.toBool(tokenSession.get(LOCK_STATUS_KEY), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前会话未锁屏,已锁屏则抛出异常
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
public static void checkLock() {
|
||||
if (isLocked()) {
|
||||
throw new AuthLockedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Component;
|
||||
import vip.xiaonuo.auth.api.SaBaseLoginUserApi;
|
||||
import vip.xiaonuo.auth.core.enums.SaClientTypeEnum;
|
||||
import vip.xiaonuo.auth.core.pojo.SaBaseLoginUser;
|
||||
import vip.xiaonuo.auth.core.util.StpLockUtil;
|
||||
import vip.xiaonuo.dev.api.DevLogApi;
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,8 @@ public class AuthListener implements SaTokenListener {
|
||||
// 更新用户的登录时间和登录ip等信息
|
||||
if(SaClientTypeEnum.B.getValue().equals(loginType)) {
|
||||
loginUserApi.updateUserLoginInfo(Convert.toStr(loginId), loginModel.getDeviceType());
|
||||
// token共享模式下重新登录会复用原token,需清除其上残留的锁屏标记,避免登录后直接处于锁屏态
|
||||
StpLockUtil.unlockByToken(tokenValue);
|
||||
// 记录B端登录日志
|
||||
Object name = loginModel.getExtra("name");
|
||||
if(ObjectUtil.isNotEmpty(name)) {
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ public enum SysRelationCategoryEnum {
|
||||
/** 用户日程数据 */
|
||||
SYS_USER_SCHEDULE_DATA("SYS_USER_SCHEDULE_DATA"),
|
||||
|
||||
/** 用户锁屏配置数据 */
|
||||
SYS_USER_LOCK_CONFIG_DATA("SYS_USER_LOCK_CONFIG_DATA"),
|
||||
|
||||
/** 用户拥有资源 */
|
||||
SYS_USER_HAS_RESOURCE("SYS_USER_HAS_RESOURCE"),
|
||||
|
||||
|
||||
+28
@@ -51,6 +51,20 @@ public class SysUserCenterController {
|
||||
@Resource
|
||||
private SysUserService sysUserService;
|
||||
|
||||
/**
|
||||
* 锁定屏幕
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
@Operation(summary = "锁定屏幕")
|
||||
@CommonLog("锁定屏幕")
|
||||
@PostMapping("/sys/userCenter/lock")
|
||||
public CommonResult<String> lock() {
|
||||
sysUserService.lock();
|
||||
return CommonResult.ok("锁定成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁屏幕
|
||||
*
|
||||
@@ -65,6 +79,20 @@ public class SysUserCenterController {
|
||||
return CommonResult.ok("解锁成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改自动锁屏时长
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
@Operation(summary = "修改自动锁屏时长")
|
||||
@CommonLog("修改自动锁屏时长")
|
||||
@PostMapping("/sys/userCenter/updateAutoLockTime")
|
||||
public CommonResult<String> updateAutoLockTime(@RequestBody @Valid SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam) {
|
||||
sysUserService.updateAutoLockTime(sysUserUpdateAutoLockTimeParam);
|
||||
return CommonResult.ok("设置成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启二级认证
|
||||
*
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright [2022] [https://www.xiaonuo.vip]
|
||||
*
|
||||
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
|
||||
*
|
||||
* 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
* 2.请不要删除和修改Snowy源码头部的版权声明。
|
||||
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
|
||||
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
|
||||
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
|
||||
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
|
||||
*/
|
||||
package vip.xiaonuo.sys.modular.user.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 用户修改自动锁屏时长参数
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
@Getter
|
||||
@Setter
|
||||
public class SysUserUpdateAutoLockTimeParam {
|
||||
|
||||
/** 自动锁屏时长,单位分钟,0表示不自动锁屏 */
|
||||
@Schema(description = "自动锁屏时长,单位分钟,0表示不自动锁屏")
|
||||
@NotNull(message = "autoLockTime不能为空")
|
||||
private Integer autoLockTime;
|
||||
}
|
||||
+16
@@ -39,6 +39,14 @@ import java.util.List;
|
||||
**/
|
||||
public interface SysUserService extends IService<SysUser> {
|
||||
|
||||
/**
|
||||
* 锁定屏幕
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
void lock();
|
||||
|
||||
/**
|
||||
* 解锁屏幕
|
||||
*
|
||||
@@ -47,6 +55,14 @@ public interface SysUserService extends IService<SysUser> {
|
||||
**/
|
||||
void unlock(SysUserUnlockParam sysUserUnlockParam);
|
||||
|
||||
/**
|
||||
* 修改自动锁屏时长
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
void updateAutoLockTime(SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam);
|
||||
|
||||
/**
|
||||
* 开启二级认证
|
||||
*
|
||||
|
||||
+104
-31
@@ -67,6 +67,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import vip.xiaonuo.auth.api.SaBaseLoginUserApi;
|
||||
import vip.xiaonuo.auth.core.pojo.SaBaseLoginUser;
|
||||
import vip.xiaonuo.auth.core.util.StpLockUtil;
|
||||
import vip.xiaonuo.auth.core.util.StpLoginUserUtil;
|
||||
import vip.xiaonuo.common.cache.CommonCacheOperator;
|
||||
import vip.xiaonuo.common.enums.CommonGenderEnum;
|
||||
@@ -210,6 +211,28 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
/** 验证码缓存前缀 */
|
||||
private static final String USER_VALID_CODE_CACHE_KEY = "user-validCode:";
|
||||
|
||||
/** 解锁失败次数缓存前缀 */
|
||||
private static final String USER_UNLOCK_FAIL_CACHE_KEY = "user-unlockFail:";
|
||||
|
||||
/** 解锁允许的最大连续失败次数,达到后强制退出登录 */
|
||||
private static final int USER_UNLOCK_FAIL_MAX_COUNT = 5;
|
||||
|
||||
/** 解锁失败次数的缓存时长,单位:秒 */
|
||||
private static final int USER_UNLOCK_FAIL_CACHE_TIMEOUT = 30 * 60;
|
||||
|
||||
/**
|
||||
* 解锁连续失败超限的状态码,前端据此清理登录态并跳转登录页
|
||||
* 不能复用401:401 走的是前端通用的“登录已失效”弹窗,该分支不会 reject 请求,
|
||||
* 会被解锁界面误判成解锁成功,同属认证类状态码,与4011二级认证、4012屏幕锁定并列
|
||||
*/
|
||||
private static final int USER_UNLOCK_FAIL_LIMIT_CODE = 4013;
|
||||
|
||||
/** 自动锁屏时长上限,单位:分钟 */
|
||||
private static final int USER_AUTO_LOCK_TIME_MAX = 720;
|
||||
|
||||
/** 自动锁屏时长在关系表扩展信息中的键名 */
|
||||
private static final String USER_AUTO_LOCK_TIME_KEY = "autoLockTime";
|
||||
|
||||
@Resource
|
||||
private CommonCacheOperator commonCacheOperator;
|
||||
|
||||
@@ -270,20 +293,77 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
@Resource(name = "loginUserApi")
|
||||
private SaBaseLoginUserApi loginUserApi;
|
||||
|
||||
@Override
|
||||
public void lock() {
|
||||
StpLockUtil.lock();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unlock(SysUserUnlockParam sysUserUnlockParam) {
|
||||
SysUser sysUser = this.queryEntity(StpUtil.getLoginIdAsString());
|
||||
String password = CommonCryptogramUtil.doSm2Decrypt(sysUserUnlockParam.getPassword()).trim();
|
||||
if (!CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword())) {
|
||||
throw new CommonException("密码错误");
|
||||
String userId = StpUtil.getLoginIdAsString();
|
||||
String failCacheKey = USER_UNLOCK_FAIL_CACHE_KEY + userId;
|
||||
if (!this.matchPassword(userId, sysUserUnlockParam.getPassword())) {
|
||||
int failCount = Convert.toInt(commonCacheOperator.get(failCacheKey), 0) + 1;
|
||||
// 连续失败达到上限,强制下线,避免锁屏界面被无限次尝试密码
|
||||
if (failCount >= USER_UNLOCK_FAIL_MAX_COUNT) {
|
||||
commonCacheOperator.remove(failCacheKey);
|
||||
StpUtil.logout();
|
||||
throw new CommonException(USER_UNLOCK_FAIL_LIMIT_CODE, "密码错误次数过多,已强制退出登录,请重新登录");
|
||||
}
|
||||
commonCacheOperator.put(failCacheKey, failCount, USER_UNLOCK_FAIL_CACHE_TIMEOUT);
|
||||
throw new CommonException("密码错误,还可尝试{}次", USER_UNLOCK_FAIL_MAX_COUNT - failCount);
|
||||
}
|
||||
commonCacheOperator.remove(failCacheKey);
|
||||
StpLockUtil.unlock();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAutoLockTime(SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam) {
|
||||
Integer autoLockTime = sysUserUpdateAutoLockTimeParam.getAutoLockTime();
|
||||
if (autoLockTime < 0 || autoLockTime > USER_AUTO_LOCK_TIME_MAX) {
|
||||
throw new CommonException("自动锁屏时长应在0至{}分钟之间", USER_AUTO_LOCK_TIME_MAX);
|
||||
}
|
||||
// 存关系表而非用户表字段,避免为一个个性化配置改动SYS_USER表结构,与工作台数据、日程数据同一套做法
|
||||
sysRelationService.saveRelationWithClear(StpUtil.getLoginIdAsString(), null,
|
||||
SysRelationCategoryEnum.SYS_USER_LOCK_CONFIG_DATA.getValue(),
|
||||
JSONUtil.createObj().set(USER_AUTO_LOCK_TIME_KEY, autoLockTime).toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户的自动锁屏时长,未配置过则返回0(不自动锁屏)
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/7 10:20
|
||||
**/
|
||||
private Integer getAutoLockTime(String userId) {
|
||||
SysRelation sysRelation = sysRelationService.getOne(new LambdaQueryWrapper<SysRelation>()
|
||||
.eq(SysRelation::getObjectId, userId)
|
||||
.eq(SysRelation::getCategory, SysRelationCategoryEnum.SYS_USER_LOCK_CONFIG_DATA.getValue()));
|
||||
if (ObjectUtil.isNull(sysRelation) || ObjectUtil.isEmpty(sysRelation.getExtJson())) {
|
||||
return 0;
|
||||
}
|
||||
return JSONUtil.parseObj(sysRelation.getExtJson()).getInt(USER_AUTO_LOCK_TIME_KEY, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户实体转登录用户,自动锁屏时长不在SYS_USER表上,BeanUtil拷不到,需单独回填
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/7 10:20
|
||||
**/
|
||||
private SysLoginUser convertToLoginUser(SysUser sysUser) {
|
||||
if (ObjectUtil.isEmpty(sysUser)) {
|
||||
return null;
|
||||
}
|
||||
transService.transOne(sysUser);
|
||||
SysLoginUser sysLoginUser = BeanUtil.copyProperties(sysUser, SysLoginUser.class);
|
||||
sysLoginUser.setAutoLockTime(this.getAutoLockTime(sysUser.getId()));
|
||||
return sysLoginUser;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openSafe(SysUserOpenSafeParam sysUserOpenSafeParam) {
|
||||
SysUser sysUser = this.queryEntity(StpUtil.getLoginIdAsString());
|
||||
String password = CommonCryptogramUtil.doSm2Decrypt(sysUserOpenSafeParam.getPassword()).trim();
|
||||
if (!CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword())) {
|
||||
if (!this.matchPassword(StpUtil.getLoginIdAsString(), sysUserOpenSafeParam.getPassword())) {
|
||||
throw new CommonException("密码错误");
|
||||
}
|
||||
// 比对成功,为当前会话打开二级认证,有效期为120秒
|
||||
@@ -294,44 +374,37 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验指定用户的密码是否正确,入参为国密加密后的密码
|
||||
*
|
||||
* @author xuyuxiang
|
||||
* @date 2026/8/6 10:20
|
||||
**/
|
||||
private boolean matchPassword(String userId, String encryptPassword) {
|
||||
SysUser sysUser = this.queryEntity(userId);
|
||||
String password = CommonCryptogramUtil.doSm2Decrypt(encryptPassword).trim();
|
||||
return CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysLoginUser getUserById(String id) {
|
||||
SysUser sysUser = this.getById(id);
|
||||
if (ObjectUtil.isNotEmpty(sysUser)) {
|
||||
transService.transOne(sysUser);
|
||||
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
|
||||
}
|
||||
return null;
|
||||
return this.convertToLoginUser(this.getById(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysLoginUser getUserByAccount(String account) {
|
||||
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getAccount, account));
|
||||
if (ObjectUtil.isNotEmpty(sysUser)) {
|
||||
transService.transOne(sysUser);
|
||||
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
|
||||
}
|
||||
return null;
|
||||
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getAccount, account)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysLoginUser getUserByPhone(String phone) {
|
||||
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhone, CommonCryptogramUtil.doSm4CbcEncrypt(phone)));
|
||||
if (ObjectUtil.isNotEmpty(sysUser)) {
|
||||
transService.transOne(sysUser);
|
||||
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
|
||||
}
|
||||
return null;
|
||||
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getPhone, CommonCryptogramUtil.doSm4CbcEncrypt(phone))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysLoginUser getUserByEmail(String email) {
|
||||
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email));
|
||||
if (ObjectUtil.isNotEmpty(sysUser)) {
|
||||
transService.transOne(sysUser);
|
||||
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
|
||||
}
|
||||
return null;
|
||||
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -64,6 +64,7 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import vip.xiaonuo.auth.core.util.StpClientUtil;
|
||||
import vip.xiaonuo.auth.core.util.StpLockUtil;
|
||||
import vip.xiaonuo.common.annotation.CommonNoRepeat;
|
||||
import vip.xiaonuo.common.annotation.CommonWrapper;
|
||||
import vip.xiaonuo.common.cache.CommonCacheOperator;
|
||||
@@ -249,6 +250,17 @@ public class GlobalConfigure implements WebMvcConfigurer {
|
||||
"/client/c/**"
|
||||
};
|
||||
|
||||
/**
|
||||
* 锁屏状态下依然放行的接口地址集合,即解锁自身、退出登录与登录态查询
|
||||
*/
|
||||
private static final String[] NO_LOCK_CHECK_PATH_ARR = {
|
||||
"/sys/userCenter/lock",
|
||||
"/sys/userCenter/unlock",
|
||||
"/auth/b/getLoginUser",
|
||||
"/auth/b/doLogout",
|
||||
"/auth/b/isLogin"
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册跨域过滤器
|
||||
*/
|
||||
@@ -273,6 +285,17 @@ public class GlobalConfigure implements WebMvcConfigurer {
|
||||
StpUtil.renewTimeout(saTokenConfig.getTimeout());
|
||||
});
|
||||
|
||||
// B端的接口校验锁屏状态,锁屏期间除解锁与退出外一律拒绝
|
||||
SaRouter.match("/**")
|
||||
// 排除无需登录接口
|
||||
.notMatch(CollectionUtil.newArrayList(NO_LOGIN_PATH_ARR))
|
||||
// 排除C端认证接口
|
||||
.notMatch(CollectionUtil.newArrayList(CLIENT_USER_PERMISSION_PATH_ARR))
|
||||
// 排除锁屏期间放行的接口
|
||||
.notMatch(CollectionUtil.newArrayList(NO_LOCK_CHECK_PATH_ARR))
|
||||
// 校验B端锁屏状态
|
||||
.check(r1 -> StpLockUtil.checkLock());
|
||||
|
||||
// C端的接口校验C端登录
|
||||
SaRouter.match("/**")
|
||||
// 排除无需登录接口
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.multipart.MultipartException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import vip.xiaonuo.auth.core.exception.AuthLockedException;
|
||||
import vip.xiaonuo.auth.core.util.AuthExceptionUtil;
|
||||
import vip.xiaonuo.common.exception.CommonException;
|
||||
import vip.xiaonuo.common.pojo.CommonResult;
|
||||
@@ -111,6 +112,9 @@ public class GlobalExceptionUtil {
|
||||
log.error(">>> 文件上传参数异常:", e);
|
||||
//文件上传错误特殊提示
|
||||
commonResult = CommonResult.error("请选择要上传的文件并检查文件参数名称是否正确");
|
||||
} else if (e instanceof AuthLockedException authLockedException) {
|
||||
// 如果是屏幕锁定异常,返回4012,前端据此弹出解锁界面
|
||||
commonResult = CommonResult.get(authLockedException.getCode(), authLockedException.getMsg(), null);
|
||||
} else if (e instanceof SaTokenException) {
|
||||
|
||||
// 如果是SaToken相关异常,则由AuthExceptionUtil处理
|
||||
|
||||
Reference in New Issue
Block a user