本文最后更新于:2 个月前
技术选型 架构设计 编码开发 很好啊,前后端项目模板跑起来了:
2024年5月5日
框架构建 安装 | Vue CLI (vuejs.org)
Arco Design Vue
1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <BasicLayout /> </template> <style></style> <script> import BasicLayout from "@/layouts/BasicLayout"; export default { components: { BasicLayout }, }; </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <template> <GlobalHeader /> </template> <script> import GlobalHeader from "@/components/GlobalHeader"; export default { name: "BasicLayout", components: { GlobalHeader }, }; </script> <style scoped></style>
1 2 3 4 5 6 7 8 9 10 11 12 <template> <div></div> </template> <script> export default { name: "GlobalHeader", }; </script> <style scoped></style>
路由跳转 router 下的 index.ts:
1 2 3 4 5 6 7 8 9 10 import { createRouter, createWebHistory } from "vue-router" ;import { routes } from "@/router/routes" ;const router = createRouter ({ history : createWebHistory (process.env .BASE_URL ), routes, });export default router;
router 下的 router.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { RouteRecordRaw } from "vue-router" ;import HomeView from "@/views/HomeView.vue" ;export const routes : Array <RouteRecordRaw > = [ { path : "/" , name : "首页" , component : HomeView , }, { path : "/about" , name : "关于" , component : () => import ( "../views/AboutView.vue" ), }, ];
动态导航栏:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <a-menu mode="horizontal" :selected-keys="selectedKeys" @menu-item-click="doMenuClick" > <a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled > <div class="title-bar"> <img class="logo" src="../assets/oj-logo.svg" /> <div class="title">OJ</div> </div> </a-menu-item> <a-menu-item v-for="item in routes" :key="item.path"> {{ item.name }} </a-menu-item> </a-menu>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { ref } from "vue" ;import { useRouter } from "vue-router" ;import { routes } from "@/router/routes" ;const router = useRouter ();const selectedKeys = ref (["/" ]); router.afterEach ((to, from , failure ) => { selectedKeys.value = [to.path ]; });const doMenuClick = (key: string ) => { router.push ({ path : key, }); };
效果如下:
全局状态管理 vuex/examples/classic/shopping-cart/store/index.js at main · vuejs/vuex (github.com)
开始 | Vuex (vuejs.org)
store 下的 user.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { StoreOptions } from "vuex" ;export default { namespaced : true , state : () => ({ loginUser : { userName : "未登录" , }, }), actions : { async getLoginUser ({ commit, state }, payload ) { commit ("updateUser" , { userName : "memory" }); }, }, mutations : { updateUser (state, payload ) { state.loginUser = payload; }, }, } as StoreOptions <any >;
index.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 import { createStore } from "vuex" ;import user from "@/store/user" ;export default createStore ({ state : {}, getters : {}, mutations : {}, actions : {}, modules : { user, }, });
main.ts
1 2 3 4 import store from "./store" ;createApp (App ).use (ArcoVue ).use (store).use (router).mount ("#app" );
store 原理(执行流程)
1 2 3 4 5 6 setTimeout (() => { store.dispatch ("user/getLoginUser" , { userName : "回忆如初" , }); }, 3000 );
这样引入 store,执行 dispatch方法,根据 actions 下的路径,提供参数,执行 mutations 下的方法。
1 2 3 4 5 actions : { async getLoginUser ({ commit, state }, payload ) { commit ("updateUser" , payload); }, },
1 2 3 4 5 mutations : { updateUser (state, payload ) { state.loginUser = payload; }, },
mutations 改变了 state 的值,根据传入的参数改变了。
我们尝试在页面获取 store 值,并展示:
1 2 3 4 5 <a-col flex="100px"> <div> {{ store.state.user?.loginUser?.userName ?? "未登录" }} </div> </a-col>
效果如下:
另外,action 这里可以写死,不接受参数:
1 2 3 4 5 actions : { async getLoginUser ({ commit, state }, payload ) { commit ("updateUser" , { userName : "memory" }); }, },
全局权限管理 关键在于这段逻辑,App.vue 下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import router from "@/router" ;import store from "@/store" ; router.beforeEach ((to, from , next ) => { if (to.meta ?.access === "canAdmin" ) { if (store.state .user ?.loginUser ?.role !== "admin" ) { next ("/noAuth" ); return ; } } next (); });
设置路由访问权限,必须为管理员可见:
1 2 3 4 5 6 7 8 { path : "/auth" , name : "管理员可见" , component : AuthView , meta : { access : "canAdmin" , }, },
默认用户权限,测试用:
1 2 3 4 5 6 state : () => ({ loginUser : { userName : "未登录" , role : "admin" , }, }),
我们发现能正常跳转页面,但这样做:
1 2 3 4 5 6 7 setTimeout (() => { store.dispatch ("user/getLoginUser" , { userName : "回忆如初" , role : "noAdmin" , }); }, 3000 );
三秒过后,该用户不是管理员权限,访问一个管理员可见的页面,直接重定向:
隐藏菜单 构造通用的导航栏组件,根据配置控制菜单栏的显隐
1 2 3 4 5 6 7 8 { path : "/hide" , name : "隐藏页面" , component : noAuthView, meta : { hideInMenu : true , }, },
过滤,仅展示显示在菜单上的路由数组
1 2 3 <a-menu-item v-for="item in visibleRoutes" :key="item.path"> {{ item.name }} </a-menu-item>
1 2 3 4 5 6 import { routes } from "@/router/routes" ;const visibleRoutes = routes.filter ((item, index ) => { return !item.meta ?.hideInmenu ; });
除了 根据配置权限隐藏菜单 ,还需要根据用户权限,只有具有相关权限的用户,才能看到该菜单。
检测用户权限:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import ACCESS_ENUM from "@/access/accessEnum" ;const checkAccess = (loginUser: any , needAccess = ACCESS_ENUM.NOT_LOGIN ) => { const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM .NOT_LOGIN ; if (needAccess === ACCESS_ENUM .NOT_LOGIN ) { return true ; } if (needAccess === ACCESS_ENUM .USER ) { if (loginUserAccess === ACCESS_ENUM .NOT_LOGIN ) { return false ; } } if (needAccess === ACCESS_ENUM .ADMIN ) { if (loginUserAccess !== ACCESS_ENUM .ADMIN ) { return false ; } } return true ; };export default checkAccess;
使用计算属性,使得用户信息发生变更时,触发菜单栏的重新渲染,。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const visibleRoutes = computed (() => { return routes.filter ((item, index ) => { if (item.meta ?.hideInmenu ) { return false ; } if ( !checkAccess (store.state .user ?.loginUser , item.meta ?.access as string ) ) { return false ; } return true ; }); });
全局项目入口 1 2 3 4 5 6 7 const doInit = ( ) => { console .log ("项目全局入口" ); };onMounted (() => { doInit (); });
库表设计 题目表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 create table if not exists question ( id bigint auto_increment comment 'id' primary key, title varchar (512 ) null comment '标题' , content text null comment '内容' , tags varchar (1024 ) null comment '标签列表(json 数组)' , answer text null comment '题目答案' , submitNum int default 0 not null comment '题目提交数' , acceptedNum int default 0 not null comment '题目通过数' , judgeCase text null comment '判题用例(json 数组)' , judgeConfig text null comment '判题配置(json 对象)' , thumbNum int default 0 not null comment '点赞数' , favourNum int default 0 not null comment '收藏数' , userId bigint not null comment '创建用户 id' , createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' , isDelete tinyint default 0 not null comment '是否删除' , index idx_userId (userId) ) comment '题目' collate = utf8mb4_unicode_ci;
数据库字段存 json 字符串,便于扩展:判题用例 、判题配置 、判题信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Data public class JudgeConfig { private Long timeLimit; private Long memoryLimit; private Long stackLimit; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Data public class JudgeCase { private String input; private String output; }
题目提交表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 create table if not exists question_submit ( id bigint auto_increment comment 'id' primary key, language varchar (128 ) not null comment '编程语言' , code text not null comment '用户代码' , judgeInfo text null comment '判题信息(json 对象)' , status int default 0 not null comment '判题状态(0 - 待判题、1 - 判题中、2 - 成功、3 - 失败)' , questionId bigint not null comment '题目 id' , userId bigint not null comment '创建用户 id' , createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' , isDelete tinyint default 0 not null comment '是否删除' , index idx_questionId (questionId), index idx_userId (userId) ) comment '题目提交' ;
枚举类构建 构建枚举类:判题信息 、判题状态 、编程语言
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public enum JudgeInfoMessageEnum { ACCEPTED("成功" , "Accepted" ), WRONG_ANSWER("答案错误" , "Wrong Answer" ), COMPILE_ERROR("Compile Error" , "编译错误" ), MEMORY_LIMIT_EXCEEDED("" , "内存溢出" ), TIME_LIMIT_EXCEEDED("Time Limit Exceeded" , "超时" ), PRESENTATION_ERROR("Presentation Error" , "展示错误" ), WAITING("Waiting" , "等待中" ), OUTPUT_LIMIT_EXCEEDED("Output Limit Exceeded" , "输出溢出" ), DANGEROUS_OPERATION("Dangerous Operation" , "危险操作" ), RUNTIME_ERROR("Runtime Error" , "运行错误" ), SYSTEM_ERROR("System Error" , "系统错误" ); .............................. }
1 2 3 4 5 6 7 8 public enum QuestionSubmitLanguageEnum { JAVA("java" , "java" ), CPLUSPLUS("cpp" , "cpp" ), GOLANG("go" , "go" ); ............................. }
1 2 3 4 5 6 7 8 9 10 public enum QuestionSubmitStatusEnum { WAITING("等待中" , 0 ), RUNNING("判题中" , 1 ), SUCCEED("成功" , 2 ), FAILED("失败" , 3 ); ............................... }
校验题目是否合法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Override public void validQuestion (Question question, boolean add) { if (question == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } String title = question.getTitle(); String content = question.getContent(); String tags = question.getTags(); String answer = question.getAnswer(); String judgeCase = question.getJudgeCase(); String judgeConfig = question.getJudgeConfig(); if (add) { ThrowUtils.throwIf(StringUtils.isAnyBlank(title, content, tags), ErrorCode.PARAMS_ERROR); } if (StringUtils.isNotBlank(title) && title.length() > 80 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "标题过长" ); } if (StringUtils.isNotBlank(content) && content.length() > 8192 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "内容过长" ); } if (StringUtils.isNotBlank(answer) && answer.length() > 8192 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "答案过长" ); } if (StringUtils.isNotBlank(judgeCase) && judgeCase.length() > 8192 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "判题用例过长" ); } if (StringUtils.isNotBlank(judgeConfig) && judgeConfig.length() > 8192 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "判题配置过长" ); } }
QuestionVO 封装了脱敏 Question 的题目答案 answer 和判题用例 judgeCase 字段,以下是包装类 QuestionVO 和原类Question 的字段属性对比:
1 2 3 4 5 6 7 8 9 10 private List<String> tags; private JudgeConfig judgeConfig;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 private String tags; private String judgeCase; private String judgeConfig;
QuestionVO 内置了包装类与原对象互相转换的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public static QuestionVO objToVo (Question question) { if (question == null ) { return null ; } QuestionVO questionVO = new QuestionVO (); BeanUtils.copyProperties(question, questionVO); List<String> tagList = JSONUtil.toList(question.getTags(), String.class); questionVO.setTags(tagList); String judgeConfigStr = question.getJudgeConfig(); questionVO.setJudgeConfig(JSONUtil.toBean(judgeConfigStr, JudgeConfig.class)); return questionVO; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public static Question voToObj (QuestionVO questionVO) { if (questionVO == null ) { return null ; } Question question = new Question (); BeanUtils.copyProperties(questionVO, question); List<String> tagList = questionVO.getTags(); if (tagList != null ) { question.setTags(JSONUtil.toJsonStr(tagList)); } JudgeConfig voJudgeConfig = questionVO.getJudgeConfig(); if (voJudgeConfig != null ) { question.setJudgeConfig(JSONUtil.toJsonStr(voJudgeConfig)); } return question; }
封装查询请求参数 QuestionQueryRequest,封装获取查询包装类,动态判断用户根据哪些字段查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 queryWrapper.like(StringUtils.isNotBlank(title), "title" , title); queryWrapper.like(StringUtils.isNotBlank(content), "content" , content); queryWrapper.like(StringUtils.isNotBlank(answer), "answer" , answer); if (CollectionUtils.isNotEmpty(tags)) { for (String tag : tags) { queryWrapper.like("tags" , "\"" + tag + "\"" ); } } queryWrapper.eq(ObjectUtils.isNotEmpty(id), "id" , id); queryWrapper.eq(ObjectUtils.isNotEmpty(userId), "userId" , userId); queryWrapper.eq("isDelete" , false ); queryWrapper.orderBy(SqlUtils.validSortField(sortField), sortOrder.equals(CommonConstant.SORT_ORDER_ASC), sortField); return queryWrapper;
阶段性总结 通用的的业务校验流程:
添加新题目,进行类成员变量字段 -> json 的转换,封装了 validQuestion 方法判断参数是否合法。为新题目设置 userId 等字段属性,执行 SQL 语句插入该新纪录并校验是否执行成功。
删除题目,封装 DeleteRequest 删除请求参数,携带删除题目 id,首先根据题目 id 查询数据库,判断该题目是否存在;根据查询得到的题目记录的 userId,判断执行该操作的用户是否为出题用户或者管理员,否则不予执行删除操作。执行 SQL 语句删除记录并校验是否执行成功。
更新题目(管理员),进行类成员变量字段 -> json 的转换,封装了 validQuestion 方法判断参数是否合法。在更新题目记录之前,根据题目 id 检查该题目是否存在,再执行 SQL 语句更新该记录并校验是否执行成功。
编辑题目(普通用户),在管理员更新题目信息中,添加了用户信息校验,只有出题用户和管理员可编辑:
1 2 3 4 5 6 7 8 9 User loginUser = userService.getLoginUser(request); long id = questionEditRequest.getId(); Question oldQuestion = questionService.getById(id); ThrowUtils.throwIf(oldQuestion == null , ErrorCode.NOT_FOUND_ERROR); if (!oldQuestion.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) { throw new BusinessException (ErrorCode.NO_AUTH_ERROR); }
根据 id 获取题目,校验用户是否为本人或管理员,否则拿不到题目信息
1 2 3 4 @Override public boolean isAdmin (User user) { return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole()); }
根据 id 获取题目信息(脱敏),并关联查询用户信息
1 return ResultUtils.success(questionService.getQuestionVO(question, request));
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override public QuestionVO getQuestionVO (Question question, HttpServletRequest request) { QuestionVO questionVO = QuestionVO.objToVo(question); Long userId = question.getUserId(); User user = null ; if (userId != null && userId > 0 ) { user = userService.getById(userId); } UserVO userVO = userService.getUserVO(user); questionVO.setUserVO(userVO); return questionVO; }
封装查询请求参数类 QuestionQueryRequest,封装获取查询包装类方法 getQueryWrapper,批量分页获取题目列表(脱敏),并关联查询用户信息:
1 2 3 Page<Question> questionPage = questionService.page(new Page <>(current, size), questionService.getQueryWrapper(questionQueryRequest)); return ResultUtils.success(questionService.getQuestionVOPage(questionPage, request));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Set<Long> userIdSet = questionList.stream().map(Question::getUserId).collect(Collectors.toSet()); Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream() .collect(Collectors.groupingBy(User::getId)); List<QuestionVO> questionVOList = questionList.stream().map(question -> { QuestionVO questionVO = QuestionVO.objToVo(question); Long userId = question.getUserId(); User user = null ; if (userIdUserListMap.containsKey(userId)) { user = userIdUserListMap.get(userId).get(0 ); } questionVO.setUserVO(userService.getUserVO(user)); return questionVO; }).collect(Collectors.toList()); questionVOPage.setRecords(questionVOList);
获取用户创建的题目列表,基本同上,查询请求参数 QuestionQueryRequest 携带该用户信息:
1 2 User loginUser = userService.getLoginUser(request); questionQueryRequest.setUserId(loginUser.getId());
简单的获取所有题目信息列表,仅管理员可用
用户提交题目,封装提交题目请求参数 QuestionSubmitAddRequest,判断编程语言是否合法、题目是否存在,直接存入题目提交信息,再异步执行判题服务:
1 2 3 4 5 6 7 8 9 10 11 12 QuestionSubmit questionSubmit = new QuestionSubmit (); questionSubmit.setUserId(userId); questionSubmit.setQuestionId(questionId); questionSubmit.setCode(questionSubmitAddRequest.getCode()); questionSubmit.setLanguage(language); questionSubmit.setStatus(QuestionSubmitStatusEnum.WAITING.getValue()); questionSubmit.setJudgeInfo("{}" ); boolean save = this .save(questionSubmit); if (!save){ throw new BusinessException (ErrorCode.SYSTEM_ERROR, "数据插入失败" ); }
1 2 3 4 5 Long questionSubmitId = questionSubmit.getId(); CompletableFuture.runAsync(() -> { judgeService.doJudge(questionSubmitId); });
封装提交题目查询参数 QuestionSubmitQueryRequest,分页查询已提交的题目,只有提交者本人和管理员才可以看到提交代码
1 2 3 4 5 6 long userId = loginUser.getId(); if (userId != questionSubmit.getUserId() && !userService.isAdmin(loginUser)) { questionSubmitVO.setCode(null ); }
1 2 3 4 List<QuestionSubmitVO> questionSubmitVOList = questionSubmitList.stream() .map(questionSubmit -> getQuestionSubmitVO(questionSubmit, loginUser)) .collect(Collectors.toList());
2024年5月4日
核心功能:提交代码,权限校验;代码沙箱,判题服务;判题规则,结果比对验证;任务调度
核心业务流程:时序图
报错解决指南 前端生成请求接口后报错
这是因为 OpenAPI 生成的请求接口是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static getLoginUserUsingGet (): CancelablePromise <BaseResponse _LoginUserVO_> { return __request (OpenAPI , { method : 'GET' , url : '/api/user/get/login' , errors : { 401 : `Unauthorized` , 403 : `Forbidden` , 404 : `Not Found` , }, }); }
修改 OpenAPIConfig 即可:
1 2 3 4 5 6 7 8 9 10 11 export const OpenAPI : OpenAPIConfig = { BASE : 'http://localhost:8101/' , VERSION : '1.0' , WITH_CREDENTIALS : false , CREDENTIALS : 'include' , TOKEN : undefined , USERNAME : undefined , PASSWORD : undefined , HEADERS : undefined , ENCODE_PATH : undefined , };