赞
踩
业务流程:
每个模块的功能描述
npm install
安装依赖首先先创建路由,比如新建一个user页面,需要先创建user页面的路由地址。
在app.config.ts的文件中的pages中,可以添加对应的路由地址(不用像之前的vue项目需要引入路由),每个路由都有规则:即为页面在项目中的路径,并且哪个路由放在第一条,哪个页面就会先被执行
将原本的代码改为函数式导出
原本的代码:
import { Component, PropsWithChildren } from 'react' import { View, Text } from '@tarojs/components' import "taro-ui/dist/style/components/button.scss" // 按需引入 import './index.scss' import {AtButton} from "taro-ui"; import Taro from "@tarojs/taro"; export default class Index extends Component<PropsWithChildren> { componentDidMount () { } componentWillUnmount () { } componentDidShow () { } componentDidHide () { } render () { return ( <View className='index'> <Text>用户</Text> <AtButton type='primary' onClick={() =>{ Taro.navigateTo({ url: '/pages/index/index', }) } }>按钮文案</AtButton> </View> ) } }
改为函数式导出后的代码:
import { View, Text } from '@tarojs/components' import "taro-ui/dist/style/components/button.scss" // 按需引入 import './index.scss' import {AtButton} from "taro-ui"; import Taro from "@tarojs/taro"; export default () => { return ( <View className='index'> <Text>用户</Text> <AtButton type='primary' onClick={() =>{ Taro.navigateTo({ url: '/pages/index/index', }) } }>按钮文案</AtButton> </View> ) }
在app.config.ts
文件中可以进行全局的修改,也可以在每个目录的文件中的index.config.ts
进行局部的标题修改
使用传统的css调样式
ts页面:
/** * 主页 */ export default () => { return ( <View className='indexPage'> <View className='at-article__h1 text1'> MBTI性格测试 </View> <View className='at-article__h2 text2'> 只需2分钟,快速测出你是谁?准确的描述出你的性格的特点 </View> <AtButton className='btn' type='primary' circle onClick={() =>{ Taro.navigateTo({ url: '/pages/doQuestion/index', }) }}>开始测试</AtButton> <Image className='imagePhoto' src={photo}/> <GlobalFooter></GlobalFooter> </View> ) }
css页面:
.indexPage{ background-color: #A2C7D7; } .text1{ color: white; padding-top: 48px; text-align: center; } .text2{ color: white; margin-bottom: 48px; } .btn{ width: 60vw; }
所有的题目和答案都是json格式:
[ { "options": [ { "result": "I", "value": "独自工作", "key": "A" }, { "result": "E", "value": "与他人合作", "key": "B" } ], "title": "你通常更喜欢" }, { "options": [ { "result": "J", "value": "喜欢有明确的计划", "key": "A" }, { "result": "P", "value": "更愿意随机应变", "key": "B" } ], "title": "当安排活动时" }, { "options": [ { "result": "T", "value": "认为应该严格遵守", "key": "A" }, { "result": "F", "value": "认为应灵活运用", "key": "B" } ], "title": "你如何看待规则" }, { "options": [ { "result": "E", "value": "经常是说话的人", "key": "A" }, { "result": "I", "value": "更倾向于倾听", "key": "B" } ], "title": "在社交场合中" }, { "options": [ { "result": "J", "value": "先研究再行动", "key": "A" }, { "result": "P", "value": "边做边学习", "key": "B" } ], "title": "面对新的挑战" }, { "options": [ { "result": "S", "value": "注重细节和事实", "key": "A" }, { "result": "N", "value": "注重概念和想象", "key": "B" } ], "title": "在日常生活中" }, { "options": [ { "result": "T", "value": "更多基于逻辑分析", "key": "A" }, { "result": "F", "value": "更多基于个人情感", "key": "B" } ], "title": "做决定时" }, { "options": [ { "result": "S", "value": "喜欢有结构和常规", "key": "A" }, { "result": "N", "value": "喜欢自由和灵活性", "key": "B" } ], "title": "对于日常安排" }, { "options": [ { "result": "P", "value": "首先考虑可能性", "key": "A" }, { "result": "J", "value": "首先考虑后果", "key": "B" } ], "title": "当遇到问题时" }, { "options": [ { "result": "T", "value": "时间是一种宝贵的资源", "key": "A" }, { "result": "F", "value": "时间是相对灵活的概念", "key": "B" } ], "title": "你如何看待时间" } ]
使用taro组件的单选框进行题目答案的选择
定义单个按钮,上一题按钮,下一题按钮,查看答案按钮
根据上面的json数据先定义一个当前序号和当前题目
//当前题目序号
const [current,setCurrent] = useState<number>(1);
//当前题目
const [currentQuestion,setCurrentQuestion] = useState(questions[0])
const questionOptions = currentQuestion.options.map((option) =>{
return {label:`${option.key}.${option.value}`,value:`${option.key}`}
})
当前题号为1时不显示上一题按钮,当前题号为10时不显示下一题按钮并且显示查看结果
并且按钮绑定点击事件,点击下一题当前题目序号加一,反之减一
{current < questions.length && (<AtButton className='btn1' type='primary' circle onClick={() =>{
setCurrent(current + 1)
}}>下一题</AtButton>)}
{current == questions.length && (<AtButton className='btn2' type='primary' circle>查看结果</AtButton>)}
{current != 1 && (<AtButton className='btn3' type='primary' circle onClick={()=>{
setCurrent(current - 1)
}}>上一题</AtButton>)}
定义当前用户选的答案,并且定义一个答案列表记录用户所选择的答案
//当前答案
const [currentAnswer,setCurrentAnswer] = useState<String>();
//回答列表
const [answerList] = useState<String[]>([]);
如何控制当前序号变化,而题目跟着同时变化呢?,使用react的一个钩子useEffect
//序号变化时,切换当前题目和当前回答
useEffect(() =>{
setCurrentQuestion(questions[current-1]);
setCurrentAnswer(answerList[current - 1])
},[current])
完善单选框,并记录用户的选择在answerList
中
<AtRadio
options={questionOptions}
value={currentAnswer}
onClick={(value) =>{
setCurrentAnswer(value)
answerList[current - 1] = value;
}}
/>
查看结果按钮进行页面的跳转和参数的传递
{current == questions.length && (<AtButton className='btn2' type='primary' circle onClick={() =>{
//传递用户答案
Taro.setStorageSync("answerList",answerList);
//跳转到结果页
Taro.navigateTo({
url: '/pages/result/index',
})
}}>查看结果</AtButton>)}
使用AI让生成算法。
复制主页。在主页的基础上进行修改
获取doQuestion
传过来的answerList
回答列表,并判断列表是否为空,为空或长度小于1即提示错误
//获取答题页传过来的参数
const answerList = Taro.getStorageSync("answerList");
if(!answerList || answerList.length < 1){
Taro.showToast({
title:"答案为空",
icon:"error",
duration:3000,
})
}
引入结果json格式,定义一个result
变量,调用算法计算出结果
//根据算法获取答案
const result = getMaxScore(answerList,questions,questionResult);
最后在页面上输出答案:
<View className='resultPage'>
<View className='at-article__h1 text1'>
{result.resultName}
</View>
<View className='at-article__h2 text2'>
{result.resultDesc}
</View>
<AtButton className='btn' type='primary' circle onClick={() =>{
Taro.reLaunch({
url: '/pages/index/index',
})
}}>返回主页</AtButton>
<Image className='imagePhoto' src={photo}/>
<GlobalFooter></GlobalFooter>
</View>
这是题目的json格式:【title就是所对应的题目,options即所对应的选项】
[
{
"options":[
{"result":"I","value":"独自工作","key":"A"},
{"result":"E","value":"与他人合作","key":"B"}
],
"title":"1. 你通常更喜欢"},
{
"options":[
{"result":"J","value":"喜欢有明确的计划","key":"A"},
{"result":"P","value":"更愿意随机应变","key":"B"}
],
"title":"2. 当安排活动时"},
{
"options":[{"result":"T","value":"认为应该严格遵守","key":"A"},{"result":"F","value":"认为应灵活运用","key":"B"}],"title":"3. 你如何看待规则"},{"options":[{"result":"E","value":"经常是说话的人","key":"A"},{"result":"I","value":"更倾向于倾听","key":"B"}],"title":"4. 在社交场合中"},{"options":[{"result":"J","value":"先研究再行动","key":"A"},{"result":"P","value":"边做边学习","key":"B"}],"title":"5. 面对新的挑战"},{"options":[{"result":"S","value":"注重细节和事实","key":"A"},{"result":"N","value":"注重概念和想象","key":"B"}],"title":"6. 在日常生活中"},{"options":[{"result":"T","value":"更多基于逻辑分析","key":"A"},{"result":"F","value":"更多基于个人情感","key":"B"}],"title":"7. 做决定时"},{"options":[{"result":"S","value":"喜欢有结构和常规","key":"A"},{"result":"N","value":"喜欢自由和灵活性","key":"B"}],"title":"8. 对于日常安排"},{"options":[{"result":"P","value":"首先考虑可能性","key":"A"},{"result":"J","value":"首先考虑后果","key":"B"}],"title":"9. 当遇到问题时"},{"options":[{"result":"T","value":"时间是一种宝贵的资源","key":"A"},{"result":"F","value":"时间是相对灵活的概念","key":"B"}],"title":"10. 你如何看待时间"}]
总共5张表,分别是:用户表(user),应用表(app),题目表(question),评分结果表(scoring_result),用户答题记录表(user_answer)
用户表:
create table if not exists user ( id bigint auto_increment comment 'id' primary key, userAccount varchar(256) not null comment '账号', userPassword varchar(512) not null comment '密码', unionId varchar(256) null comment '微信开放平台id', mpOpenId varchar(256) null comment '公众号openId', userName varchar(256) null comment '用户昵称', userAvatar varchar(1024) null comment '用户头像', userProfile varchar(512) null comment '用户简介', userRole varchar(256) default 'user' not null comment '用户角色:user/admin/ban', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default not null comment '是否删除', index idx_unionId (unionId) ) comment '用户' collate = utf8mb4_unicode_ci;
应用表:
-- auto-generated definition create table app ( id bigint auto_increment comment 'id' primary key, appName varchar(128) not null comment '应用名', appDesc varchar(2048) null comment '应用描述', appIcon varchar(1024) null comment '应用图标', 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-拒绝', reviewMessage varchar(512) null comment '审核信息', reviewerId bigint null comment '审核人 id', reviewTime datetime 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 '是否删除' ) comment '应用' collate = utf8mb4_unicode_ci; create index idx_appName on app (appName);
题目表:
每个应用对应一个题目表的记录,使用 questionContent 这一 JSON 字段,整体更新和维护该应用的题目列表、选项信息。
-- auto-generated definition create table question ( id bigint auto_increment comment 'id' primary key, questionContent text null comment '题目内容(json格式)', appId 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 '是否删除' ) comment '题目' collate = utf8mb4_unicode_ci; create index idx_appId on question (appId);
评分结果表:
用户提交答案后,会获得一定的回答评定,例如 ISTJ 之类的,评分结果表就是存储这些数据的表。
-- auto-generated definition create table scoring_result ( id bigint auto_increment comment 'id' primary key, resultName varchar(128) not null comment '结果名称,如物流师', resultDesc text null comment '结果描述', resultPicture varchar(1024) null comment '结果图片', resultProp varchar(128) null comment '结果属性集合 JSON,如 [I,S,T,J]', resultScoreRange int null comment '结果得分范围,如 80,表示 80及以上的分数命中此结果', appId 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 '是否删除' ) comment '评分结果' collate = utf8mb4_unicode_ci; create index idx_appId on scoring_result (appId);
用户答题记录表:(这个表包含了用户做题的记录,和最后的做题结果与得分)
需要注意:
-- auto-generated definition create table user_answer ( id bigint auto_increment primary key, appId bigint not null comment '应用 id', appType tinyint default 0 not null comment '应用类型(0-得分类,1-角色测评类)', scoringStrategy tinyint default 0 not null comment '评分策略(0-自定义,1-AI)', choices text null comment '用户答案(JSON 数组)', resultId bigint null comment '评分结果 id', resultName varchar(128) null comment '结果名称,如物流师', resultDesc text null comment '结果描述', resultPicture varchar(1024) null comment '结果图标', resultScore int 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 '是否删除' ) comment '用户答题记录' collate = utf8mb4_unicode_ci; create index idx_appId on user_answer (appId); create index idx_userId on user_answer (userId);
将数据库创建完成,使用mybatisX自动生成器生成对应的实体类与mapper。
完成初始化后,先写枚举类,首先分析哪些字段需要进行枚举,
创建一个应用类型枚举类
/** * 应用类型枚举 * * @author <a href="https://github.com/liyupi">程序员鱼皮</a> * @from <a href="https://yupi.icu">编程导航知识星球</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; } /** * 获取值列表 * * @return */ public static List<Integer> getValues() { return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList()); } /** * 根据 value 获取枚举 * * @param value * @return */ public static AppTypeEnum getEnumByValue(int value) { if (ObjectUtils.isEmpty(value)) { return null; } for (AppTypeEnum anEnum : AppTypeEnum.values()) { if (anEnum.value == value) { return anEnum; } } return null; } public Integer getValue() { return value; } public String getText() { return text; } }
评分策略枚举类
/** * 评分策略枚举 * * @author <a href="https://github.com/liyupi">程序员鱼皮</a> * @from <a href="https://yupi.icu">编程导航知识星球</a> */ public enum ScoringStrategyTypeEnum { CUSTOM("自定义", 0), AI("AI", 1); private final String text; private final int value; ScoringStrategyTypeEnum(String text, int value) { this.text = text; this.value = value; } /** * 获取值列表 * * @return */ public static List<Integer> getValues() { return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList()); } /** * 根据 value 获取枚举 * * @param value * @return */ public static ScoringStrategyTypeEnum getEnumByValue(int value) { if (ObjectUtils.isEmpty(value)) { return null; } for (ScoringStrategyTypeEnum anEnum : ScoringStrategyTypeEnum.values()) { if (anEnum.value == value) { return anEnum; } } return null; } public Integer getValue() { return value; } public String getText() { return text; } }
略
先说明一个应用这个概念,也就是app表,用户可以创建一个测评应用(即选A说明你偏向于i人,选B偏向于e人这种)或得分应用(即结果是一个分数,每道题会有一个分数),每个应用都必须经过管理员审核通过才能展示出来
流程:前端用户删除应用要对应删除哪个应用的id,管理员能删除任何应用,用户只能删除自己创建的应用
流程:管理员前端进行对某个应用的审核,把修改审核后的状态和审核的信息发给后端接口
@AuthCheck
标识只能是管理员才能调用审核的接口/** * 管理员审核应用是否合法接口 * @param reviewRequest * @param request * @return */ @PostMapping("/review") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) //标记这个接口只能给管理员使用 public BaseResponse<Boolean> doAppReview(@RequestBody ReviewRequest reviewRequest,HttpServletRequest request){ if(reviewRequest == null){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } //参数判断 Long id = reviewRequest.getId(); Integer reviewStatus = reviewRequest.getReviewStatus(); ReviewStatusEnum enumByValue = ReviewStatusEnum.getEnumByValue(reviewStatus); if(id == null || enumByValue == null){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } //判断是否存在 App app = appService.getById(id); if(app == null){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } //已是该状态 if(app.getReviewStatus().equals(enumByValue)){ throw new BusinessException(ErrorCode.PARAMS_ERROR,"请勿重复提交"); } //更新审核状态 User loginUser = userService.getLoginUser(request); App newApp = new App(); newApp.setId(id); newApp.setReviewStatus(reviewStatus); newApp.setReviewMessage(reviewRequest.getReviewMessage()); newApp.setReviewerId(loginUser.getId()); newApp.setReviewTime(new Date()); boolean result = appService.updateById(app); if(!result){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } return ResultUtils.success(true); }
需求:针对不同的应用类别和评分策略,编写不同的实现逻辑。
使用策略模式,定义一系列算法,并将每个算法封装到独立的类中,使得它们可以互相替换。
参数:应用、用户的答案列表。(可以根据应用id获取到题目列表、测评结果)
返回值:题目答案
public interface ScoringStrategy {
/**
* 指定评分
* @param choices
* @param app
* @return
* @throws Exception
*/
UserAnswer doScore(List<String> choices, App app) throws Exception;
}
定义两种策略:
首先实现策略接口,这是策略模式的写法。
/** * 自定义测评类应用的评分策略 */ 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 id = app.getId(); //1、根据appId查询到题目和题目结果信息 //使用lambda查询出Question中的appId等于当前应用的id对应的题目 Question question = questionService.getOne(Wrappers.lambdaQuery(Question.class).eq(Question::getAppId, id)); List<ScoringResult> scoringResultList = scoringResultService.list(Wrappers.lambdaQuery(ScoringResult.class).eq(ScoringResult::getAppId, id)); //2、统计用户的每个选择对应的属性个数,如 I = 10个,E = 5 个 //定义一个Map,用于存放每个选项的计数 Map<String, Integer> optionCount = new HashMap<>(); //获取题目内容 QuestionVO questionVO = QuestionVO.objToVo(question); //题目列表 List<QuestionContentDTO> questionContentList = questionVO.getQuestionContent(); //遍历题目列表 for (QuestionContentDTO questionContent : questionContentList) { //遍历用户选择的答案列表 for (String choice : choices) { //遍历题目中的选项 for (QuestionContentDTO.Option option : questionContent.getOptions()) { //如果答案和选项的key匹配(即用户选的是这个选项) if (option.getKey().equals(choice)) { String result = option.getResult(); //如果map中没有出现过这个result属性,就加入到map中 if (!optionCount.containsKey(result)) { optionCount.put(result, 0); } optionCount.put(result, optionCount.get(result) + 1); } } } } //3、遍历每种评分结果,计算哪个结果得分更高 //首先先初始化最高分数和最高分数对应的评分结果(随便给个值) int maxScore = 0; ScoringResult maxScoreResult = scoringResultList.get(0); //遍历评分结果列表 for (ScoringResult result : scoringResultList) { //因为ScoringResult中的ResultProp字段存放的是json格式,要将json转成列表 List<String> resultProp = JSONUtil.toList(result.getResultProp(), String.class); //计算当前评分结果的分数 int score = resultProp.stream().mapToInt(prop -> optionCount.getOrDefault(prop, 0)).sum(); //如果分数高于当前最高分数,更新最高分数和最高分数对应的评分结果 if (score > maxScore) { maxScore = score; maxScoreResult = result; } } //4、构造返回值,填充答案对象的属性 UserAnswer userAnswer = new UserAnswer(); userAnswer.setAppId(id); userAnswer.setAppType(app.getAppType()); userAnswer.setScoringStrategy(app.getScoringStrategy()); userAnswer.setChoices(JSONUtil.toJsonStr(choices)); userAnswer.setResultId(maxScoreResult.getId()); userAnswer.setResultName(maxScoreResult.getResultName()); userAnswer.setResultDesc(maxScoreResult.getResultDesc()); userAnswer.setResultPicture(maxScoreResult.getResultPicture()); return userAnswer; } }
注意:这个统计用户选择属性计算出哪个结果得分更高是由GPT生成的。【在GPT生成的算法代码中进行修改】
向gpt提问的方式:
将下面这段 js 函数转换为 Java 方法: export function getBestQuestionResult(answerList, questions, question_results) { // 初始化一个对象,用于存储每个选项的计数 const optionCount = {}; // 用户选择 A, B, C // 对应 result:I, I, J // optionCount[I] = 2; optionCount[J] = 1 // 遍历题目列表 for (const question of questions) { // 遍历答案列表 for (const answer of answerList) { // 遍历题目中的选项 for (const option of question.options) { // 如果答案和选项的key匹配 if (option.key === answer) { // 获取选项的result属性 const result = option.result; // 如果result属性不在optionCount中,初始化为0 if (!optionCount[result]) { optionCount[result] = 0; } // 在optionCount中增加计数 optionCount[result]++; } } } } // 初始化最高分数和最高分数对应的评分结果 let maxScore = 0; let maxScoreResult = question_results[0]; // 遍历评分结果列表 for (const result of question_results) { // 计算当前评分结果的分数 const score = result.resultProp.reduce((count, prop) => { return count + (optionCount[prop] || 0); }, 0); // 如果分数高于当前最高分数,更新最高分数和最高分数对应的评分结果 if (score > maxScore) { maxScore = score; maxScoreResult = result; } } // 返回最高分数和最高分数对应的评分结果 return maxScoreResult; }
上面的定义的评分策略,是switch case的方式判断哪组应用类型调用哪种评分策略的方法固然可行,但是当我们每新加一种策略,都需要改动这个类,很不优雅。
所以我们使用自定义一个注解,通过注解找到是哪种应用类型,调用对应的评分策略
补充一下知识点(注解的写法):
1.)@Retention – 定义该注解的生命周期
2.)Target – 表示该注解用于什么地方。默认值为任何元素,表示该注解用于什么地方。可用的ElementType 参数包括
● ElementType.CONSTRUCTOR: 用于描述构造器
● ElementType.FIELD: 成员变量、对象、属性(包括enum实例)
● ElementType.LOCAL_VARIABLE: 用于描述局部变量
● ElementType.METHOD: 用于描述方法
● ElementType.PACKAGE: 用于描述包
● ElementType.PARAMETER: 用于描述参数
● ElementType.TYPE: 用于描述类、接口(包括注解类型) 或enum声明
3.)@Documented – 一个简单的Annotations 标记注解,表示是否将注解信息添加在java 文档中。
原文链接:https://blog.csdn.net/u014365523/article/details/126730735
/** * 自定义注解类:用来标识某个应用是什么应用类型和评分策略 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Component public @interface ScoringStrategyConfig { /** * 应用类型 * @return */ int appType(); /** * 评分策略 * @return */ int scoringStrategy(); }
在两种评分策略中都加上该注解,并填写上对应的(测评类的为1,得分类为0,自定义为0,AI为1)
编写一个执行器,获取对应策略中对应得注解来区分使用哪种策略:
/** * 评分策略执行器 */ @Service public class ScoringStrategyExecutor { @Resource private List<ScoringStrategy> scoringStrategyList; public UserAnswer doScore(List<String> choices, App app) throws Exception{ //获取该应用的应用类型和评分类型 Integer appType = app.getAppType(); Integer appScoringStrategy = app.getScoringStrategy(); if(appType == null || appScoringStrategy == null){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } for (ScoringStrategy scoringStrategy : scoringStrategyList) { //获取某个策略的注解 if(scoringStrategy.getClass().isAnnotationPresent(ScoringStrategyConfig.class)){ ScoringStrategyConfig annotation = scoringStrategy.getClass().getAnnotation(ScoringStrategyConfig.class); if(annotation.appType() == appType && annotation.scoringStrategy() == appScoringStrategy){ return scoringStrategy.doScore(choices,app); } } } throw new BusinessException(ErrorCode.SYSTEM_ERROR,"应用配置有误"); } }
在文件夹中打开cmd,已安装好vue,执行下面代码创建项目:
vue create 项目名
安装Arco Design组件库,并且进行引入(具体看官方文档)
创建基础布局布局采用上中下放入结构,(看官方组件库文档),注意每个也买你的内容都是动态获取的,需要根据路由的不同展示不同页面,所以使用router-view
,来展示页面的主体内容
<div id="basicLayout">
<a-layout style="height: 100vh;">
<a-layout-header class="header">
<GlobalHeader></GlobalHeader>
</a-layout-header>
<a-layout-content class="content">
<!--使用router-view 动态加载页面-->
<router-view></router-view>
</a-layout-content>
<a-layout-footer class="footer">
作者:Hines
</a-layout-footer>
</a-layout>
</div>
若某个页面不想用这个布局,那么我们还可以设置多个布局,首先先创建新的布局UserLayout
,我们仍然采用上中下的结构
<template> <div id="userLayout"> <a-layout style="min-height: 100vh"> <a-layout-header class="header"> <a-space> <img src="../assets/logo.png" class="logo" /> <div>可达鸭 AI 答题应用平台</div> </a-space> </a-layout-header> <a-layout-content class="content"> <router-view /> </a-layout-content> <a-layout-footer class="footer"> <a href="https://www.code-nav.cn" target="_blank"> 作者:Hines </a> </a-layout-footer> </a-layout> </div> </template>
那如何使页面动态的展示布局呢?
思路:根据路由的不同来展示对应的布局,使用useRoute()
来获取当前路径的路由,使用 v-if 进行判断(当当前路径包含 /user 的就展示UserLayout布局)
<template>
<div id="app">
<template v-if="route.path.startsWith('/user')">
<UserLayout></UserLayout>
</template>
<template v-else>
<BasicLayout></BasicLayout>
</template>
</div>
</template>
这里使用组件库中的Menu菜单组件开发导航栏。
需要注意,一般的导航栏菜单左边使菜单选项,右边是登录按钮,所以需要用到组件库中的一种栅格组件的flex布局来控制左右比例(这里我们设置左边是auto,右边为100px)
<template> <div id="globalHeader"> <a-row class="grid-demo" align="center" :wrap="false"> <a-col flex="auto"> <a-menu mode="horizontal" :selected-keys="selectKey" @menu-item-click="doMenu"> <a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled> <div class="titleBar"> <img class="logo" src="../assets/logo.jpg"> <div class="title">可达鸭AI应答平台</div> </div> </a-menu-item> <a-menu-item v-for="item in isvisableMenu" :key="item.path"> {{item.name}} </a-menu-item> </a-menu> </a-col> <a-col flex="100px"> <a-button type="primary">登录</a-button> </a-col> </a-row> </div> </template>
实现路由跳转:
这里为了方便后续根据路由判断是哪个页面的操作,需要将routes提取出来形成单独的ts文件并进行导出:
import { RouteRecordRaw } from "vue-router"; import HomeView from "@/views/HomeView.vue"; export const routes: Array<RouteRecordRaw> = [ { path: "/", name: "home", component: HomeView, }, { path: "/hide", name: "隐藏页面", component: HomeView, meta: { hideMenu: true, //表示该页面中不显示导航栏 }, }, { path: "/about", name: "about", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/AboutView.vue"), }, ];
那么菜单栏中一般都显示路由中的页面名称并且会显示当前页面菜单高亮显示,那么该如何决定呢?
根据路由,首先引入上面提取出来的routes,根据routes的path进行跳转,并且根据routes的name展示菜单的名称。
那么高亮怎么做,由于组件库给出的源代码如下,他是根据:default-selected-keys="['1']"
来显示高亮的,那么我们将default去掉,定义一个变量为selectKey
并且默认为 “/”(注意是数组,因为组件库的源代码就是数组形式),表示默认主页面,当路由变换后,selectKey
的值也跟着变化,将selectKey
的值传给:-selected-keys
就能完成
<template> <div class="menu-demo"> <a-menu mode="horizontal" :default-selected-keys="['1']"> <a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled> <div :style="{ width: '80px', height: '30px', borderRadius: '2px', background: 'var(--color-fill-3)', cursor: 'text', }" /> </a-menu-item> <a-menu-item key="1">Home</a-menu-item> <a-menu-item key="2">Solution</a-menu-item> <a-menu-item key="3">Cloud Service</a-menu-item> <a-menu-item key="4">Cooperation</a-menu-item> </a-menu> </div> </template>
//控制菜单高亮显示
const selectKey = ref(["/"]);
router.afterEach((to, from, failure) => {
selectKey.value = [to.path];
})
向后端发请求我们使用的axios请求,
import axios from "axios"; const myAxios = axios.create({ baseURL: "http://localhost:8088/", timeout: 10000, }); myAxios.defaults.withCredentials = true; // 添加请求拦截器 myAxios.interceptors.request.use( function (config) { // 在发送请求之前做些什么 return config; }, function (error) { // 对请求错误做些什么 return Promise.reject(error); } ); // 添加响应拦截器 myAxios.interceptors.response.use( function (response) { console.log(response); const { data } = response; if (data.code === 40100) { //不是获取用户信息的请求,并且用户目前不是已经在用户登录页面,则跳到登录页面 if ( !response.request.responseURL.includes("/user/get/login") && !window.location.pathname.includes("/user/login") ) { window.location.href = `/user/login?redirect=${window.location.href}`; } } return response; }, function (error) { // 超出 2xx 范围的状态码都会触发该函数。 // 对响应错误做点什么 return Promise.reject(error); } ); export default myAxios;
那么我们之前发送请求的时候都是 const res = await myAxios.get("/user/get/login")
,这样我们每次发送请求都需要写这几行代码,并且还需要写对应的路径,很麻烦。
解决方法:使用umi openai
,这个工具会根据后端接口文档生成对应的请求,只要你像发请求的时候比如获取登录用户信息的请求:直接写getLoginUserUsingGet()
就能发请求,并且 getLoginUser
对应的是后端controller的获取登录信息的方法名。
那怎么使用umi openai
生成对应得请求呢?
官方文档:https://www.npmjs.com/package/@umijs/openapi
安装umi openai
在项目根目录新建 openapi.config.ts
,并写下如下配置
const { generateService } = require("@umijs/openapi");
generateService({
//后端接口文档地址
schemaPath: "http://localhost:8088/api/v2/api-docs",
//生成请求代码所存放得目录
serversPath: "./src",
//自定义请求方法路径
requestLibPath: "import request from '@/request'",
});
在 package.json
的 script
中添加 api: "openapi": "ts-node openapi.config.ts",
生成api
注意umi openai
还生成了一些类型,使用API.
就能使用
问题:每次获取当前登录信息都需要手写一般请求十分麻烦,
解决方法:使用pinia能够定义当前登录信息的状态,文档地址:https://pinia.web3doc.top/getting-started.html#安装
安装pinia
npm install pinia
全局引入,参照官网文档引入
创建一个store包,并在该包下创建一个store.ts文件,
使用 定义一个变量使用defineStore
存储当前登录用户信息,在这个变量中第一个ref的loginUser
用户存放用户信息,并且创建一个设置loginUser
的方法和获取loginUser
的方法
/** * 使用pinia存放登录用户信息 */ export const useLoginUserStore = defineStore("loginUser", () => { const loginUser = ref<API.LoginUserVO>({ userName: "未登录", }); //创建一个能改变LoginUser的函数(类似于java中实体类的setter方法) function setLoginUser(newLoginUser: API.LoginUserVO) { loginUser.value = newLoginUser; } //创建一个能得到LoginUser的函数(类似于java中实体类的getter方法):就去发请求获取 async function fetchLoginUser() { const res = await getLoginUserUsingGet(); if (res.data.code === 0 && res.data.data) { loginUser.value = res.data.data; } } return { loginUser, setLoginUser, fetchLoginUser }; });
定义一个枚举类,枚举用户的权限:
const ACCESS_ENUM = {
NOT_LOGIN: "notLogin",
USER: "user",
ADMIN: "admin",
};
export default ACCESS_ENUM;
写一个方法用于检查用户的权限:
首先这个方法定义两个参数,需要注意的是 needAccess = ACCESS_ENUM.NOT_LOGIN 只是付一个默认参数
import ACCESS_ENUM from "@/access/accsessEnum"; /** * 检查权限 * @param loginUser:当前登录用户 * @param needAccess:所需得权限 */ const checkAccess = ( loginUser: API.LoginUserVO, 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; } return true; } //当前访问得页面所需要得权限是管理员才能访问 if (needAccess === ACCESS_ENUM.ADMIN) { if (loginUserAccess !== ACCESS_ENUM.ADMIN) { return false; } return true; } }; export default checkAccess;
因为在用户进行页面跳转之前,我们需要判断期有没有权限访问这个路径:
所以需要编写一个路由跳转前的判定:
import router from "@/router"; import { useLoginUserStore } from "@/store/userStore"; import ACCESS_ENUM from "@/access/accsessEnum"; import checkAccess from "@/access/checkAccess"; /** * 权限检查 */ router.beforeEach(async (to, from, next) => { //获取当前登录用户 const loginUserStore = useLoginUserStore(); let loinUser = loginUserStore.loginUser; //如果之前没尝试获取过当前登录的用户信息,才自动登录 if (!loinUser || !loinUser.userRole) { await loginUserStore.fetchLoginUser(); loinUser = loginUserStore.loginUser; } //当前页面需要得权限 const needAccess = (to.meta?.accsee as string) ?? ACCESS_ENUM.NOT_LOGIN; //要跳转得页面必须登录 if (needAccess !== ACCESS_ENUM.NOT_LOGIN) { if ( !loinUser || loinUser.userRole === ACCESS_ENUM.NOT_LOGIN || !loinUser.userRole ) { //跳转到登录页 next(`/user/login/redirect=${to.fullPath}`); } //如果用户已经登录了,判断权限是否足够,如果不足则跳转到无权限页面 if (!checkAccess(loinUser, needAccess)) { next("/noAuth"); return; } } //放行 next(); });
为了使项目支持markdown语法编写题目,我们使用字节跳动提供的 bytemd
。
安装依赖:
npm install @bytemd/vue-next
npm i @bytemd/plugin-highlight @bytemd/plugin-gfm
定义在component包中定义两个组件 MdEditor.vue
,MdViewer.vue
(不学前端直接复制)
原因:直接展示后端的时间会很不好看
使用Day.js进行样式的优化
因为acro框架已经内置了day.js,所以直接引入即可
import { dayjs } from "@arco-design/web-vue/es/_utils/date";
在表格上进行时间的展示:
<template #createTime="{ record }">
{{ dayjs(record.createTime).format("YYYY-MM-DD HH:mm:ss") }}
</template>
因为后端包含的枚举类有:应用类型,评分策略,审核状态,都是以整形的方式传给前端,定义枚举值更好管理:
//应用类型枚举 export const APPTYPE_ENUM = { //得分类 SCORING: 0, //测评类 TEST: 1, }; export const APPTYPE_MAP = { 0: "评分类", 1: "测评类", }; //审核状态枚举 export const REVIEWTYPE_ENUM = { //待审核 REVIEWING: 0, //已通过 PASS: 1, //已拒绝 REJECT: 2, }; export const REVIEWTYPE_MAP = { 0: "待审核", 1: "已通过", 2: "已拒绝", }; //应用得分枚举 export const SCORINGTYPE_ENUM = { //自定义 CUSTOM: 0, //AI AI: 1, }; export const SCORINGTYPE_MAP = { 0: "自定义", 1: "AI", };
首先我们应该要清楚一个路由的结构有哪些:
{
path: "",
name: "",
props: true,
component: ,
meta: {
hideMenu: true,
},
children:[]
},
上面的代码展示了路由的基本参数,path:路径,name:名称,pros:是否开启路由跳转携带参数,meta:自定义的参数(如:你想某个路由不显示在菜单栏中就可以定义一个boolean类型的变量,如上面的hideMenu,还可以判断权限等)children:子路由的配置
创建应用页面需要用的只是一个表单,(不多说,看代码),但有一个需要注意的点:
用户创建应用需要选择 应用的类型和评分策略,这里我们使用下拉框进行选择,并且在form变量中给出其默认值都为0
使用组件中的下拉框,遍历之前定义的枚举值(注意是MAP的那个),因为vue中const一个变量是以v-k存储的,value是名称展示在前端,key是存入后端的整形
<a-form-item field="appType" label="应用类型"> <a-select v-model="form.appType" :style="{ width: '320px' }" placeholder="请选择应用类型" > <!--value:是字符串,key:是传给后端的整形--> <a-option v-for="(value, key) of APPTYPE_MAP" :value="Number(key)" :label="value" /> </a-select> </a-form-item> <a-form-item field="scoringStrategy" label="评分策略"> <a-select v-model="form.scoringStrategy" :style="{ width: '320px' }" placeholder="请选择评分策略" > <!--value:是字符串,key:是传给后端的整形--> <a-option v-for="(value, key) of SCORINGTYPE_MAP" :value="Number(key)" :label="value" /> </a-select> </a-form-item>
当我们完成创建之后就会跳转到应用详情页,展示出刚刚创建的应用,注意:必须携带上该应用的id,res.data.data就是返回的创建成功后此应用的id
在用户详情页能够进行对应用的修改,所以这里就又跳转到应用创建页,复用同一个表单,但此时必须携带上id,并且应用创建页通过withDefaults
进行获取,==定义一个loadData的函数根据id获取该应用的值并且回显到表单上,使用一个钩子watchEffect
监听loadData函数的变化,否则回显会出问题.,==具体实现:
/** * 加载数据 */ const oldApp = ref<API.AppVO>() const loadData = async () => { if (!props.id) { return; } const res = await getAppByIdUsingGet({ id: props.id as any, }); if (res.data.code === 0 && res.data.data) { oldApp.value = res.data.data; //这个form是定义创建应用所需的变量 form.value = res.data.data; } else { message.error("获取数据失败," + res.data.message); } }; /** * 监听 searchParams 变量,改变时触发数据的重新加载 */ watchEffect(() =>{ loadData() })
同时这里就要区分到底是创建还是修改,很简单:根据props.id
来判断,因为创建的时候props.id
为空,修改的时候会使用路由传入id,所以id不为空,具体实现:
const handleSubmit = async () => { let res : any //如果是修改 if(props.id){ res = await editAppUsingPost({ id:props.id as any, ...form.value }); } //创建 else { res = await createAppUsingPost(form.value); } if (res.data.code === 0) { message.success("创建成功,即将跳转到应用详情页") setTimeout(() =>{ router.push({ path: `/app/detail/${props.id || res.data.data}`, }) },3000) } else { message.error("创建失败" + res.data.message); } };
这个页面比较复杂。
使用 acro designer 的嵌套数据组件:
<a-form-item label="Posts" :content-flex="false" :merge-props="false">
<a-space direction="vertical" fill>
<a-form-item field="posts.post1" label="Post1">
<a-input v-model="form.posts.post1" placeholder="please enter your post..." />
</a-form-item>
<a-form-item field="posts.post2" label="Post2">
<a-input v-model="form.posts.post2" placeholder="please enter your post..." />
</a-form-item>
</a-space>
</a-form-item>
首先先定义一个questionContent
变量,类型为QuestionContentDTO[]
,这个变量表示题目内容
定义一个增加题目的方法,增加题目包括题目的标题,题目的选项。
定义一个删除题目的方法,
//题目内容结构(理解为题目列表) const questionContent = reactive<API.QuestionContentDTO[]>([]); const addQuestion = (index:number) =>{ questionContent.splice(index,0,{ //标题 title:'', //选项 options:[] }) } const deleteQuestion = (index:number) =>{ //index:所要删除的元素位置,删除的个数 questionContent.splice(index,1); }
修改组件库的源代码:
<a-form-item label="题目列表" :content-flex="false" :merge-props="false"> <a-button @click="addQuestion(questionContent.length)"> 底部添加标题 </a-button> <!--展示底部添加标题这个按钮的效果--> <div v-for="(question,index) in questionContent" :key="index"> <a-space size="large"> <h3>题目{{index + 1}}</h3> <a-button size="samll" @click="addQuestion(index + 1)">添加题目</a-button> <a-button size="small" status="danger" @click="deleteQuestion(index)">删除题目</a-button> </a-space> <a-form-item field="posts.post1" :label="`题目${index + 1}标题`"> <a-input v-model="question.title" placeholder="请输入标题" /> </a-form-item> </div> <!----> </a-form-item>
将创建题目和修改题目都写到创建题目页面中,那如何判断是修改还是创建呢? ——> 因为这里传入的是appId,而后端只有根据题目id获取题目的接口,所以这里根据appId去调用后端listQuestionVOByPage
,只要传入current
= 1,pageSize
= 1,就能获取1条数据,并且要对创建时间进行降序排序,取出第0条数据,若能取出则是修改,不能则是创建。
同样提交的时候也要区分修改还是提交(调用的接口不一样),同样是根据oldQuestion
来进行判断
/** * 加载数据 */ const oldQuestion = ref<API.QuestionVO>() const loadData = async () => { if (!props.appId) { return; } const res = await listQuestionVoByPageUsingPost({ appId: props.appId as any, current:1, pageSize:1, sortField:"createTime", sortOrder:"descend" }); if (res.data.code === 0 && res.data.data?.records) { oldQuestion.value = res.data.data?.records[0]; //修改回显 if(oldQuestion.value){ questionContent.value = oldQuestion.value.questionContent ?? []; } } else { message.error("获取数据失败," + res.data.message); } }; //提交按钮 const handleSubmit = async () => { if(!props.appId || !questionContent.value){ return; } let res : any // 如果是修改 if(oldQuestion.value?.id) { res = await editQuestionUsingPost({ id: oldQuestion.value.id, questionContent:questionContent.value }); } //创建 else { res = await createQuestionUsingPost({ appId:props.appId as any, questionContent:questionContent.value }); } if (res.data.code === 0) { message.success("操作成功,即将跳转到应用详情页") setTimeout(() =>{ router.push({ path: `/app/detail/${props.appId}`, }) },3000) } else { message.error("操作失败" + res.data.message); } }; /** * 监听 searchParams 变量,改变时触发数据的重新加载 */ watchEffect(() =>{ loadData() })
同样的我们将创建评分结果页和修改评分结果页都写在同一个页面上。
因为一个应用的评分结果可以为多条(如:物流师,治愈师等),所以我们这里的使用的组件为表单和表格,若是创建评分结果我们,表单为空,让用户填写评分规则,若是修改,则表格显示出该appId原有的所有评分结果,每一条评分结果都有一个修改按钮,点击修改按钮,该条评分结果就会回显到表单中,给用户进行修改
这里我们定义一个component
包,用来存放子组件,这个子组件就是一个表格,用来回显评分结果
应该首先通过props
获取到路由的参数appId
,根据appId
,向后端查询出原数据(注意是VO),
需要注意的是,当点击修改按钮后,创建评分结果页需要回显该条数据,应该怎么做?
@click
方法触发事件,并且参数为该条评分结果interface Props
中,传递出去interface Props {
appId: string;
doUpdate: (scoringResult: API.ScoringResultVO) => void;
}
父组件获取子组件传递的props
,这里需要在子组件标签中进行获取,并且重新定义该方法,需要注意:为了区分是创建还是修改,这里需要定义一个updateId
变量来进行判断。
<template>
<ScoringResultTable :appId="appId" :doUpdate="doUpdate" ref="tableRef" />
</template>
<script>
//用于区分修改还是创建(不为空则是更新,空则是创建)
const updateId = ref<any>();
//获取子组件表格传来的评分结果,回显到表单中
const doUpdate = (scoringResult:API.ScoringResultVO) =>{
updateId.value = scoringResult.id;
form.value = scoringResult;
}
</script>
提交按钮,通过updateId
判断是修改还是创建,注意:若是修改,最后updateId
要清空
//提交 const handleSubmit = async () => { //如果是修改 if(!props.appId){ return; } let res : any if(updateId){ res = await editScoringResultUsingPost({ id:props.appId as any, ...form.value }); } //创建 else { res = await addScoringResultUsingPost({ appId:props.appId as any, ...form.value }); } if (res.data.code === 0) { message.success("操作成功,即将跳转到应用详情页") } else { message.error("操作失败" + res.data.message); } if(tableRef){ //刷新表格数据 tableRef.value.loadData(); //清空更新id updateId.value = undefined; } };
还有一个问题:创建的评分结果不能及时的显示在表格中
原因:没有刷新表格子组件,怎么做?
子组件需要将加载数据的方法暴露给父组件
// 暴露函数给父组件(原因:新创建的评分结果不能立刻显示在表格中)
defineExpose({
loadData,
});
父组件需要接收所暴露的方法,并且到当请求完后端接口的时候需要调用该方法重新执行loadData
<template> <!--下面ref属性是接收子组件暴露的函数--> <ScoringResultTable :appId="appId" :doUpdate="doUpdate" ref="tableRef" /> </template> <script> //获取子组件所暴露的函数。 const tableRef = ref(); //创建 else { res = await addScoringResultUsingPost({ appId:props.appId as any, ...form.value }); } if (res.data.code === 0) { message.success("操作成功,即将跳转到应用详情页") } else { message.error("操作失败" + res.data.message); } if(tableRef){ //刷新表格数据 tableRef.value.loadData(); //清空更新id updateId.value = undefined; } </script>
首先需要定义一下参数:
需要注意的是获取题目选项需要使用computed
进行动态的渲染
//题目内容结构(理解为题目列表) const questionContent = ref<API.QuestionContentDTO[]>([]); //当前app的信息 const app = ref<API.AppVO>({}); //当前题目序号 const current = ref(1); //当前题目 const currentQuestion = ref<API.QuestionContentDTO>({}); //当前题目选项 const currentQuestionOptions = computed(() =>{ //如果当前题目的选项存在就遍历,否则返回空数组 return currentQuestion.value?.options ? currentQuestion.value.options.map((option) =>{ return {label:`${option.key}.${option.value}`,value:option.key} }) : [] }) //当前用户选择的答案 const currentAnswer = ref<string>(); //回答列表 const answerList = reactive<string[]>([]);
第二步,向后端发请求获取app的信息和获取题目列表(这里获取题目列表在创建题目页的时候获取过,一样的代码)
第三步,通过监听,显示当前题目的改变,当触发选择事件时记录在回答列表中。
watchEffect(() =>{
//current.value - 1:是因为current的默认值为1,题目中索引从0开始,所以要减1
currentQuestion.value = questionContent.value[current.value - 1]
currentAnswer.value = answerList[current.value - 1];
})
/**
* 选中选项后,保存选项记录
* @param value
*/
const doRadioChange = (value: string) => {
answerList[current.value - 1] = value;
};
最后一步,点击提交,将回答列表传给后端,创建一条用户回答记录
//提交按钮 const doSubmit = async () => { if(!props.appId || !questionContent.value){ return; } const res = await addUserAnswerUsingPost({ appId:props.appId as any, choices:answerList }); if (res.data.code === 0 && res.data.data) { message.success("操作成功,即将跳转结果页面") setTimeout(() =>{ router.push({ path: `/answer/result/${res.data.data}`, }) },3000) } else { message.error("操作失败" + res.data.message); } };
这里我们使用的AI模型是智谱开放平台:https://open.bigmodel.cn/dev/api#sdk_auth
同样的根据官方文档快速开始AI的开发
引入依赖:
<dependency>
<groupId>cn.bigmodel.openapi</groupId>
<artifactId>oapi-java-sdk</artifactId>
<version>release-V4-2.0.2</version>
</dependency>
这里先编写一个测试类,测试AI模型的客户端是否能够正常进行连接和使用
创建客户端的代码:(官方文档直接复制)
ClientV4 client = new ClientV4.Builder("{Your ApiSecretKey}").build();
根据官方文档的实例代码编写测试类,确定智谱ai能够进行连接:
@Test public void test(){ //建立连接 ClientV4 clientV4 = new ClientV4.Builder("密钥").build(); //构建请求 List<ChatMessage> messages = new ArrayList<>(); //用户输入的提问语句 ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), "作为一名营销专家,请为智谱开放平台创作一个吸引人的slogan"); messages.add(chatMessage); // String requestId = String.format(requestIdTemplate, System.currentTimeMillis()); ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() //模型选择 .model(Constants.ModelChatGLM4) .stream(Boolean.FALSE) .invokeMethod(Constants.invokeMethod) .messages(messages) .build(); ModelApiResponse invokeModelApiResp = clientV4.invokeModelApi(chatCompletionRequest); System.out.println(invokeModelApiResp.getData().getChoices().get(0)); }
对上面连接的代码进行封装配置
1、首先对客户端的连接进行封装,把他封装为一个实体Bean,能够在任何位置使用 @Resource 进行导入
package com.zsc.yadada.config; import com.zhipu.oapi.ClientV4; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * 智谱api模型客户端连接配置 */ @Configuration @ConfigurationProperties(prefix = "ai") //yml文件对应的前缀 @Data public class ApiConfig { /** * 获取到yml文件的apiKey */ private String apiKey; /** * 标注为一个实体bean,就不需要自行创建,用@Resource引入即可 * @return */ @Bean public ClientV4 getClient(){ return new ClientV4.Builder(apiKey).build(); } }
2、封装构建请求
通过重载的对构建请求进行封装:【尽可能满足多种场景直接进行调用】
package com.zsc.yadada.manager; import com.zhipu.oapi.ClientV4; import com.zhipu.oapi.Constants; import com.zhipu.oapi.service.v4.model.ChatCompletionRequest; import com.zhipu.oapi.service.v4.model.ChatMessage; import com.zhipu.oapi.service.v4.model.ChatMessageRole; import com.zhipu.oapi.service.v4.model.ModelApiResponse; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.ArrayList; import java.util.List; @Component public class AIManager { @Resource private ClientV4 clientV4; public static final Float STABLE_TEMPERATURE = 0.05f; public static final Float UNSTABLE_TEMPERATURE = 0.99F; /** * 回答稳定的请求 * @param systemMessage * @param userMessage * @return */ public String doUnstableRequest(String systemMessage,String userMessage){ return doRequest(systemMessage,userMessage,Boolean.FALSE,UNSTABLE_TEMPERATURE); } /** * 回答稳定的请求 * @param systemMessage * @param userMessage * @return */ public String doStableRequest(String systemMessage,String userMessage){ return doRequest(systemMessage,userMessage,Boolean.FALSE,STABLE_TEMPERATURE); } /** * 同步请求 * @param messages * @param stream * @param temperature * @return */ public String doSyncRequest(List<ChatMessage> messages,Boolean stream,Float temperature){ return doRequest(messages, Boolean.FALSE, temperature); } /** * 提问信息响应 * @param systemMessage:系统信息 * @param stream:用户信息 * @param temperature * @return */ public String doRequest(String systemMessage,String userMessage,Boolean stream,Float temperature){ //构建请求 List<ChatMessage> messages = new ArrayList<>(); ChatMessage systemChatMessage = new ChatMessage(ChatMessageRole.SYSTEM.value(),systemMessage); ChatMessage userChatMessage = new ChatMessage(ChatMessageRole.USER.value(),userMessage); messages.add(systemChatMessage); messages.add(userChatMessage); return doRequest(messages,stream,temperature); } /** * 全局通用响应 * @param messages * @param stream * @param temperature * @return */ public String doRequest(List<ChatMessage> messages,Boolean stream,Float temperature){ //构建请求 ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), "作为一名营销专家,请为智谱开放平台创作一个吸引人的slogan"); messages.add(chatMessage); // String requestId = String.format(requestIdTemplate, System.currentTimeMillis()); ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() //模型选择 .model(Constants.ModelChatGLM4) .stream(stream) .invokeMethod(Constants.invokeMethod) .temperature(temperature) .messages(messages) .build(); ModelApiResponse invokeModelApiResp = clientV4.invokeModelApi(chatCompletionRequest); return invokeModelApiResp.getData().getChoices().get(0).toString(); } }
可能会出现的报错:
解决方法:
原本创建题目需要人工一个个添加标题和选项,比较麻烦。
可以使用 AI,根据已经填写的应用信息,自动生成题目,然后再由人工进行编辑确认,提高创建题目的效率。
设计方案:
AI 生成内容的核心是编写 Prompt,好的、精准的 Prompt 才能帮助我们得到预期的结果。
首先明确我们能提供或者需要输入给 AI 的参数,然后构建 Prompt 并输入给 AI,让 AI 生成题目并处理成我们需要的格式。
编写Prompt的技巧:
系统Prompt示例:
你是一位严谨的出题专家,我会给你如下信息: ``` 应用名称, 【【【应用描述】】】, //这里之所以使用【【【 来括住应用描述是因为防止应用描述的换行和一些特殊字符 应用类别, 要生成的题目数, 每个题目的选项数 ``` 请你根据上述信息,按照以下步骤来出题: 1. 要求:题目和选项尽可能地短,题目不要包含序号,每题的选项数以我提供的为主,题目不能重复 2. 严格按照下面的 json 格式输出题目和选项 ``` [{"options":[{"value":"选项内容","key":"A"},{"value":"","key":"B"}],"title":"题目标题"}] ``` title 是题目,options 是选项,每个选项的 key 按照英文字母序(比如 A、B、C、D)以此类推,value 是选项内容 3. 检查题目是否包含序号,若包含序号则去除序号 4. 返回的题目列表格式必须为 JSON 数组
用户 Prompt 按照顺序提供信息即可,示例 Prompt 如下。
测评类应用:
MBTI 性格测试,
【【【快来测测你的 MBTI 性格】】】,
测评类,
10,
3
得分类应用:
小学数学测验,
【【【小学三年级的数学题】】】,
得分类,
10,
3
有了相对应的prompt,接下来就可以开发后端接口了
流程:用户使用AI生成题目的这个功能,需要用户确定题目的数量和题目选项的数量,并且是在哪个应用下创建题目的。
1、首先定义一个请求类
/** * ai生成题目请求类 */ @Data public class AIGeneratorQuestionRequest implements Serializable { /** * 应用的id */ private Long appId; /** * 题目数量(默认生成10道题目) */ int questionNumber = 10; /** * 选项数量,用测评类(A,B,C,D),有平分类(对,错) */ int optionNumber = 2; private static final long serialVersionUID = 1L; }
2、把上一步编写好的prompt定义为常量,需要注意的是用户的prompt
public static final String QUESTION_GENERATOR_SYSTEM_PROMPT = "你是一位严谨的出题专家,我会给你如下信息:\n" + "```\n" + "应用名称,\n" + "【【【应用描述】】】, //这里之所以使用【【【 来括住应用描述是因为防止应用描述的换行和一些特殊字符\n" + "应用类别,\n" + "要生成的题目数,\n" + "每个题目的选项数\n" + "```\n" + "\n" + "请你根据上述信息,按照以下步骤来出题:\n" + "1. 要求:题目和选项尽可能地短,题目不要包含序号,每题的选项数以我提供的为主,题目不能重复\n" + "2. 严格按照下面的 json 格式输出题目和选项\n" + "```\n" + "[{\"options\":[{\"value\":\"选项内容\",\"key\":\"A\"},{\"value\":\"\",\"key\":\"B\"}],\"title\":\"题目标题\"}]\n" + "```\n" + "title 是题目,options 是选项,每个选项的 key 按照英文字母序(比如 A、B、C、D)以此类推,value 是选项内容\n" + "3. 检查题目是否包含序号,若包含序号则去除序号\n" + "4. 返回的题目列表格式必须为 JSON 数组"; /** * 获取用户的prompt * @param app:题目对用的app * @param questionNumber:题目数量 * @param optionNumber:选项数量 * @return */ public String getUserPrompt(App app,int questionNumber,int optionNumber){ StringBuilder userMessage = new StringBuilder(); userMessage.append(app.getAppName()).append("\n"); userMessage.append(app.getAppDesc()).append("\n"); userMessage.append(AppScoringStrategyEnum.getEnumByValue(app.getScoringStrategy()).getText() + "类").append("\n"); userMessage.append(questionNumber).append("\n"); userMessage.append(optionNumber).append("\n"); return userMessage.toString(); }
3、编写接口,使用AI生成题目
/** * ai生成题目接口 * @param aiGeneratorQuestionRequest * @return */ @PostMapping("ai_generate") public BaseResponse<List<QuestionContentDTO>> aiGenerateQuestion(@RequestBody AIGeneratorQuestionRequest aiGeneratorQuestionRequest){ if(aiGeneratorQuestionRequest == null){ throw new BusinessException(ErrorCode.PARAMS_ERROR); } Long appId = aiGeneratorQuestionRequest.getAppId(); int questionNumber = aiGeneratorQuestionRequest.getQuestionNumber(); int optionNumber = aiGeneratorQuestionRequest.getOptionNumber(); if(appId == null){ throw new BusinessException(ErrorCode.NOT_FOUND_ERROR); } //获取应用 App app = appService.getById(appId); //获取用户的prompt String userPrompt = getUserPrompt(app, questionNumber, optionNumber); //调用ai接口 String json = aiManager.doUnstableRequest(QUESTION_GENERATOR_SYSTEM_PROMPT, userPrompt); //因为ai生成的json会标注为json,存在多余的字段,如下图 int begin = json.indexOf("["); int end = json.indexOf("]"); json.substring(begin,end+1); //把json转成列表 List<QuestionContentDTO> questionList = JSONUtil.toList(json, QuestionContentDTO.class); return ResultUtils.success(questionList); }
注意:AI生成的题目json是下面这样的:【所以要进行分割】
在创建题目的页面中,我们使用 acro design 的抽屉组件,创建一个子组件,将所有ai生成题目按钮,输入的题目数量和选项数量的表单都写在该子组件中。
这里的难点在于子组件与父组件间参数的传递,比如:子组件中调用完后端ai生成题目的接口,想要将获取到的题目列表传给父组件进行展示,这该怎么做?
首先在porps接口中定义一个方法,参数就是获取到的题目列表
后端接口请求成功后,对该方法赋值
//获取前页面跳转过来的参数 interface Props { appId:string //将ai生成的题目传递给父组件 onSuccess:(result:API.QuestionContentDTO[]) => void } const props = withDefaults(defineProps<Props>(),{ appId:() =>{ return ""; } }) const handleSubmit = async () =>{ if(!props.appId){ return; } submitting.value = true; const res = await aiGenerateQuestionUsingPost({ appId:props.appId as any, ...form }); if(res.data.code === 0 && res.data.data){ props.onSuccess(res.data.data) //方法赋值 message.success("生成成功!") //关闭抽屉 handleCancel(); }else{ message.error("生成失败!") } submitting.value = false; }
父组件通过方法名进行接收,并且还需要重新定义该方法
<!--抽屉,【向子组件传递appId,接收子组件的onSuccess方法】-->
<AiGenerateDrawer :appId="appId" :onSuccess="onAiGenerateQuestion"></AiGenerateDrawer>
<script>
//将ai生成的题目回显到题目列表中
const onAiGenerateQuestion = (result:API.QuestionContentDTO[]) => {
message.success(`AI 生成题目成功,生成 ${result.length} 道题目`);
questionContent.value = [...questionContent.value,...result] //回显到原题目列表中
}
</script>
需求分析:
原本题目评分需要让应用创建者自己创建评分结果,并且给题目选项设置得分和对应的属性,比较麻烦。
可以使用 AI,根据应用信息、题目和用户的答案进行评分,直接返回评分结果。这种评分策略更适用于测评类应用,提高创建应用效率的同时,给结果更多的可能性。
所以下面我们主要实现测评类应用的 AI 评分策略,暂时不关注得分类应用的 AI 评分结果,
也可以在前端做一些控制,如果是得分类应用,不支持选择 AI 评分策略等。
AI开发三步走
同样的我们需要编写系统的prompt和用户提交的prompt
因为是对测评,所以喂给ai需要加上题目,还有ai需要给你的返回结果,大致如下
输入示例:
{
"appName": "MBTI 性格测试",
"appDesc": "测试你的 MBTI 性格",
"question": [
{
"title": "你喜欢和人交流",
"answer": "喜欢"
}
]
}
返回结果示例
{
"resultName": "INTJ",
"resultDesc": "INTJ被称为'策略家'或'建筑师',是一个高度独立和具有战略思考能力的性格类型"
}
系统的prompt:
你是一位严谨的判题专家,我会给你如下信息:
```
应用名称,
【【【应用描述】】】,
题目和用户回答的列表:格式为 [{"title": "题目","answer": "用户回答"}]
```
请你根据上述信息,按照以下步骤来对用户进行评价:
1. 要求:需要给出一个明确的评价结果,包括评价名称(尽量简短)和评价描述(尽量详细,大于 200 字)
2. 严格按照下面的 json 格式输出评价名称和评价描述
```
{"resultName": "评价名称", "resultDesc": "评价描述"}
```
3. 返回格式必须为 JSON 对象
用户的prompt:
MBTI 性格测试,
【【【快来测测你的 MBTI 性格】】】,
[{"title": "你通常更喜欢","answer": "独自工作"}, {"title": "当安排活动时","answer": "更愿意随机应变"}]
之前我们使用了策略模式通过自定义的注解来区分:若是自定义评分,测评类调用测评类的评分策略,得分类调用得分类的评分策略;若是AI评分,测评类就喂给ai让ai生成对应的评分结果。
首先根据用户的prompt可知:需要一个应用的名称,应用的描述,题目的标题和对应用户选择的答案,创建一个QuestionDTO
类,因为应用的名称,应用的描述直接根据传来的app获取,所以这个类只需要定义题目的标题和用户选择的答案
/** * 题目答案封装类 */ @Data public class QuestionAnswerDTO{ /** * 题目标题 */ private String title; /** * 用户选择的答案 */ private String userAnswer; }
编写一个获取用户的prompt方法,需要传入的参数:app,List,List choises,首先需要明确,传过来的题目列表是字符串格式。
StringBuilder
用于拼接字符串QuestionDTO
类的列表,使用for i 循环遍历题目列表,每获取一道题目就获取该道题目的用户选项,并加入带 StringBuilder
中/** * 获取用户的prompt * @param app * @param questionContentDTOS:题目列表 * @param choices:用户的选择 * @return */ public String getUserPrompt(App app,List<QuestionContentDTO> questionContentDTOS,List<String> choices){ StringBuilder messages = new StringBuilder(); //获取app名称 messages.append(app.getAppName()).append("\n"); //获取app的描述 messages.append(app.getAppDesc()).append("\n"); //这个题目列表是json格式,我们首先要对其进行遍历,分别取出其标题再取出choices中的答案,对应用户回答该题的答案 List<QuestionAnswerDTO> questionAnswerDTOList = new ArrayList<>(); for(int i = 0; i < questionContentDTOS.size(); i++){ QuestionAnswerDTO questionAnswerDTO = new QuestionAnswerDTO(); questionAnswerDTO.setTitle(questionContentDTOS.get(i).getTitle()); questionAnswerDTO.setUserAnswer(choices.get(i)); questionAnswerDTOList.add(questionAnswerDTO); } messages.append(JSONUtil.toJsonStr(questionAnswerDTOList)); return messages.toString(); }
定义一个AITestScoringStrategy
类,并且实现ScoringStrategy接口,这是策略模式的应用。传入用户的prompt和系统的prompt让ai生成结果,需要注意的是ai生成的结果如下图,我们需要对其进行截取,并且转成一个对象。
/** * AI测评类评分策略 */ @Component @ScoringStrategyConfig(appType = 1,scoringStrategy = 1)//AI测评类 public class AITestScoringStrategy implements ScoringStrategy{ @Resource private QuestionService questionService; @Resource private AIManager aiManager; //系统的prompt public static final String SCORING_GENERATE_PROMPT = "你是一位严谨的判题专家,我会给你如下信息:\n" + "```\n" + "应用名称,\n" + "【【【应用描述】】】,\n" + "题目和用户回答的列表:格式为 [{\"title\": \"题目\",\"answer\": \"用户回答\"}]\n" + "```\n" + "\n" + "请你根据上述信息,按照以下步骤来对用户进行评价:\n" + "1. 要求:需要给出一个明确的评价结果,包括评价名称(尽量简短)和评价描述(尽量详细,大于 200 字)\n" + "2. 严格按照下面的 json 格式输出评价名称和评价描述\n" + "```\n" + "{\"resultName\": \"评价名称\", \"resultDesc\": \"评价描述\"}\n" + "```\n" + "3. 返回格式必须为 JSON 对象"; @Override public UserAnswer doScore(List<String> choices, App app) throws Exception { //1、根据appId 查询到题目和题目结果信息(按分数降序排序) Long id = app.getId(); //使用lambda查询出Question中的appId等于当前应用的id对应的题目 Question question = questionService.getOne(Wrappers.lambdaQuery(Question.class).eq(Question::getAppId, id)); //获取到题目内容 QuestionVO questionVO = QuestionVO.objToVo(question); List<QuestionContentDTO> questionContentList = questionVO.getQuestionContent(); //获取到用户的prompt String userPrompt = getUserPrompt(app, questionContentList, choices); //AI生成评分 String result = aiManager.doStableRequest(SCORING_GENERATE_PROMPT, userPrompt); int start = result.indexOf("{"); int end = result.indexOf("}"); String json = result.substring(start, end + 1); UserAnswer userAnswer = JSONUtil.toBean(json, UserAnswer.class); //4、构造返回值,填充答案对象的属性 userAnswer.setAppId(id); userAnswer.setAppType(app.getAppType()); userAnswer.setScoringStrategy(app.getScoringStrategy()); userAnswer.setChoices(JSONUtil.toJsonStr(choices)); return userAnswer; }
提出问题:由于AI生成出来结果十分慢,给用户的响应效果及其不友好,那么怎么样AI生成什么就立马显示再页面上呢?这种效果能够用户更好的反馈
那么再智谱ai中,它使用的是一种流式接口的调用方法
这个流式请求是意思?Flowable 又是什么?可以给我们的项目带来哪些优化呢?
实际上 Flowable 是 RxJava 响应式编程库中定义的类,为了更好地进行流式开发,我们要先来了解下响应式编程和 RxJava。
什么是响应式编程?
它类似于java8 StreamAPI的那种写法,使用一种链式结果对链表进行一系列操作。
具体的定义:响应式编程(Reactive Programming)是一种编程范式,它专注于 异步数据流 和 变化传播。
核心概念:
数据流:响应式编程中,数据以流的形式存在,流就像一条河流,源源不断、有一个流向(比如从A系统到B系统再到C系统),它可以被过滤、观测、或者跟另一条河流合并成一个新的流
异步处理:响应式编程是异步的,即操作不会阻塞线程,而是通过回调或者其他机制再未来某个时间点处理结果。提高了应用的响应性和性能
变化传播:当数据源发生变化时,响应式编程模型会自动将变化传播到依赖这些数据源的地方。这种传播是自动的,不需要显示调用。【举个例子:有一只股票涨了,所有订阅了这只股票的人,都会同时收到app的通知,不哦那个你自己盯着看】
什么是RxJava?
事件可以是任何事情,如用户点击操作、网络请求的结果、文件的读写等。事件驱动的编程模型是通过事件触发行动的。
比如前端开发中,用户点击按钮触发的弹窗
再RxJava中,事件可以被看作是数据流中的数据项,被称为事件流或数据流。每当一个事件发生,这个事件就会被推送到给那些对它感兴趣的观察者
可观测序列是指一系列按照时间顺序发出的数据项,可以被观察和处理。可观测序列提供了一种将数据流和异步事件建模为一系列可以订阅和操作的事件的方式。
可以理解为在数据流的基础上封装了一层,多加了一点方法。
RxJava的核心知识
RxJava 是基于 观察者模式 实现的,分别有观察者和被观察者两个角色,被观察者会实时传输数据流,观察者可以观测到这些数据流。
基于传输和观察的过程,用户可以通过一些操作方法对数据进行转换或其他处理。
在 RxJava 中,观察者就是 Observer,被观察者是 Observable 和 Flowable。
Observable 适合处理相对较小的、可控的、不会迅速产生大量数据的场景。它不具备背压处理能力,也就是说,当数据生产速度超过数据消费速度时,可能会导致内存溢出或其他性能问题。
Flowable 是针对背压(反向压力)问题而设计的可观测类型。背压问题出现于数据生产速度超过数据消费速度的场景。Flowable 提供了多种背压策略来处理这种情况,确保系统在处理大量数据时仍然能够保持稳定。
被观察者.subscribe(观察者)
,它们之间就建立的订阅关系,被观察者传输的数据或者发出的事件会被观察者观察到。
常用操作符
变换类操作符,对数据流进行变换,如map、flatMap等
比如mao将int类型转为string
聚合类操作符,将数据流i进行聚合,如toList、toMap
过滤操作符,过滤或者跳过一些数据,如:filter,skip
连接操作符,将两个数据流连接到一起,如concat、zip
排序操作符,对数据流内的数据进行排序,如 sorted
事件
RxJava 也是一个基于事件驱动的框架,我们来看看一共有哪些事件,分别在什么时候触发:
代码示例演示RxJava
由于我们引入智谱ai的依赖已经包含了RxJava依赖,所以这里不用引入
public class RxjavaTest { @Test public void test() throws InterruptedException { //创建数据流 //interval的作用:每个一段时间产生一个流 Flowable<Long> flowable = Flowable.interval(1, TimeUnit.SECONDS) .map(i -> i + 1)//让流中的数据+1 .subscribeOn(Schedulers.io());//创建一个io类的线程池 //订阅Flowable 流,并且打印出每个接收到的数字 flowable .subscribeOn(Schedulers.io())//创建一个io类的线程池 .doOnNext(item -> System.out.println(item.toString()))//对流进行操作 .subscribe();//进行对流的订阅 //主线程休眠以便观察到结果 Thread.sleep(10000L); } }
需求分析
原先 AI 生成题目的场景响应较慢,如果题目数过多,容易产生请求超时;并且界面上没有响应,用户体验不佳。
需要 流式化改造 AI 生成题目接口,一道一道地实时返回已生成题目给前端,而不是让前端请求一直阻塞等待,最后一起返回,提升用户体验且避免请求超时。
首先智谱 AI 为我们提供了流式响应的支持,数据已经可以一点一点地返回给后端了,那么我们要思考的问题是如何让后端接收到的一点一点的内容实时返回给前端?
需要进行一些调研,来了解前后端实时通讯的方案。
前后端实时通讯方案
轮询(前端主动去要)
前端间隔一定时间就调用后端提供的结果接口,比如200ms一次,后端处理一些结果就累加放置在缓存中
SEE(后端推送给前端)
前端发送请求并和后端建立连接后,后端可以实时推送数据给前端,无需前端自主轮询
WebSocket
全双工协议,前端能实时推送数据给后端(或者从后端缓存拿数据),后端也可以实时推送数据给前端。
服务器发送事件(Server-Sent Events)是一种用于从服务器到客户端的 单向、实时 数据传输技术,基于 HTTP协议实现。
特点:
text/event-stream
MIME 类型。SEE数据格式
SSE 数据流的格式非常简单,每个事件使用 data
字段,事件以两个换行符结束。还可以使用 id
字段来标识事件,并且 retry
字段可以设置重新连接的时间间隔。
data: First message\n
\n
data: Second message\n
\n
data: Third message\n
id: 3\n
\n
retry: 10000\n
data: Fourth message\n
\n
方案对比
熟悉了 SSE 技术后,对比上述前后端实时通讯方案。
1)主动轮询其实是一种伪实时,比如每 3 秒轮询请求一次,结果后端在 0.1 秒就返回了数据,还要再等 2.9 秒,存在延迟。
2)WebSocket 和 SSE 虽然都能实现服务端推送,但 Websocket 会更复杂些,且是二进制协议,调试不方便。AI 对话只需要服务器单向推送即可,不需要使用双向通信,所以选择文本格式的 SSE。
AI生成题目的具体优化流程:
1)前端向后端发送普通 HTTP 请求1647489399610462209_0.43728996091195316
2)后端创建 SSE 连接对象,为后续的推送做准备
3)后端流式调用智谱 AI,获取到数据流,使用 RxJava 订阅数据流
4)以 SSE 的方式响应前端,至此接口主流程已执行完成
5)异步:基于 RxJava 实时获取到智谱 AI 的数据,并持续将数据拼接为字符串,当拼接出一道完整题目时,通过 SSE 推送给前端。
6)前端每获取一道题目,立刻插入到表单项中
首先先修改AiManager
类中的调用ai接口的方法,之前写了通用请求类,现在加一个流式请求,将.stream()
改为.stream(Boolean.TRUE)
,表示开启流式,并且获取的返回值为getFlowable
表示获取流。【参照之前的全局请求,再重写一个支持传入用户prompt和系统prompt和稳定值的方法】
/** * 流式通用响应 * @param messages * @param temperature * @return */ public Flowable<ModelData> doRequest(List<ChatMessage> messages, Float temperature){ //构建请求 ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() //模型选择 .model(Constants.ModelChatGLM4) .stream(Boolean.TRUE) .invokeMethod(Constants.invokeMethod) .temperature(temperature) .messages(messages) .build(); try { ModelApiResponse invokeModelApiResp = clientV4.invokeModelApi(chatCompletionRequest); return invokeModelApiResp.getFlowable(); } catch (Exception e) { e.printStackTrace(); throw new BusinessException(ErrorCode.SYSTEM_ERROR, e.getMessage()); } }
复制原有的ai生成题目的接口,将PostMapping
改为GetMapping
,在此基础上修改代码
SseEmitter
,并且是get请求不需要@RequestBody
AiManager
的方法,获取到ai回答的流Flowable<ModelData>
//建立SSE连接对象,0表示永不过时 SseEmitter sseEmitter = new SseEmitter(0L); //调用ai接口,sse流式返回 Flowable<ModelData> modelDataFlowable = aiManager.doStreamUnstableRequest(QUESTION_GENERATOR_SYSTEM_PROMPT, userPrompt); StringBuilder sb = new StringBuilder();//用于拼接字符 //定义一个计数器,注意一定是原子类,因为下面的可能是异步的 AtomicInteger count = new AtomicInteger(0); //订阅流 modelDataFlowable .observeOn(Schedulers.io())//创建线程池 .map(modelData -> modelData.getChoices().get(0).getDelta().getContent())//取出ai回答的消息 .map(message -> message.replaceAll("\\s",""))//使用正则表达式将所有非法的字符串转成空字符 .filter(message -> StringUtils.isNotBlank(message))//过滤掉空字符串 .flatMap(message -> { List<Character> characterList = new ArrayList<>(); for (char c : message.toCharArray()) { characterList.add(c); } return Flowable.fromIterable(characterList); })//把完成字符串的流分成多个只有一个字符的流,即每个流都是一个字符 .doOnNext(c ->{ //如果字符是 '{',计数器+1 if(c == '{'){ count.addAndGet(1);//加一 } if(count.get() > 0){ sb.append(c); } if(c == '}'){ count.addAndGet(-1); if(count.get() == 0){ //意味着已经stringBuilder已经拼接了一道题目了 //通过SSE将题目发给前端及时显示给用户,注意要转成json格式 sseEmitter.send(JSONUtil.toJsonStr(sb.toString())); //重置stringBuilder sb.setLength(0); } } })//将字符拼接成完整的题目 .doOnComplete(() ->{ sseEmitter.complete();//告诉前端题目生成完成 }) .subscribe();
ai回答的数据【所以需要处理】:
那该如何处理呢?如果是切割字符串的这种思路是不可行的,ai回答答案是断断续续生成的,会出现很多种情况的字符串,【这里可以将这个字符串变成一个个字符】
存在的问题:ai生成的评分非常慢,十分影响用户的体验,如何进行优化呢?
若用户答得是同一道题目,并且每个选项与之前自己回答过或者其他用户回答过一致,那生成的结果也是一致的,那么这里就可以将结果进行缓存,这样做不仅性能得到了优化,并且不会再调取ai接口减少token的消耗。
那么进行缓存就要设置key-value,这里就将appId和用户选择的答案列表作为key进行唯一标识,value就是ai返回的评分结果。
这是使用的caffeine进行本地缓存。
缓存的技术选型上,一般是本地缓存和 Redis 分布式缓存。
如果项目不考虑分布式或扩容、且不要求持久化,一般用本地缓存能解决的问题,就不要用分布式缓存,会增加系统的复杂度。
对于我们的缓存需求,哪怕是多机部署,每台服务器上分别缓存也是 ok 的,不用保证多台机器缓存间的一致性,所以采用 Caffeine 本地缓存。
业务流程:
需要注意:应用题目发生变更时,需要清理缓存(比如appId不做MD5,就可以根据appId去清理特定前缀的缓存)
使用caffeine编写缓存的代码实现步骤:
引入依赖(不多说,这里引入的是caffeine的依赖)
定义一个Cache,注意这个Cache是一个map,存放的是key-value,那我们将appId和用户选择的答案列表作为key,value就是ai返回的评分结果。要设置该缓存的容量和过期时间。【这个变量就是存储缓存的地方】
private final Cache<String,String> answerCacheMap =
Caffeine.newBuilder().initialCapacity(1024) //给缓存设置容量
//设置5分钟过期
.expireAfterAccess(5L, TimeUnit.MINUTES).build();
定义一个创建缓存key的方法,按照之前说的进行创建,传入的参数为appId,String choices
,
/**
* 构建缓存key(根据appId和用户选择的答案构建缓存key)
* @param appId
* @param choices
* @return
*/
public String buildCacheKey(Long appId,String choices){
return DigestUtil.md5Hex(appId + ":" + choices);
}
在进行ai评分结果的方法中,首先appId和用户的选择获取到对应的缓存key,根据key到缓存map中查看是否存在缓存,若存在,则返回缓存中的value,若没有就将ai生成的结果存放到缓存map中
//1、根据appId 查询到题目和题目结果信息(按分数降序排序) Long id = app.getId(); /*如果有缓存从缓存中获取数据*/ //首先先将用户的选择转成json String jsonStr = JSONUtil.toJsonStr(choices); //获取缓存key String cacheKey = buildCacheKey(id, jsonStr); //获取缓存中对应key的值,并判断是否存在缓存 String answerJson = answerCacheMap.getIfPresent(cacheKey); if(StringUtils.isNotBlank(answerJson)){ UserAnswer userAnswer = JSONUtil.toBean(answerJson, UserAnswer.class); userAnswer.setAppId(id); userAnswer.setAppType(app.getAppType()); userAnswer.setScoringStrategy(app.getScoringStrategy()); userAnswer.setChoices(jsonStr); return userAnswer; }
至此完成。
完成的ai的评分回答的缓存,思考:如果同一时刻有大量的用户答题,比如 1w 个用户,且答题选择都是一致的,但没有命中缓存(刚好过期),这时候会有 1w 个请求并发访问 AI。
这其实就是缓存击穿问题,即大量请求并发访问热点数据,刚好热点数据过期,会直接绕过缓存,命中数据库或 AI 接口。
在 AI 场景因接口限流,AI 应该不会崩溃,但是 token(钱)浪费了,而且搞不好平台会以为你的服务器是攻击者,把你的 IP 封禁。在数据库场景,所有请求打到数据库上,数据库可能直接宕机。
解决方法:因此,我们需要避免缓存击穿,一种常见的解决方式就是加锁。如果服务部署在多个机器上,就必须要使用分布式锁。可以直接使用 Redisson 客户端,它为 Redis 提供了多种数据结构的支持,并提供了线程安全的操作,简化了在 Java 中使用 Redis 的复杂度。Redisson 对 Redis 的一些功能进行了增强,如分布式锁、计数器、队列等,使得 Redis 的使用更加方便。
代码实现Redisson分布式锁
配置Redisson连接,写一个配置类(该配置类要对应yml中redis中的配置),在配置类写一个建立连接的方法并标注为一个Bean,
/** * Redisson配置类 */ @Configuration @ConfigurationProperties(prefix = "spring.redis") @Data public class RedissonConfig { private Integer database; private String host; private Integer port; @Bean public RedissonClient redisClient(){ Config config = new Config(); config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setDatabase(database); return Redisson.create(config); } }
在AI评分中设置锁,思考:应该在哪里设置锁呢?回顾问题:比如 1w 个用户,且答题选择都是一致的,但没有命中缓存(刚好过期),这时候会有 1w 个请求并发访问 AI。所以就要在没有命中缓存的时候添加锁
首先先定义一个分布式锁的key
定义一把锁
创建一个try-finally,try中的逻辑就是锁的竞争,无论如何都要释放锁,所以要有finally,注意释放锁一定要判断锁是否存在,锁是否被占用,并且锁还得是自己的才能释放
/*没有命中缓存,就加锁*/ //定义锁 RLock lock = redissonClient.getLock(AI_ANSWER_LOCK + cacheKey); try{ //竞争锁 boolean res = lock.tryLock(3, 15, TimeUnit.SECONDS); //没抢到锁直接返回空 if(!res){ return null; } //抢到锁执行后续代码 //使用lambda查询出Question中的appId等于当前应用的id对应的题目 Question question = questionService.getOne(Wrappers.lambdaQuery(Question.class).eq(Question::getAppId, id)); //获取到题目内容 QuestionVO questionVO = QuestionVO.objToVo(question); List<QuestionContentDTO> questionContentList = questionVO.getQuestionContent(); //获取到用户的prompt String userPrompt = getUserPrompt(app, questionContentList, choices); //AI生成评分 String result = aiManager.doStableRequest(SCORING_GENERATE_PROMPT, userPrompt); int start = result.indexOf("{"); int end = result.indexOf("}"); String json = result.substring(start, end + 1); //进行缓存,将ai生成的结果存到缓存中 answerCacheMap.put(cacheKey,json); //4、构造返回值,填充答案对象的属性 //将ai的答案json转成对象 UserAnswer userAnswer = JSONUtil.toBean(json, UserAnswer.class); userAnswer.setAppId(id); userAnswer.setAppType(app.getAppType()); userAnswer.setScoringStrategy(app.getScoringStrategy()); userAnswer.setChoices(JSONUtil.toJsonStr(choices)); return userAnswer; }finally { //如果锁不为空并且锁是被使用的 if(lock != null && lock.isLocked()){ //锁只能被自己释放 if(lock.isHeldByCurrentThread()){ lock.unlock(); } } }
回顾一下我们数据库设计:
app应用表
显然不会,成百上千的应用已经多,但对数据库而已,这还是小量级
question题目表
不太可能,一个应用一般最多也就几十个题目
scoring_result评分结果表
不太可能,一个应用对应不会有多少结果,比如 MBTI 也就16个
user表
有可能,如果用户达到几千万级,确实有点可能
user_answer用户答题记录表
一个用户可以对同个应用多次答题,也可以在多个应用多次答题,理论上如果用户量足够大,那么这个表肯定是最优先遇到瓶颈的。处理清理数据外,常见的一种优化方案是分库分表。
分库分表概念
随着用户量的激增和时间的堆砌,存在数据库里面的数据越来越多,此时的数据库就会产生瓶颈,出现资源报警、查询慢等场景。
首先单机数据库所能承载的连接数、I/O 及网络的吞吐等都是有限的,所以当并发量上来了之后,数据库就渐渐顶不住了。
而且如果单表的数据量过大,查询的性能也会下降。因为数据越多底层存储的 B+ 树就越高,树越高则查询 I/O 的次数就越多,那么性能也就越差。
分库分表怎么区分呢?
把以前存在一个数据库实例里的数据拆分成多个数据库实例,部署在不同的服务器中,这是分库。
把以前存在一张表里面的数据拆分成多张表,这就是分表
分库分表开源组件选型
ShardingSphere(Sharding-JDBC)原理
可以用几个字总结:改写SQL
首先进行分表需要明确对表中的哪个字段进行分表
根据上述分析的需求,【一个用户可以对一个应用回答许多次,用户答题的答案就会存放在 user_answer表中,随着用户的增多,这张表是最有可能需要分表的】,这里我们可以在 user_answer 表中根据appId
进行区分:
将 appId % 2 等于 0 的应用的所有用户的答题记录都划分到 user_answer_0,等于 1 的应用的所有用户的答题记录都划分到 user_answer_1。
按照我们正常的想法处理逻辑就是:
if(appId % 2 == 0){
userAnswer0Service.save(userAnswer);
} else {
userAnswer1Service.save(userAnswer);
}
而使用 Sharding-JDBC后,我们只要写好配置,Sharding-JDBC 就会根据配置,执行逻辑,在业务代码上就可以透明化分库分表的存在,
实现流程:
根据实现流程进行后端开发
1、新建两个用户回答表,并且以0和1区分
create table user_answer_0 ( id bigint auto_increment primary key, appId bigint not null comment '应用 id', appType tinyint default 0 not null comment '应用类型(0-得分类,1-角色测评类)', scoringStrategy tinyint default 0 not null comment '评分策略(0-自定义,1-AI)', choices text null comment '用户答案(JSON 数组)', resultId bigint null comment '评分结果 id', resultName varchar(128) null comment '结果名称,如物流师', resultDesc text null comment '结果描述', resultPicture varchar(1024) null comment '结果图标', resultScore int 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 '是否删除' ) comment '用户答题记录' collate = utf8mb4_unicode_ci; create table user_answer_1 ( id bigint auto_increment primary key, appId bigint not null comment '应用 id', appType tinyint default 0 not null comment '应用类型(0-得分类,1-角色测评类)', scoringStrategy tinyint default 0 not null comment '评分策略(0-自定义,1-AI)', choices text null comment '用户答案(JSON 数组)', resultId bigint null comment '评分结果 id', resultName varchar(128) null comment '结果名称,如物流师', resultDesc text null comment '结果描述', resultPicture varchar(1024) null comment '结果图标', resultScore int 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 '是否删除' ) comment '用户答题记录' collate = utf8mb4_unicode_ci;
2、引入依赖
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.2.0</version>
</dependency>
3、yml文件中进行配置
spring: shardingsphere: #数据源配置 datasource: # 多数据源以逗号隔开即可 names: yudada yudada: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/yudada username: root password: "数据库密码" # 规则配置 rules: sharding: # 分片算法配置 sharding-algorithms: # 自定义分片规则名 answer-table-inline: ## inline 类型是简单的配置文件里面就能写的类型,其他还有自定义类等等 type: INLINE props: algorithm-expression: user_answer_$->{appId % 2} tables: user_answer: actual-data-nodes: yudada.user_answer_$->{0..1} # 分表策略 table-strategy: standard: sharding-column: appId sharding-algorithm-name: answer-table-inline
对上面的yml文件解析:
1)需要将数据源挪至 shardingsphere 下
2)定于数据源的名字和 url 等配置
3)自定义分片规则,即 answer-table-inline,分片算法为 user_answer_$->{appId % 2} ,这个含义就是根据 appId % 2 的结果拼接表名,改写 SQL
4)设置对应的表使用分片规则,即 tables:user_answer:table-strategy,指定分片键为 appId,分片的规则是 answer-table-inline
5、需要注意当该表的 appId
作为分表的字段时,再用户创建用户回答的接口中,不能将appId
设置到user_answer表中。
什么时幂等性?
在编程场景指的是:使用相同参数来调用同一接口,调用多次的结果跟单次产生的结果是一致的。比如支付扣款、发货等等。
思考:因为网络问题我们调用扣款接口超时了,并且没有进行重试,这样有可能给用户发货了,但是实际没扣款。因此这种情况下通常要重试扣款。但是如果重试了,假设之前超时的那次调用实际是成功了,只是响应结果的时候接口超时了,这样不是重复扣两次款了?
需要注意,虽然前端可以通过将按钮置灰防止重复点击,但是纯前端无法完美实现幂等性!比如前端调用后端接口超时,有可能后端已经存储了数据,此时前端的按钮已经可点击,用户再次点击就会生成两条数据。
回到本项目进行需求分析(在该项目中为什么需要幂等性)
在本项目的用户答题功能中,如果用户选择完答案后,快速点击答题接口,数据库可能会产生多条相同的回答记录,并且调用多次 AI。
虽然调用多次 AI 的问题已经在之前通过缓存解决,但仍然要防止用户不小心点击多次导致产生多条回答记录,因此需要对接口进行幂等处理。
方案选型
利用数据库唯一索引的一致性保证幂等性。
比如将数据库订单表中的订单号字段配置成唯一索引,用户生成订单执行insert语句,MySQL根据唯一索引天然阻止相同订单号数据的插入,我们可以catch住报错,让接口正常返回插入成功结果。
try {
insertOrder({id: xxx});
} catch(DuplicateKeyException e) {
return true;
}
对应的订单插入语句为:
INSERT INTO order (id,orderNo,xx,updateTime) VALUES (1,2,3,"2024-05-28 15:55:18")
ON DUPLICATE KEY UPDATE updateTime = now();
这样用一笔订单,不论调用几次,结果都不会新增重复的订单记录
使用乐观锁的场景:比如需要对一个配置进行修改,同时记录修改的时间、旧配置、新配置、操作人等日志信息到操作记录表中,方便后面追溯。
// update sys_config set config = "a" where id = 1;
updateConfig();
// insert opreation_log (createTime, oldConfig, newConfig, userId)
// value("2024-05-28 15:55:18", "b", "a", 1L)
addOpreationLog();
乐观锁的实现并不是正真的加锁,而是可以给配置表加一个version版本号字段,每次修改需要验证版本号是否等于修改前的(没被别人同时修改),然后才能给版本号加1。
如果配置表修改成功(通过影响行数来判断 1 表示成功,0 表示失败)
因此进行如下改造:
// update sys_config set config = "a", version = version + 1
// where id = 1 and version = 1;
int updateEffect = updateConfig();
// insert opreation_log (createTime, oldConfig, newConfig, userId)
// value("2024-05-28 15:55:18", "b", "a", 1L)
if (updateEffect == 1) {
addOpreationLog();
}
比如一些 delete 操作,这种是天然幂等的,因为删除一次和多次都是一样的。
还有一些更新操作,例如 :
update sys_config set config = "a" where id = 1;
这样的 SQL 不论执行几遍,结果都是一样的。1647489399610462209_0.4508506403325405
如果接口里面仅包含上述的这些天然幂等的行为,那么对外就可以标记当前接口为幂等接口,不需要任何其他操作。
导致数据错乱的元凶很多时候都是”并发修改“。
很多时候业务场景是这样的:
1、查找数据
2、if (不包含这个数据) {
3、插入这条数据
}
在没有并发的情况下,这样的逻辑没任何问题,但是一旦出现并发,就会导致数据不一致的情况。
因为同时可能出现多个线程在同一时刻到达第 2 步的判断,这时候其实数据都没有插入,因此它们都能通过这个判断到达第 3 步,这就导致重复插入一样的数据。
针对这种场景,可以上一把分布式锁,杜绝并发问题:
分布式锁 {
1、查找数据
2、if (不包含这个数据) {
3、插入这条数据
}
}
思考:所以本项目用哪种方案合适?
根据上面的分析,这里使用数据库唯一索引来实现幂等性,防止用户重复点击查看结果导致重复向后端数据库插入数据。
流程:
给用户答题记录表的哪个字段添加唯一索引呢?
id一般是首要选择,因为本来就是唯一的。但是由于插入新数据,id还没有生成,怎么办?
解决方法:造一个字段来作为唯一索引就好了。
这样改造后,每个用户答题时,即使提交多次,也能避免多条记录的产生。
唯一id生成
有一种方案:生成随机 UUID 字符串,并且在数据库中新增一列唯一索引存储 UUID。但其实没必要新增一列,因为表里面的主键本身就是唯一的,所以可以复用主键来进行唯一性判断。因为主键的类型是 bigint,所以只需要更换唯一 id 生成的策略,使用雪花算法来生成分布式全局唯一的自增 id 即可。
可以使用 Hutool 工具类提供的 IdUtil 工具类来基于雪花算法生成 id。
雪花算法的原理图:
1、在UserAnswerController
下新增生成id的接口:
@GetMapping("/generate/id")
public BaseResponse<Long> generateUserAnswerId(){
//使用胡图工具类的雪花算法生成id
return ResultUtils.success(IdUtil.getSnowflakeNextId());
}
2、在userAnswerAddRequest
中新增一个字段id,类型为Long,表示生成的uuid
@Data public class UserAnswerAddRequest implements Serializable { /** * id(防止用户重复提交) */ private Long id; /** * 应用 id */ private Long appId; /** * 用户答案(JSON 数组) */ private List<String> choices; private static final long serialVersionUID = 1L; }
3、补充userAnswer的校验规则,当获取到userAnswerAddRequest
中的id要判断该id不能为空,并且id不能 <= 0
ThrowUtils.throwIf(userAnswer == null, ErrorCode.PARAMS_ERROR);
// 从对象中取值
Long appId = userAnswer.getAppId();
Long id = userAnswer.getId();
// 创建数据时,参数不能为空
if (add) {
// 补充校验规则
ThrowUtils.throwIf(appId == null, ErrorCode.PARAMS_ERROR, "应用不存在");
ThrowUtils.throwIf(id == null || id <= 0,ErrorCode.PARAMS_ERROR,"id不存在");
}
5、创建用户答题 插入数据库前要进行判断,若重复报错就异常
/* 写入数据库 */
//若用户重复插入,就报错
try {
boolean result = userAnswerService.save(userAnswer);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}catch (DuplicateKeyException e){
//ignore error
}
DoQuestionPage
这个页面一加载就要获取id,做法为:定义一个ref变量接收生成的id,定义一个方法向后端发请求生成id,最后使用WatchEffect
进行监听,页面一加载就触发事件获取id回顾:之前使用 RxJava 实现 AI 题目生成的时候,用到了 Schedulers.io()
方法,创建了一个 I/O 密集型线程池来处理智谱 AI 返回的流。
存在的问题:Schedulers.io()
这个线程池是全局共享的
什么是全局共享?
举例:【比如组内的一个同学上线了一个功能,用到了共享的线程池,但是他写的代码有 bug ,导致的线程池里的所有线程都被阻塞了。你本来在那边喝着咖啡笑看他在那里手忙脚乱,突然发现报警群里面发出了新的告警通知,一看是你负责的业务。原因是你的业务跟他共用了一个线程池,他的任务占着线程不放,你的任务当然也会被阻塞住,所以也告警了,你就傻眼了。】
所以为了解决全局共享线程池所带来的问题我们需要进行线程池的隔离,优点:
假设 AI 答题用户分为了两类,一类是普通用户,一类是 VIP 用户。可以给 VIP 用户设置独立的线程池来处理 AI 题目生成功能,普通用户使用 Schedulers.single()
单线程,防止普通用户太多占用资源。是不是很真实?
方案设计:
Schedulers.single()
,即单个线程池首先隔离线程池,意思是将不同用户角色使用不同的线程池。
所以这里我们可以自定义一个线程池专门给VIP角色进行使用。
1、那么首先先要写一个配置类来自定义一个线程池:
注意线程的返回值一定是Scheduler
,因为订阅流中的默认的线程池是Scheduler
类型,使用一个Executors
自定义一个线程池,这是创建线程池最常用的方法,设置核心线程的数量,通过线程工厂设置线程的名称
@Configuration public class VipSchedulerConfig { @Bean public Scheduler vipScheduler(){ /*使用Executors 进行自定义线程池*/ //线程工厂的作用是:定义如何创建线程 ThreadFactory threadFactory = new ThreadFactory() { //定义一个原子计数器 private final AtomicInteger threadName = new AtomicInteger(0); @Override public Thread newThread(@NotNull Runnable r) { //自定义线程的名称 Thread thread = new Thread(r,"VIPThreadPool-" + threadName.getAndIncrement()); thread.setDaemon(false);//非守护线程 return thread; } }; //第一个参数:线程池的核心线程 //第二个参数:线程工厂 ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10, threadFactory); return Schedulers.from(scheduledExecutorService); } }
2、改造通过 SSE 方式让 AI 生成题目的接口,补充根据用户身份使用不同的线程池的逻辑。
// 注入 VIP 线程池 @Resource private Scheduler vipScheduler; @GetMapping("/ai_generate/sse") public SseEmitter aiGenerateQuestionSSE( AiGenerateQuestionRequest aiGenerateQuestionRequest, HttpServletRequest request) { ... // 默认全局线程池 Scheduler scheduler = Schedulers.single(); User loginUser = userService.getLoginUser(request); // 如果用户是 VIP,则使用定制线程池 if ("vip".equals(loginUser.getUserRole())) { scheduler = vipScheduler; } // 订阅流 modelDataFlowable .observeOn(scheduler) .subscribe(); return sseEmitter; }
需求分析
在 AI 答题应用平台中,我们可以分析哪个 App 用户使用的最多,后期功能迭代时,可根据统计结果,把热门的应用排在首页的靠前位置,并且添加缓存以提升访问速度。
也可以分析一个应用内用户的答案结果分布,根据分布结果,可优先对群体大的用户进行定制化开发或广告投放,吸引更多用户。
比如 MBTI 性格测试结果大部分是 I 人,那么系统可以推送适合 I 人的书籍,或者跟其他需要这类人群的产品合作。
技术选型
我们要统计哪个app最热门?
首先先明确返回值:肯定就是appId对应的统计结果,所以我们封装一个返回DTO
@Data
public class AppAnswerCountDTO {
private Long appId;
/**
* 统计出该app的使用次数
*/
private Integer appCount;
}
在mapper中使用@select
编写sql语句
@Select("select appId,count(userId) as appCount from user_answer\n" +
" group by appId order by appCount desc;")
List<AppAnswerCountDTO> doAppAnswerCount();
创建接口调用该方法
@GetMapping("app_count")
public BaseResponse<List<AppAnswerCountDTO>> countAppAnswer(){
List<AppAnswerCountDTO> appAnswerCountDTOS = userAnswerMapper.doAppAnswerCount();
return ResultUtils.success(appAnswerCountDTOS);
}
我们要统计哪个一种人格是最多的?
首先先明确返回值:肯定就是appId对应的统计人格结果,所以我们封装一个返回DTO
@Data
public class AppAnswerResultCountDTO {
/**
* 结果名称
*/
private String resultName;
/**
* 统计某个人格的次数
*/
private Integer resultNameCount;
}
在mapper中使用@select
编写sql语句
@Select("select resultName,count(resultName) as resultNameCount from user_answer\n" +
" where appId = #{appId}\n" +
" group by resultName order by resultNameCount desc ;")
List<AppAnswerResultCountDTO> doAppAnswerResultCount(Long appId);
创建接口调用该方法
@GetMapping("answer_result_count")
public BaseResponse<List<AppAnswerResultCountDTO>> countAppAnswerResult(Long appId){
if(appId == null || appId <= 0){
throw new BusinessException(ErrorCode.SYSTEM_ERROR,"appId为空");
}
List<AppAnswerResultCountDTO> appAnswerResultCountDTOS = userAnswerMapper.doAppAnswerResultCount(appId);
return ResultUtils.success(appAnswerResultCountDTOS);
}
这里使用 vue-echarts
实现可视化数据,文档链接:https://echarts.apache.org/examples/zh/index.html#chart-type-pie
首先需要安装类库:
npm i echarts vue-echarts
在需要使用图标的页面显示js:
import VChart from "vue-echarts";
import "echarts";
定义一个变量接收应用统计的数据,调取后端接口,接收统计的数据
//应用统计数据
const appCountList = ref<API.AppAnswerCountDTO[]>([])
//调用接口获取应用数据
const loadAppCount = async () =>{
const res = await countAppAnswerUsingGet();
if(res.data.code === 0){
appCountList.value = res.data.data || [];
}else{
message.error("获取失败" + res.data.message)
}
}
定义一个变量接收答案结果统计的数据,调取后端接口,接收统计的数据
//答案结果统计数据
const appAnswerResultCountList = ref<API.AppAnswerResultCountDTO[]>([])
const loadAppAnswerResultCount = async (appId:string) =>{
const res = await countAppAnswerResultUsingGet({
appId:appId as any,
});
if(res.data.code === 0){
appAnswerResultCountList.value = res.data.data || [];
}else{
message.error("获取失败" + res.data.message)
}
}
使用watchEffect
分别监听上面这两个方法,及时刷新值得变化
watchEffect(() =>{ //注意需要赋一个默认值为空串
loadAppAnswerResultCount("")
})
watchEffect(() =>{
loadAppCount()
})
参考echart文档,编写柱状图所需要得参数,需要注意:要使用computed
计算属性及时渲染。
const AppCountOption = computed(() =>{ return{ xAxis: { type: 'category', data: appCountList.value.map((item) => item.appId),//遍历结果统计列表,取出每一个appId name: "应用Id" }, yAxis: { type: 'value', name:"用户答案数" }, series: [ { data: appCountList.value.map((item) => item.appCount),//遍历结果统计列表,取出每一个appCount type: 'bar' } ] }; })
编写饼图同理:
const AppAnswerResultOption = computed(() =>{ return { tooltip: { trigger: 'item' }, legend: { orient: 'vertical', left: 'left' }, series: [ { name: '应用答案结果分布', type: 'pie', radius: '50%', data: appAnswerResultCountList.value.map((item) => { return{ value:item.resultNameCount, name:item.resultName } }), emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } } } ] }; })
最后使用在中使用组件显示出来:
<v-chart :option="上面获取数据得方法" style="height: 300px" />
1、管理员可以在用户管理页设置某个用户为管理员或设置某个管理员为普通用户。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。