feat: 登录失效检测 & 协作冲突检测 (#287)

Co-authored-by: Liuxinyi <liuxy0406@163.com>
Co-authored-by: dayou <853094838@qq.com>
This commit is contained in:
Xinyi Liu 2024-07-05 16:51:51 +08:00 committed by GitHub
parent b5bcb7ff7e
commit 3003c2cbfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 6640 additions and 39 deletions

View File

@ -40,6 +40,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"mongodb": "^5.9.2", "mongodb": "^5.9.2",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-cron": "^3.0.3",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"qiniu": "^7.11.1", "qiniu": "^7.11.1",

View File

@ -18,5 +18,6 @@ export class SurveyHistory extends BaseEntity {
operator: { operator: {
username: string; username: string;
_id: string; _id: string;
sessionId: string;
}; };
} }

View File

@ -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 { ConfigService } from '@nestjs/config';
import { UserService } from '../services/user.service'; import { UserService } from '../services/user.service';
import { CaptchaService } from '../services/captcha.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 { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { create } from 'svg-captcha'; import { create } from 'svg-captcha';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
@ApiTags('auth') @ApiTags('auth')
@Controller('/api/auth') @Controller('/api/auth')
export class AuthController { 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 || '用户凭证检测失败');
}
}
} }

View File

@ -35,4 +35,17 @@ export class AuthService {
} }
return user; 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;
}
} }

View File

@ -130,6 +130,7 @@ export class SurveyController {
const { value, error } = Joi.object({ const { value, error } = Joi.object({
surveyId: Joi.string().required(), surveyId: Joi.string().required(),
configData: Joi.any().required(), configData: Joi.any().required(),
sessionId: Joi.string().required(),
}).validate(surveyInfo); }).validate(surveyInfo);
if (error) { if (error) {
this.logger.error(error.message, { req }); this.logger.error(error.message, { req });
@ -137,7 +138,7 @@ export class SurveyController {
} }
const username = req.user.username; const username = req.user.username;
const surveyId = value.surveyId; const surveyId = value.surveyId;
const sessionId = value.sessionId;
const configData = value.configData; const configData = value.configData;
await this.surveyConfService.saveSurveyConf({ await this.surveyConfService.saveSurveyConf({
surveyId, surveyId,
@ -151,6 +152,7 @@ export class SurveyController {
_id: req.user._id.toString(), _id: req.user._id.toString(),
username, username,
}, },
sessionId: sessionId,
}); });
return { return {
code: 200, code: 200,
@ -271,6 +273,7 @@ export class SurveyController {
) { ) {
const { value, error } = Joi.object({ const { value, error } = Joi.object({
surveyId: Joi.string().required(), surveyId: Joi.string().required(),
sessionId: Joi.string().required(),
}).validate(surveyInfo); }).validate(surveyInfo);
if (error) { if (error) {
this.logger.error(error.message, { req }); this.logger.error(error.message, { req });
@ -278,6 +281,7 @@ export class SurveyController {
} }
const username = req.user.username; const username = req.user.username;
const surveyId = value.surveyId; const surveyId = value.surveyId;
const sessionId = value.sessionId;
const surveyMeta = req.surveyMeta; const surveyMeta = req.surveyMeta;
const surveyConf = const surveyConf =
await this.surveyConfService.getSurveyConfBySurveyId(surveyId); await this.surveyConfService.getSurveyConfBySurveyId(surveyId);
@ -317,6 +321,7 @@ export class SurveyController {
_id: req.user._id.toString(), _id: req.user._id.toString(),
username, username,
}, },
sessionId: sessionId,
}); });
return { return {
code: 200, code: 200,

View File

@ -18,6 +18,7 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger'; import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { val } from 'cheerio/lib/api/attributes';
@ApiTags('survey') @ApiTags('survey')
@Controller('/api/surveyHisotry') @Controller('/api/surveyHisotry')
@ -66,4 +67,52 @@ export class SurveyHistoryController {
data, 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,
};
}
} }

View File

@ -17,8 +17,9 @@ export class SurveyHistoryService {
schema: SurveySchemaInterface; schema: SurveySchemaInterface;
type: HISTORY_TYPE; type: HISTORY_TYPE;
user: any; user: any;
sessionId: string;
}) { }) {
const { surveyId, schema, type, user } = params; const { surveyId, schema, type, user, sessionId } = params;
const newHistory = this.surveyHistory.create({ const newHistory = this.surveyHistory.create({
pageId: surveyId, pageId: surveyId,
type, type,
@ -26,6 +27,7 @@ export class SurveyHistoryService {
operator: { operator: {
_id: user._id.toString(), _id: user._id.toString(),
username: user.username, username: user.username,
sessionId: sessionId,
}, },
}); });
return this.surveyHistory.save(newHistory); return this.surveyHistory.save(newHistory);
@ -50,4 +52,29 @@ export class SurveyHistoryService {
select: ['createDate', 'operator', 'type', '_id'], 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

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,7 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"uuid": "^10.0.0",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",

View File

@ -20,13 +20,13 @@ export const getSurveyById = (id) => {
}) })
} }
export const saveSurvey = ({ surveyId, configData }) => { export const saveSurvey = ({ surveyId, configData, sessionId }) => {
return axios.post('/survey/updateConf', { surveyId, configData }) return axios.post('/survey/updateConf', { surveyId, configData, sessionId })
} }
export const publishSurvey = ({ surveyId }) => { export const publishSurvey = ({ surveyId, sessionId }) => {
return axios.post('/survey/publishSurvey', { 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) => { export const deleteSurvey = (surveyId) => {
return axios.post('/survey/deleteSurvey', { return axios.post('/survey/deleteSurvey', {
surveyId surveyId

View File

@ -14,22 +14,63 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, ref, onUnmounted } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router' 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 'element-plus/theme-chalk/src/message.scss'
import LeftMenu from '@/management/components/LeftMenu.vue' import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue' import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue' import Navbar from './components/ModuleNavbar.vue'
import axios from '../../api/base'
import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine' import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine'
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
const route = useRoute() 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 () => { onMounted(async () => {
store.commit('edit/setSurveyId', route.params.id) store.commit('edit/setSurveyId', route.params.id)
@ -43,6 +84,13 @@ onMounted(async () => {
router.replace({ name: 'survey' }) router.replace({ name: 'survey' })
}, 1000) }, 1000)
} }
// 30
authCheckInterval.value = setInterval(() => {
checkAuth()
}, 30 * 60 * 1000);
})
onUnmounted(() => {
clearInterval(authCheckInterval.value);
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -4,20 +4,21 @@
</el-button> </el-button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useRouter } from 'vue-router' 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 'element-plus/theme-chalk/src/message.scss'
import { publishSurvey, saveSurvey, getConflictHistory } from '@/management/api/survey'
import { publishSurvey, saveSurvey } from '@/management/api/survey'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import buildData from './buildData' import buildData from './buildData'
const isPublishing = ref<boolean>(false) const isPublishing = ref<boolean>(false)
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
const saveData = computed(() => {
return buildData(store.state.edit.schema, sessionStorage.getItem('sessionUUID'))
})
const updateLogicConf = () => { const updateLogicConf = () => {
if ( if (
showLogicEngine.value && 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 () => { const handlePublish = async () => {
if (isPublishing.value) { if (isPublishing.value) {
return return
@ -46,22 +96,15 @@ const handlePublish = async () => {
return return
} }
const saveData = buildData(store.state.edit.schema)
if (!saveData.surveyId) {
isPublishing.value = false
ElMessage.error('未获取到问卷id')
return
}
try { try {
const saveRes: any = await saveSurvey(saveData) const saveRes: any = await onSave()
if (saveRes.code !== 200) { if (!saveRes) {
isPublishing.value = false
ElMessage.error(saveRes.errmsg || '问卷保存失败')
return return
} }
if(saveRes && saveRes?.code !== 200) {
const publishRes: any = await publishSurvey({ surveyId: saveData.surveyId }) ElMessage.error(`保存失败 ${saveRes.errmsg}`)
}
const publishRes: any = await publishSurvey({ surveyId: saveData.value.surveyId, sessionId: sessionStorage.getItem('sessionUUID') })
if (publishRes.code === 200) { if (publishRes.code === 200) {
ElMessage.success('发布成功') ElMessage.success('发布成功')
store.dispatch('edit/getSchemaFromRemote') store.dispatch('edit/getSchemaFromRemote')

View File

@ -14,15 +14,18 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { get as _get } from 'lodash-es' 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 'element-plus/theme-chalk/src/message.scss'
import { saveSurvey } from '@/management/api/survey' import { saveSurvey } from '@/management/api/survey'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import buildData from './buildData' import buildData from './buildData'
import { getSurveyHistory, getConflictHistory } from '@/management/api/survey'
const isSaving = ref<boolean>(false) const isSaving = ref<boolean>(false)
const isShowAutoSave = ref<boolean>(false) const isShowAutoSave = ref<boolean>(false)
@ -38,15 +41,61 @@ const saveText = computed(
const store = useStore() const store = useStore()
const saveData = async () => { onMounted(() => {
const saveData = buildData(store.state.edit.schema) 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) { if (!saveData.surveyId) {
ElMessage.error('未获取到问卷id') ElMessage.error('未获取到问卷id')
return null 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 return res
} }
@ -78,7 +127,7 @@ const triggerAutoSave = () => {
isShowAutoSave.value = true isShowAutoSave.value = true
nextTick(async () => { nextTick(async () => {
try { try {
const res: any = await saveData() const res: any = await handleSave()
if (res.code === 200) { if (res.code === 200) {
autoSaveStatus.value = 'succeed' autoSaveStatus.value = 'succeed'
} else { } else {
@ -115,12 +164,17 @@ const handleSave = async () => {
} }
try { try {
const res: any = await saveData() const res: any = await onSave()
if(!res) {
return
}
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('保存成功') ElMessage.success('保存成功')
} else { }
if(res.code !== 200) {
ElMessage.error(res.errmsg) ElMessage.error(res.errmsg)
} }
} catch (error) { } catch (error) {
ElMessage.error('保存问卷失败') ElMessage.error('保存问卷失败')
} finally { } finally {

View File

@ -1,7 +1,7 @@
import { pick as _pick, get as _get } from 'lodash-es' 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 surveyId = _get(schema, 'metaData._id')
const configData = _pick(schema, [ const configData = _pick(schema, [
'bannerConf', 'bannerConf',
@ -18,6 +18,7 @@ export default function (schema) {
delete configData.questionDataList delete configData.questionDataList
return { return {
surveyId, surveyId,
configData configData,
sessionId
} }
} }