目录
一、需求分析
目标是明确要做的需求,并且给需求设置优先级,从而明确开发计划。
a.项目功能梳理
按照模块梳理功能:
• 用户模块
- 注册
- 登录
- 管理用户 - 增删改查(仅管理员可用)
• 应用模块
- 创建应用
- 修改应用
- 删除应用
- 查看应用列表
- 查看应用详情
- 查看自己创建的应用
- 管理应用 - 增删改查(仅管理员可用)
- 审核发布和下架应用(仅管理员可用)
- 应用分享(扫码查看)
• 题目模块
- 创建题目(包括题目选项得分设置)
- 修改题目
- 删除题目
- 管理题目 - 增删改查(仅管理员可用)
- AI 生成题目
• 评分模块
- 创建评分结果
- 修改评分结果
- 删除评分结果
- 根据回答计算评分结果(多种评分策略)
- 自定义规则评分 - 测评类
- 自定义规则评分 - 打分类
- AI 评分
- 管理评分结果 - 增删改查(仅管理员可用)
• 回答模块
- 提交回答(创建)
- 查看某次回答的评分结果
- 查看自己提交的回答列表
- 管理回答 - 增删改查(仅管理员可用)
• 统计分析模块
- 应用评分结果分析和查看
b.核心业务流程
流程图:
文字描述:
- 用户注册 => 用户登录
- 用户创建应用 => 创建题目(包括题目选项得分)=> 创建评分规则(评分策略和评分结果)
- 管理员管理应用,审核发布(或下架)应用1808505663815548929_0.618888251920501
- 用户查看和检索应用列表,进入应用详情页,在线答题并提交回答
- 经过评分模块计算后,用户可查看本次评分结果
c.需求优先级
根据核心业务业务流程,明确需求开发的优先级。
- P0 为核心,非做不可
- P1 为重点功能,最好做
- P2 为实用功能,有空就做
- P3 可做可不做,时间充裕再做
排好优先级的需求列表如下,其实用表格的形式整理更好:
- 用户模块
- 注册 P0
- 登录 P0
- 管理用户 - 增删改查(仅管理员可用)P1
应用模块
- 创建应用 P0
- 修改应用 P1
- 删除应用 P1
- 查看应用列表 P0
- 查看应用详情 P0
- 查看自己创建的应用 P1
- 管理应用 - 增删改查(仅管理员可用)P0
- 审核发布和下架应用(仅管理员可用)P0
- 应用分享(扫码查看)P2
- 题目模块
- 创建题目(包括题目选项得分设置)P0
- 修改题目 P1
- 删除题目 P1
- 管理题目 - 增删改查(仅管理员可用)P1
- AI 生成题目 P1
- 评分模块
- 创建评分结果 P0
- 修改评分结果 P1
- 删除评分结果 P1
- 根据回答计算评分结果(多种评分策略)
- 自定义规则评分 - 测评类 P0
- 自定义规则评分 - 打分类 P0
- AI 评分 P1
- 管理评分结果 - 增删改查(仅管理员可用)P1
- 回答模块
- 提交回答(创建)P0
- 查看某次回答的评分结果 P0
- 查看自己提交的回答列表 P1
- 管理回答 - 增删改查(仅管理员可用)P1
- 统计分析模块
- 应用评分结果分析和查看 P2
排好优先级后,就可以根据优先级去设计接口和页面了。
企业中一般使用专业的系统或者表格来管理需求:
二、库表设计
对应需求分析中的功能梳理的模块,应该有 6 张表(统计分析表本节先不做)。
库名:yudada
数据库初始化文件:
- 创建库表:create_table.sql
- 初始数据:init_data.sql
创建时间、更新时间、是否删除等字段表中一定要有。
a.用户表
b.应用表
审核字段 4 件套:
reviewStatus int default 0 not null comment '审核状态:0-待审核, 1-通过, 2-拒绝', reviewMessage varchar(512) null comment '审核信息', reviewerId bigint null comment '审核人 id', reviewTime datetime null comment '审核时间',
c.题目表
每个应用对应一个题目表的记录,使用 questionContent 这一 JSON 字段,整体更新和维护该应用的题目列表、选项信息。(方便后期排序题目等功能完善)
questionContent 结构,注意区分 result 和 score,分别用于统计两种不同应用类型的结果。
[ { "options": [ { "result": "I", // 如果是测评类,则用 reslut 来保存答案属性 "score": 1, // 如果是得分类,则用 score 来设置本题分数 "value": "A选项", //选项内容 "key": "A" //选项 key }, { "result": "E", // 如果是测评类,则用 reslut 来保存答案属性 "score": 0, "value": "B选项", "key": "B" } ], "title": "题目" } ]
d.评分结果表
用户提交答案后,会获得一定的回答评定,例如 ISTJ 之类的,评分结果表就是存储这些数据的表。
**不同类型的应用使用不同的字段。**测评类用 resultProp,得分类用 resultScoreRange。
测评类应用
resultProp 的结构,结果属性集合 JSON:
["I", "S", "T", "J"]
得分类应用
默认 resultScoreRange 字段的使用规则是“大于等于设定的分数则命中对应的 result”。
举一个得分类应用的例子:
- resultName:知识大师
- resultDesc:你真棒棒哦,知识掌握地非常出色!
- resultIcon:预留字段,如果想界面好看点,可以给 result 设定图片
- resultScoreRange:9
示例数据如下:
-- 得分 评分结果初始化 INSERT INTO demo.scoring_result (id, resultName, resultDesc, resultPicture, resultProp, resultScoreRange, createTime, updateTime, isDelete, appId, userId) VALUES (17, '知识大师', '你真棒棒哦,知识掌握地非常出色!', null, null, 9, '2024-04-25 15:05:44', '2024-05-09 12:28:21', 0, 2, 1); INSERT INTO demo.scoring_result (id, resultName, resultDesc, resultPicture, resultProp, resultScoreRange, createTime, updateTime, isDelete, appId, userId) VALUES (18, '地理小能手!', '你对于地理知识了解得相当不错,但还有一些小地方需要加强哦!', null, null, 7, '2024-04-25 15:05:44', '2024-05-09 12:28:21', 0, 2, 1); INSERT INTO demo.scoring_result (id, resultName, resultDesc, resultPicture, resultProp, resultScoreRange, createTime, updateTime, isDelete, appId, userId) VALUES (19, '继续加油!', '还需努力哦', null, null, 0, '2024-04-25 15:05:44', '2024-05-09 12:28:21', 0, 2, 1);
e.用户答题记录表
其中字段存在冗余设计(某些字段在其他表中也有),通过冗余设计可以避免多表联查,提高查询性能。
为什么要有冗余字段?因为回答记录一旦设置,几乎不会更改,便于查询,不用联表,节约开发成本。
还有可能通过异步的方式、或者题目答案没提交(只答一半的时候),先临时保存回答记录。
resultId 可能为空,是因为 AI 分析策略不会从结果表中选取结果,没有 resultId。
choices 是 JSON 结构,题目选项的 key 数组:
["A", "B", "C"]
只存储选项的优点是,可以节约存储空间;但缺点是应用的题目如果发生修改,就对应不上了。
如果更严谨一些,可能要对应题目的 id(或者题目编号、题目的 key),比如:
{ 1: "A", 2: "B" }
三、后端项目初始化
a.打开后端模板项目
b.准备依赖
c.执行模板的初始化 SQL 脚本,运行模板
d.模板改造
- 改模块名(全局替换)
ctrl+shift+R修改(注意当前目录级别)
- 改包名(重构 + 全局替换)
- 移除不必要的模块(Elasticsearch、微信开发、表格处理、定时任务相关代码)。不移除也不影响项目运行。
在pop.xml中删除依赖,哪里报错删哪里;
将显而易见的相关代码删掉;
本项目去掉es搜索和wx
- 执行真实业务的库表初始化脚本,更改数据库等配置为自己的,尝试重新运行项目。
四、后端基础开发-增删改查
对着项目功能梳理,就是 5 张表最基础的增删改查,先不包含任何复杂的业务逻辑。
每个模块都要遵循如下的开发流程:
a. 数据访问层代码生成
MyBatisX 插件生成 mapper 和数据库实体类,生成后移动到项目对应位置。
首先(user表不选,因为模板已经实现)
然后
然后
最后
生成的代码移动到对应的模块目录(service层不移动,后面要生成业务逻辑);
b. 业务逻辑代码生成
使用万用模板的代码生成器工具(CodeGenerator)生成代码,包括:Controller、Service 接口和实现类、数据模型包装类和枚举类
首先修改指定生成参数和路径
根据路径会在项目的根目录下生成一个generator
如法炮制将其他表生成业务逻辑代码
获得代码
最后将代码移动到各个controler等层中
c.数据模型开发
编写数据模型包装类(请求类和视图类)、JSON 结构对应的类、枚举类。
JSON结构对应的类是为了保存答案属性、本题分数等信息;
枚举类是为了展示给用户可以选择创建哪些类试题;
VO层面;
包装类
包装类需要根据前端实际传递的请求参数或需要的响应结果自行修改。
例如对库表的增删改查中的question表中的字段,前端传递过来JSON格式的数据,后端需要转换为 (便于进行校验等操作),进入数据库前再转换为JSON格式。
JSON 结构对应的类可以利用 CodeGeex AI 编程助手生成,prompt 如下:
帮我把下列 json 结构转为使用 lombok 的 java 对象: { "options": [ { "result": "I", // 如果是测评类,则用 reslut 来保存答案属性 "score": 1, // 如果是得分类,则用 score 来设置本题分数 "value": "A选项", //选项内容 "key": "A" //选项 key }, { "result": "E", // 如果是测评类,则用 reslut 来保存答案属性 "score": 0, "value": "B选项", "key": "B" } ], "title": "题目" }
生成的结果如下:
import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.Builder; import java.util.List; @Data @NoArgsConstructor @AllArgsConstructor @Builder public class Question { private String title; private List<Option> options; @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class Option { private String result; private int score; private String value; private String key; } }
修改加入对库表增删改查的包中
枚举类
关于枚举类,需要编写的枚举字段:
appType tinyint default 0 not null comment '应用类型(0-得分类,1-测评类)', scoringStrategy tinyint default 0 not null comment '评分策略(0-自定义,1-AI)', reviewStatus int default 0 not null comment '审核状态:0-待审核, 1-通过, 2-拒绝',
作为示例,提供审核状态枚举类代码:
package com.yupi.yudada.model.enums; import cn.hutool.core.util.ObjectUtil; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; /** * 审核状态枚举 * * @author <a href="https://github.com/liyupi">程序员鱼皮</a> * @from <a href="https://www.code-nav.cn">编程导航学习圈</a> */ public enum ReviewStatusEnum { REVIEWING("待审核", 0), PASS("通过", 1), REJECT("拒绝", 2); private final String text; private final int value; ReviewStatusEnum(String text, int value) { this.text = text; this.value = value; } /** * 根据 value 获取枚举 * * @param value * @return */ public static ReviewStatusEnum getEnumByValue(Integer value) { if (ObjectUtil.isEmpty(value)) { return null; } for (ReviewStatusEnum anEnum : ReviewStatusEnum.values()) { if (anEnum.value == value) { return anEnum; } } return null; } /** * 获取值列表 * * @return */ public static List<Integer> getValues() { return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList()); } public int getValue() { return value; } public String getText() { return text; } }
据此修改为具体的枚举类代码
package com.yupi.springbootinit.model.enums; import cn.hutool.core.util.ObjectUtil; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; /** * App应用类型枚举 * * @author <a href="https://github.com/liyupi">程序员鱼皮</a> * @from <a href="https://www.code-nav.cn">编程导航学习圈</a> */ public enum AppTypeEnum { SCORE("得分类", 0), TEST("测评类", 1); private final String text; private final int value; AppTypeEnum(String text, int value) { this.text = text; this.value = value; } /** * 根据 value 获取枚举 * * @param value * @return */ public static AppTypeEnum getEnumByValue(Integer value) { if (ObjectUtil.isEmpty(value)) { return null; } for (AppTypeEnum anEnum : AppTypeEnum.values()) { if (anEnum.value == value) { return anEnum; } } return null; } /** * 获取值列表 * * @return */ public static List<Integer> getValues() { return Arrays.stream(AppTypeEnum.values()).map(item -> item.value).collect(Collectors.toList()); } public int getValue() { return value; } public String getText() { return text; } }
VO视图
展示给用户
根据库表写VO
d. 对库表的增删改查
entity中是库表的字段映射出来的属性;
dto是对库表的增删改查;
对于前端确定哪些字段是必填的(必填的就从entity中取出);
dto
对于app应用表,AppAddRequest(创建应用请求)为:
package com.yupi.springbootinit.model.dto.app; import lombok.Data; import java.io.Serializable; /** * 创建应用请求 * * @author <a href="https://github.com/liyupi">程序员鱼皮</a> * @from <a href="https://www.code-nav.cn">编程导航学习圈</a> */ @Data public class AppAddRequest implements Serializable { /** * 应用名 */ private String appName; /** * 应用描述 */ private String appDesc; /** * 应用图标 */ private String appIcon; /** * 应用类型(0-得分类,1-测评类) */ private Integer appType; /** * 评分策略(0-自定义,1-AI) */ private Integer scoringStrategy; private static final long serialVersionUID = 1L; }
其余的表也按这种方式进行。
五、后端基础开发-接口开发
六、后端基础开发-服务开发
编写 Service 接口和实现类,完善业务逻辑,比如数据校验之类的。
上传图片
七、后端核心业务开发
核心业务流程:
- 用户注册 => 用户登录
- 用户创建应用 => 创建题目(包括题目选项得分)=> 创建评分规则(评分方式和评分结果)
- 管理员管理应用,审核发布(或下架)应用
- 用户查看和检索应用列表,进入应用详情页,在线答题并提交回答
- 经过评分模块计算后,用户可查看本次评分结果
需要额外开发的能力:
- 应用模块
- 审核发布和下架应用(仅管理员可用)P0
- 评分模块
- 根据回答计算评分结果(多种评分策略)
- 自定义规则评分 - 测评类 P0
- 自定义规则评分 - 打分类 P0
- 回答模块
- 提交回答(创建)P0。需要额外调整,提交回答后就可以调用评分模块并更新回答表。
然后通过接口文档来完整测试一遍业务流程即可。
应用审核功能
审核是一个通用能力,每个表都可以按照如下方式开发。
首先因为审核是一个通用能力,因此将其功能抽取出来放在common包下的ReviewRequest;
其次模拟前端发送请求,这个请求中包含哪些参数,需要后端接收;
package com.yupi.springbootinit.common; import lombok.Data; import java.io.Serializable; /** * 审核请求 */ @Data public class ReviewRequest implements Serializable { /** * id */ private Long id; /** * 状态(0-待审核,1-通过,2-拒绝) */ private Integer reviewStatus; /** * 审核信息 */ private String reviewMessage; private static final long serialVersionUID = 1L; }
然后因为是每个表都有审核功能因此以App表为例,在AppController写一个接口;
/** * 应用审核 * * @param reviewRequest * @param request * @return */ @PostMapping("/review") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse<Boolean> doAppReview(@RequestBody ReviewRequest reviewRequest, HttpServletRequest request) { ThrowUtils.throwIf(reviewRequest == null, ErrorCode.PARAMS_ERROR); Long id = reviewRequest.getId(); Integer reviewStatus = reviewRequest.getReviewStatus(); // 校验 ReviewStatusEnum reviewStatusEnum = ReviewStatusEnum.getEnumByValue(reviewStatus); if (id == null || reviewStatusEnum == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } // 判断是否存在 App oldApp = appService.getById(id); ThrowUtils.throwIf(oldApp == null, ErrorCode.NOT_FOUND_ERROR); // 已是该状态 if (oldApp.getReviewStatus().equals(reviewStatus)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "请勿重复审核"); } // 更新审核状态 User loginUser = userService.getLoginUser(request); App app = new App(); app.setId(id); app.setReviewStatus(reviewStatus); app.setReviewMessage(reviewRequest.getReviewMessage()); app.setReviewerId(loginUser.getId()); app.setReviewTime(new Date()); boolean result = appService.updateById(app); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); }
逻辑并不复杂,所以就直接在 Controller 编写了。有时间的话,可以再移动到 Service 中。
评分模块实现
需求:针对不同的应用类别和评分策略,编写不同的实现逻辑。
核心实现方式:策略模式
策略模式是一种行为设计模式,它定义了一系列算法,并将每个算法封装到独立的类中,使得它们可以相互替换。
在本项目的场景中,输入的参数是一致的(应用和用户的答案列表),并且每种实现逻辑区别较大,很适合使用策略模式。
所有代码放到 scoring 包中,模块化。
首先在根目录创建scoring包,模块化;
其次需要一个接口来定义需要传递什么参数和准备写实现类;
import com.yupi.springbootinit.model.entity.App; import com.yupi.springbootinit.model.entity.UserAnswer; import java.util.List; /** * 评分策略 */ public interface ScoringStrategy { /** * 执行评分评分 * @param choice 用户答案 * @param app 应用 * @return 评分结果 */ UserAnswer doScore(List<String> choice, App app)throws Exception; }
因此其实现类(需要一个测评类评分和自定义评分)(也就是写评分算法逻辑);
/** * 自定义测评类应用评分策略 */ @ScoringStrategyConfig(appType = 1, scoringStrategy = 0) public class CustomTestScoringStrategy implements ScoringStrategy { @Resource private QuestionService questionService; @Resource private ScoringResultService scoringResultService; @Override public UserAnswer doScore(List<String> choices, App app) throws Exception { Long appId = app.getId(); // 1. 根据 id 查询到题目和题目结果信息 Question question = questionService.getOne( Wrappers.lambdaQuery(Question.class).eq(Question::getAppId, appId) ); List<ScoringResult> scoringResultList = scoringResultService.list( Wrappers.lambdaQuery(ScoringResult.class) .eq(ScoringResult::getAppId, appId) ); // 2. 统计用户每个选择对应的属性个数,如 I = 10 个,E = 5 个 // 初始化一个Map,用于存储每个选项的计数 Map<String, Integer> optionCount = new HashMap<>(); QuestionVO questionVO = QuestionVO.objToVo(question); List<QuestionContentDTO> questionContent = questionVO.getQuestionContent(); // 遍历题目列表 for (QuestionContentDTO questionContentDTO : questionContent) { // 遍历答案列表 for (String answer : choices) { // 遍历题目中的选项 for (QuestionContentDTO.Option option : questionContentDTO.getOptions()) { // 如果答案和选项的key匹配 if (option.getKey().equals(answer)) { // 获取选项的result属性 String result = option.getResult(); // 如果result属性不在optionCount中,初始化为0 if (!optionCount.containsKey(result)) { optionCount.put(result, 0); } // 在optionCount中增加计数 optionCount.put(result, optionCount.get(result) + 1); } } } } // 3. 遍历每种评分结果,计算哪个结果的得分更高 // 初始化最高分数和最高分数对应的评分结果 int maxScore = 0; ScoringResult maxScoringResult = scoringResultList.get(0); // 遍历评分结果列表 for (ScoringResult scoringResult : scoringResultList) { List<String> resultProp = JSONUtil.toList(scoringResult.getResultProp(), String.class); // 计算当前评分结果的分数,[I, E] => [10, 5] => 15 int score = resultProp.stream() .mapToInt(prop -> optionCount.getOrDefault(prop, 0)) .sum(); // 如果分数高于当前最高分数,更新最高分数和最高分数对应的评分结果 if (score > maxScore) { maxScore = score; maxScoringResult = scoringResult; } } // 4. 构造返回值,填充答案对象的属性 UserAnswer userAnswer = new UserAnswer(); userAnswer.setAppId(appId); userAnswer.setAppType(app.getAppType()); userAnswer.setScoringStrategy(app.getScoringStrategy()); userAnswer.setChoices(JSONUtil.toJsonStr(choices)); userAnswer.setResultId(maxScoringResult.getId()); userAnswer.setResultName(maxScoringResult.getResultName()); userAnswer.setResultDesc(maxScoringResult.getResultDesc()); userAnswer.setResultPicture(maxScoringResult.getResultPicture()); return userAnswer; } }
全局执行器
为了简化外部调用,需要根据不同的应用类别和评分策略,选择对应的策略执行,因此需要一个全局执行器。
2 种实现方式:
1)编程式,在内部计算选用何种策略:
@Service @Deprecated public class ScoringStrategyContext { @Resource private CustomScoreScoringStrategy customScoreScoringStrategy; @Resource private CustomTestScoringStrategy customTestScoringStrategy; /** * 评分 * * @param choiceList * @param app * @return * @throws Exception */ public UserAnswer doScore(List<String> choiceList, App app) throws Exception { AppTypeEnum appTypeEnum = AppTypeEnum.getEnumByValue(app.getAppType()); AppScoringStrategyEnum appScoringStrategyEnum = AppScoringStrategyEnum.getEnumByValue(app.getScoringStrategy()); if (appTypeEnum == null || appScoringStrategyEnum == null) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用配置有误,未找到匹配的策略"); } // 根据不同的应用类别和评分策略,选择对应的策略执行 switch (appTypeEnum) { case SCORE: switch (appScoringStrategyEnum) { case CUSTOM: return customScoreScoringStrategy.doScore(choiceList, app); case AI: break; } break; case TEST: switch (appScoringStrategyEnum) { case CUSTOM: return customTestScoringStrategy.doScore(choiceList, app); case AI: break; } break; } throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用配置有误,未找到匹配的策略"); } }
优点是直观清晰,缺点是不利于扩展和维护。
2)声明式,在每个策略类中通过接口声明对应的生效条件,适合比较规律的策略选取场景。
接口:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Component public @interface ScoringStrategyConfig { int appType(); int scoringStrategy(); }
给策略实现类补充注解:
@ScoringStrategyConfig(appType = 0, scoringStrategy = 0)
全局执行器:
@Service public class ScoringStrategyExecutor { // 策略列表 @Resource private List<ScoringStrategy> scoringStrategyList; /** * 评分 * * @param choiceList * @param app * @return * @throws Exception */ public UserAnswer doScore(List<String> choiceList, App app) throws Exception { Integer appType = app.getAppType(); Integer appScoringStrategy = app.getScoringStrategy(); if (appType == null || appScoringStrategy == null) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用配置有误,未找到匹配的策略"); } // 根据注解获取策略 for (ScoringStrategy strategy : scoringStrategyList) { if (strategy.getClass().isAnnotationPresent(ScoringStrategyConfig.class)) { ScoringStrategyConfig scoringStrategyConfig = strategy.getClass().getAnnotation(ScoringStrategyConfig.class); if (scoringStrategyConfig.appType() == appType && scoringStrategyConfig.scoringStrategy() == appScoringStrategy) { return strategy.doScore(choiceList, app); } } } throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用配置有误,未找到匹配的策略"); } }
因为用了 ScoringStrategyConfig 注解,所以这个实现类被加上了 component 注解,因此可以被spring 管理扫描到。 然后 @Resoure 注入的时候,会通过 ScoringStrategy 类型找到所有实现 ScoringStrategy 接口的实现类
回答模块
在 addUserAnswer 创建回答方法中补充 “调用评分模块并更新回答表”的逻辑即可。
控制应用可见范围
如果应用未过审,用户无法答题、并且无法通过列表查看到未过审的应用。