feat: 登录失效检测 & 协作冲突检测 (#287)
Co-authored-by: Liuxinyi <liuxy0406@163.com> Co-authored-by: dayou <853094838@qq.com>
This commit is contained in:
parent
b5bcb7ff7e
commit
3003c2cbfa
@ -40,6 +40,7 @@
|
||||
"moment": "^2.30.1",
|
||||
"mongodb": "^5.9.2",
|
||||
"nanoid": "^3.3.7",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"qiniu": "^7.11.1",
|
||||
|
@ -18,5 +18,6 @@ export class SurveyHistory extends BaseEntity {
|
||||
operator: {
|
||||
username: string;
|
||||
_id: string;
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, HttpCode, Req, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserService } from '../services/user.service';
|
||||
import { CaptchaService } from '../services/captcha.service';
|
||||
@ -7,6 +7,7 @@ import { HttpException } from 'src/exceptions/httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
import { create } from 'svg-captcha';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
@ApiTags('auth')
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
@ -162,4 +163,25 @@ export class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/statuscheck')
|
||||
@HttpCode(200)
|
||||
async checkStatus(@Req() request: Request) {
|
||||
const token = request.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('请登录');
|
||||
}
|
||||
try {
|
||||
const expired = await this.authService.expiredCheck(token);
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
expired: expired
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(error?.message || '用户凭证检测失败');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -35,4 +35,17 @@ export class AuthService {
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async expiredCheck(token: string) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = verify(
|
||||
token,
|
||||
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
|
||||
);
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +130,7 @@ export class SurveyController {
|
||||
const { value, error } = Joi.object({
|
||||
surveyId: Joi.string().required(),
|
||||
configData: Joi.any().required(),
|
||||
sessionId: Joi.string().required(),
|
||||
}).validate(surveyInfo);
|
||||
if (error) {
|
||||
this.logger.error(error.message, { req });
|
||||
@ -137,7 +138,7 @@ export class SurveyController {
|
||||
}
|
||||
const username = req.user.username;
|
||||
const surveyId = value.surveyId;
|
||||
|
||||
const sessionId = value.sessionId;
|
||||
const configData = value.configData;
|
||||
await this.surveyConfService.saveSurveyConf({
|
||||
surveyId,
|
||||
@ -151,6 +152,7 @@ export class SurveyController {
|
||||
_id: req.user._id.toString(),
|
||||
username,
|
||||
},
|
||||
sessionId: sessionId,
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
@ -271,6 +273,7 @@ export class SurveyController {
|
||||
) {
|
||||
const { value, error } = Joi.object({
|
||||
surveyId: Joi.string().required(),
|
||||
sessionId: Joi.string().required(),
|
||||
}).validate(surveyInfo);
|
||||
if (error) {
|
||||
this.logger.error(error.message, { req });
|
||||
@ -278,6 +281,7 @@ export class SurveyController {
|
||||
}
|
||||
const username = req.user.username;
|
||||
const surveyId = value.surveyId;
|
||||
const sessionId = value.sessionId;
|
||||
const surveyMeta = req.surveyMeta;
|
||||
const surveyConf =
|
||||
await this.surveyConfService.getSurveyConfBySurveyId(surveyId);
|
||||
@ -317,6 +321,7 @@ export class SurveyController {
|
||||
_id: req.user._id.toString(),
|
||||
username,
|
||||
},
|
||||
sessionId: sessionId,
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
|
@ -18,6 +18,7 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
|
||||
import { Logger } from 'src/logger';
|
||||
import { HttpException } from 'src/exceptions/httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
import { val } from 'cheerio/lib/api/attributes';
|
||||
|
||||
@ApiTags('survey')
|
||||
@Controller('/api/surveyHisotry')
|
||||
@ -66,4 +67,52 @@ export class SurveyHistoryController {
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@Get('/getConflictList')
|
||||
@HttpCode(200)
|
||||
@UseGuards(SurveyGuard)
|
||||
@SetMetadata('surveyId', 'query.surveyId')
|
||||
@SetMetadata('surveyPermission', [
|
||||
SURVEY_PERMISSION.SURVEY_CONF_MANAGE,
|
||||
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
|
||||
SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
|
||||
])
|
||||
@UseGuards(Authentication)
|
||||
async getConflictList(
|
||||
@Query()
|
||||
queryInfo: {
|
||||
surveyId: string;
|
||||
historyType: string;
|
||||
sessionId: string;
|
||||
},
|
||||
@Request() req,
|
||||
) {
|
||||
const { value, error } = Joi.object({
|
||||
surveyId: Joi.string().required(),
|
||||
historyType: Joi.string().required(),
|
||||
sessionId: Joi.string().required(),
|
||||
}).validate(queryInfo);
|
||||
|
||||
if (error) {
|
||||
this.logger.error(error.message, { req });
|
||||
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
|
||||
}
|
||||
|
||||
const surveyId = value.surveyId;
|
||||
const historyType = value.historyType;
|
||||
const sessionId = value.sessionId;
|
||||
|
||||
const data = await this.surveyHistoryService.getConflictList({
|
||||
surveyId,
|
||||
historyType,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -17,8 +17,9 @@ export class SurveyHistoryService {
|
||||
schema: SurveySchemaInterface;
|
||||
type: HISTORY_TYPE;
|
||||
user: any;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const { surveyId, schema, type, user } = params;
|
||||
const { surveyId, schema, type, user, sessionId } = params;
|
||||
const newHistory = this.surveyHistory.create({
|
||||
pageId: surveyId,
|
||||
type,
|
||||
@ -26,6 +27,7 @@ export class SurveyHistoryService {
|
||||
operator: {
|
||||
_id: user._id.toString(),
|
||||
username: user.username,
|
||||
sessionId: sessionId,
|
||||
},
|
||||
});
|
||||
return this.surveyHistory.save(newHistory);
|
||||
@ -50,4 +52,29 @@ export class SurveyHistoryService {
|
||||
select: ['createDate', 'operator', 'type', '_id'],
|
||||
});
|
||||
}
|
||||
|
||||
async getConflictList({
|
||||
surveyId,
|
||||
historyType,
|
||||
sessionId,
|
||||
}: {
|
||||
surveyId: string;
|
||||
historyType: HISTORY_TYPE;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const result = await this.surveyHistory.find({
|
||||
where: {
|
||||
pageId: surveyId,
|
||||
type: historyType,
|
||||
// 排除掉sessionid相同的历史,这些保存不构成冲突
|
||||
'operator.sessionId': { $ne: sessionId },
|
||||
},
|
||||
order: { createDate: 'DESC' },
|
||||
take: 100,
|
||||
select: ['createDate', 'operator', 'type', '_id'],
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
6326
server/yarn.lock
Normal file
6326
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@
|
||||
"nanoid": "^5.0.7",
|
||||
"node-forge": "^1.3.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"uuid": "^10.0.0",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuedraggable": "^4.1.0",
|
||||
|
@ -20,13 +20,13 @@ export const getSurveyById = (id) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const saveSurvey = ({ surveyId, configData }) => {
|
||||
return axios.post('/survey/updateConf', { surveyId, configData })
|
||||
export const saveSurvey = ({ surveyId, configData, sessionId }) => {
|
||||
return axios.post('/survey/updateConf', { surveyId, configData, sessionId })
|
||||
}
|
||||
|
||||
export const publishSurvey = ({ surveyId }) => {
|
||||
export const publishSurvey = ({ surveyId, sessionId }) => {
|
||||
return axios.post('/survey/publishSurvey', {
|
||||
surveyId
|
||||
surveyId, sessionId
|
||||
})
|
||||
}
|
||||
|
||||
@ -43,6 +43,16 @@ export const getSurveyHistory = ({ surveyId, historyType }) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const getConflictHistory = ({ surveyId, historyType, sessionId }) => {
|
||||
return axios.get('/surveyHisotry/getConflictList', {
|
||||
params: {
|
||||
surveyId,
|
||||
historyType,
|
||||
sessionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteSurvey = (surveyId) => {
|
||||
return axios.post('/survey/deleteSurvey', {
|
||||
surveyId
|
||||
|
@ -14,22 +14,63 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref, onUnmounted } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import 'element-plus/theme-chalk/src/message.scss'
|
||||
|
||||
import LeftMenu from '@/management/components/LeftMenu.vue'
|
||||
import CommonTemplate from './components/CommonTemplate.vue'
|
||||
import Navbar from './components/ModuleNavbar.vue'
|
||||
|
||||
import axios from '../../api/base'
|
||||
import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine'
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const authCheckInterval = ref<any>(null)
|
||||
const showConfirmBox = () => {
|
||||
const token = store.state.user.userInfo.token
|
||||
ElMessageBox.alert('登录状态已失效,请刷新同步。页面将展示最新保存的内容。', '提示', {
|
||||
confirmButtonText: '确认',
|
||||
callback: () => {
|
||||
axios.get('/auth/statuscheck', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.expired) {
|
||||
store.dispatch('user/logout').then(() => {
|
||||
router.replace({name: 'login'}); // 仍然失效,登出,跳转到登录界面
|
||||
})
|
||||
} else {
|
||||
location.reload(); // 已登录,刷新页面
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("error: " + error);
|
||||
ElMessage.error(error.message || '请求失败');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
const checkAuth = () => {
|
||||
const token = store.state.user.userInfo.token
|
||||
axios.get('/auth/statuscheck', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.expired) {
|
||||
showConfirmBox();
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log("erro:" + error)
|
||||
ElMessage.error(err)
|
||||
});
|
||||
}
|
||||
onMounted(async () => {
|
||||
store.commit('edit/setSurveyId', route.params.id)
|
||||
|
||||
@ -43,6 +84,13 @@ onMounted(async () => {
|
||||
router.replace({ name: 'survey' })
|
||||
}, 1000)
|
||||
}
|
||||
// 启动定时器,每30分钟调用一次
|
||||
authCheckInterval.value = setInterval(() => {
|
||||
checkAuth()
|
||||
}, 30 * 60 * 1000);
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(authCheckInterval.value);
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -4,20 +4,21 @@
|
||||
</el-button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import 'element-plus/theme-chalk/src/message.scss'
|
||||
|
||||
import { publishSurvey, saveSurvey } from '@/management/api/survey'
|
||||
import { publishSurvey, saveSurvey, getConflictHistory } from '@/management/api/survey'
|
||||
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
|
||||
import buildData from './buildData'
|
||||
|
||||
const isPublishing = ref<boolean>(false)
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const saveData = computed(() => {
|
||||
return buildData(store.state.edit.schema, sessionStorage.getItem('sessionUUID'))
|
||||
})
|
||||
const updateLogicConf = () => {
|
||||
if (
|
||||
showLogicEngine.value &&
|
||||
@ -31,6 +32,55 @@ const updateLogicConf = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const checkConflict = async (surveyid:string) => {
|
||||
try {
|
||||
const dailyHis = await getConflictHistory({surveyId: surveyid, historyType: 'dailyHis', sessionId: sessionStorage.getItem('sessionUUID')})
|
||||
console.log(dailyHis)
|
||||
if (dailyHis.data.length > 0) {
|
||||
const lastHis = dailyHis.data.at(0)
|
||||
if (Date.now() - lastHis.createDate > 2 * 60 * 1000) {
|
||||
return [false, '']
|
||||
} else {
|
||||
return [true, lastHis.operator.username]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return [false, '']
|
||||
}
|
||||
const onSave = async () => {
|
||||
let res
|
||||
|
||||
if (!saveData.value.surveyId) {
|
||||
ElMessage.error('未获取到问卷id')
|
||||
return null
|
||||
}
|
||||
// 增加冲突检测
|
||||
const [isconflict, conflictName] = await checkConflict(saveData.value.surveyId)
|
||||
if(isconflict) {
|
||||
if (conflictName == store.state.user.userInfo.username) {
|
||||
ElMessageBox.alert('当前问卷已在其它页面开启编辑,刷新以获取最新内容。', '提示', {
|
||||
confirmButtonText: '确认',
|
||||
callback: () => {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ElMessageBox.alert(`当前问卷2分钟内由${conflictName}编辑,刷新以获取最新内容。`, '提示', {
|
||||
confirmButtonText: '确认',
|
||||
callback: () => {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
// 保存数据
|
||||
res = await saveSurvey(saveData.value)
|
||||
}
|
||||
return res
|
||||
}
|
||||
const handlePublish = async () => {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
@ -46,22 +96,15 @@ const handlePublish = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const saveData = buildData(store.state.edit.schema)
|
||||
if (!saveData.surveyId) {
|
||||
isPublishing.value = false
|
||||
ElMessage.error('未获取到问卷id')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const saveRes: any = await saveSurvey(saveData)
|
||||
if (saveRes.code !== 200) {
|
||||
isPublishing.value = false
|
||||
ElMessage.error(saveRes.errmsg || '问卷保存失败')
|
||||
const saveRes: any = await onSave()
|
||||
if (!saveRes) {
|
||||
return
|
||||
}
|
||||
|
||||
const publishRes: any = await publishSurvey({ surveyId: saveData.surveyId })
|
||||
if(saveRes && saveRes?.code !== 200) {
|
||||
ElMessage.error(`保存失败 ${saveRes.errmsg}`)
|
||||
}
|
||||
const publishRes: any = await publishSurvey({ surveyId: saveData.value.surveyId, sessionId: sessionStorage.getItem('sessionUUID') })
|
||||
if (publishRes.code === 200) {
|
||||
ElMessage.success('发布成功')
|
||||
store.dispatch('edit/getSchemaFromRemote')
|
||||
|
@ -14,15 +14,18 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { get as _get } from 'lodash-es'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import 'element-plus/theme-chalk/src/message.scss'
|
||||
|
||||
import { saveSurvey } from '@/management/api/survey'
|
||||
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
|
||||
import buildData from './buildData'
|
||||
import { getSurveyHistory, getConflictHistory } from '@/management/api/survey'
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isShowAutoSave = ref<boolean>(false)
|
||||
@ -38,15 +41,61 @@ const saveText = computed(
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const saveData = async () => {
|
||||
const saveData = buildData(store.state.edit.schema)
|
||||
onMounted(() => {
|
||||
if (!sessionStorage.getItem('sessionUUID')) {
|
||||
sessionStorage.setItem('sessionUUID', uuidv4());
|
||||
}
|
||||
})
|
||||
const checkConflict = async (surveyid: string) => {
|
||||
try {
|
||||
const dailyHis = await getConflictHistory({surveyId: surveyid, historyType: 'dailyHis', sessionId: sessionStorage.getItem('sessionUUID')})
|
||||
//sconsole.log(dailyHis)
|
||||
if (dailyHis.data.length > 0) {
|
||||
const lastHis = dailyHis.data.at(0)
|
||||
if (Date.now() - lastHis.createDate > 2 * 60 * 1000) {
|
||||
return [false, '']
|
||||
} else {
|
||||
return [true, lastHis.operator.username]
|
||||
}
|
||||
}
|
||||
}catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return [false, '']
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
let res
|
||||
const saveData = buildData(store.state.edit.schema, sessionStorage.getItem('sessionUUID'))
|
||||
|
||||
if (!saveData.surveyId) {
|
||||
ElMessage.error('未获取到问卷id')
|
||||
return null
|
||||
}
|
||||
|
||||
const res = await saveSurvey(saveData)
|
||||
// 增加冲突检测
|
||||
const [isconflict, conflictName] = await checkConflict(saveData.surveyId)
|
||||
if(isconflict) {
|
||||
if (conflictName == store.state.user.userInfo.username) {
|
||||
ElMessageBox.alert('当前问卷已在其它页面开启编辑,刷新以获取最新内容。', '提示', {
|
||||
confirmButtonText: '确认',
|
||||
callback: () => {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ElMessageBox.alert(`当前问卷2分钟内由${conflictName}编辑,刷新以获取最新内容。`, '提示', {
|
||||
confirmButtonText: '确认',
|
||||
callback: () => {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
// 保存数据
|
||||
res = await saveSurvey(saveData)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@ -78,7 +127,7 @@ const triggerAutoSave = () => {
|
||||
isShowAutoSave.value = true
|
||||
nextTick(async () => {
|
||||
try {
|
||||
const res: any = await saveData()
|
||||
const res: any = await handleSave()
|
||||
if (res.code === 200) {
|
||||
autoSaveStatus.value = 'succeed'
|
||||
} else {
|
||||
@ -115,12 +164,17 @@ const handleSave = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res: any = await saveData()
|
||||
const res: any = await onSave()
|
||||
if(!res) {
|
||||
return
|
||||
}
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
}
|
||||
if(res.code !== 200) {
|
||||
ElMessage.error(res.errmsg)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
ElMessage.error('保存问卷失败')
|
||||
} finally {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { pick as _pick, get as _get } from 'lodash-es'
|
||||
|
||||
// 生成需要保存到接口的数据
|
||||
export default function (schema) {
|
||||
export default function (schema, sessionId) {
|
||||
const surveyId = _get(schema, 'metaData._id')
|
||||
const configData = _pick(schema, [
|
||||
'bannerConf',
|
||||
@ -18,6 +18,7 @@ export default function (schema) {
|
||||
delete configData.questionDataList
|
||||
return {
|
||||
surveyId,
|
||||
configData
|
||||
configData,
|
||||
sessionId
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user