SDU软件学院创新实训(六)
这次开发主要围绕小程序中的“首次登录建档”功能展开。目标不是单纯做一个问卷,而是让用户第一次使用时,通过类似正常 AI 对话的方式回答几个和便秘情况相关的问题,再由后端调用 AI 对回答进行整理,最终形成结构化档案和 Markdown 个性化资料,供后续 AI 回复时参考。
整个功能涉及小程序端、后端、管理端和数据库,算是一次比较完整的前后端联动开发。
一、需求分析
本次功能最开始的目标比较明确:当用户第一次登录小程序时,需要自动完成一次基础情况摸排。用户回答后,系统要把这些回答整理成可以长期使用的用户档案。
具体拆分后,主要有几个关键点:
- 判断用户是否需要建档。
- 小程序端引导用户完成建档问答。
- 支持文字回答和语音回答。
- 后端调用 AI 对用户回答进行结构化整理。
- 将原始回答和整理后的档案保存到数据库。
- 生成一份 Markdown 用户档案,供后续 AI 回复读取。
- 管理端提供问卷题目管理和用户档案查看能力。
- 开发阶段需要方便查看云端数据库中写入的数据。
这里比较重要的一点是:用户回答往往不是标准化的,可能会比较口语化,比如“好几天才一次吧”“喝水不多”“有时候肚子胀”。如果直接存原文,后续 AI 很难稳定使用,所以必须引入 AI 整理这一层。
二、功能迭代
一开始设计时,我采用的是比较传统的“弹窗问卷”形式:用户第一次进入小程序后,弹出一个问卷弹框,用户逐题填写。
但实际思考和测试后发现,这种形式和小程序原本的 AI 对话体验不太一致。用户使用的是 AI 健康助手,如果突然出现一个独立问卷弹窗,会有一点割裂感。
后来将方案调整为:直接使用已有 AI 对话框完成建档问答。
也就是说,建档问题由 AI 助手以聊天消息的形式逐条提出,用户仍然使用底部正常输入框进行回答。这样体验更统一,也更符合“AI 助手在和用户聊天”的感觉。
小程序端的核心逻辑大致是:
async checkOnboarding() {
try {
const status = await onboardingApi.status()
if (!status.needsOnboarding) {
return
}
const questions = await onboardingApi.questions()
if (!questions.length) {
return
}
this.startOnboardingConversation(questions, false)
} catch (error) {
console.error('check onboarding failed', error)
}
}
当后端返回 needsOnboarding=true 时,小程序端就开始建档对话。
startOnboardingConversation(questions: OnboardingQuestion[], forceNew = false) {
const firstQuestion = questions[0]
const content = `为了让后续建议更贴合您的情况,我先问几个便秘相关的小问题。\n\n${firstQuestion.questionText}`
this.setData({
showOnboarding: true,
showWelcome: false,
onboardingQuestions: questions,
onboardingStep: 0,
onboardingCurrentQuestion: firstQuestion,
onboardingAnswers: [],
onboardingForceNew: forceNew,
messages: [...this.data.messages, this.createLocalMessage('assistant', content)]
})
this.updateViewStates()
this.scrollToBottom()
}
这个设计的好处是,不需要为建档单独设计一套复杂页面,而是复用已有聊天界面,把建档作为一种特殊对话状态来处理。
三、小程序端实现
小程序端主要做了三件事:
- 登录后检查是否需要建档。
- 使用聊天消息逐题提问。
- 用户回答后收集答案并提交。
在发送消息时,如果当前处于建档状态,就不走普通 AI 问答流程,而是进入建档回答处理逻辑:
async sendMessage() {
const content = this.data.inputText.trim()
if (!content || this.data.isLoading) return
if (this.data.showOnboarding) {
await this.handleOnboardingReply(content)
return
}
// 普通 AI 对话逻辑
}
建档回答处理逻辑会保存当前问题的回答,然后判断是否还有下一题:
async handleOnboardingReply(content: string) {
const question = this.data.onboardingCurrentQuestion
if (!question || this.data.onboardingSubmitting) return
const nextAnswers = this.data.onboardingAnswers
.filter((item) => item.questionId !== question.id)
.concat([{
questionId: question.id,
questionText: question.questionText,
answerText: content
}])
const nextStep = this.data.onboardingStep + 1
if (nextStep < this.data.onboardingQuestions.length) {
const nextQuestion = this.data.onboardingQuestions[nextStep]
this.setData({
inputText: '',
onboardingAnswers: nextAnswers,
onboardingStep: nextStep,
onboardingCurrentQuestion: nextQuestion,
messages: [
...this.data.messages,
this.createLocalMessage('user', content),
this.createLocalMessage('assistant', nextQuestion.questionText)
]
})
return
}
await this.submitOnboardingAnswers(nextAnswers)
}
为了方便开发测试,我还在小程序端加了一个“测试新建档案”的入口。因为如果每次测试都要重新注册账号,效率会很低。这个按钮可以强制开始一轮新的建档流程,方便反复测试提交、数据库写入和管理端展示。
四、后端接口设计
后端新增了建档相关接口,主要包括:
GET /onboarding/status
GET /onboarding/questions
POST /onboarding/submit
其中 /onboarding/status 用来判断当前用户是否已经完成建档。登录接口中也会返回 needsOnboarding,小程序可以据此决定是否触发建档流程。
登录响应中增加了类似这样的逻辑:
private Map<String, Object> buildLoginResponse(AppUser user) {
Map<String, Object> data = new HashMap<>();
data.put("token", jwtUtils.generateUserToken(String.valueOf(user.getId()), user.getUsername()));
data.put("profile", buildProfile(user));
data.put("needsOnboarding", !onboardingService.hasCompletedProfile(String.valueOf(user.getId())));
return data;
}
判断是否完成建档的逻辑放在 OnboardingService 中:
public boolean hasCompletedProfile(String userId) {
if (!StringUtils.hasText(userId)) {
return false;
}
Long count = userOnboardingProfileMapper.selectCount(
new LambdaQueryWrapper<UserOnboardingProfile>()
.eq(UserOnboardingProfile::getUserId, userId)
.eq(UserOnboardingProfile::getStatus, 1)
);
return count != null && count > 0;
}
这样“第一次登录”的判断不是简单依赖注册时间,而是依赖用户是否已经有完成状态的建档档案。这个方式更灵活,比如后续如果需要重新建档,也比较容易扩展。
五、数据库设计
这次建档功能新增了三张表。
第一张是问卷题目表:
CREATE TABLE IF NOT EXISTS `onboarding_question` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '建档题目ID',
`question_text` VARCHAR(500) NOT NULL COMMENT '题目内容',
`question_type` VARCHAR(32) DEFAULT 'text' COMMENT '题目类型',
`required_flag` TINYINT DEFAULT 1 COMMENT '是否必填',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`status` TINYINT DEFAULT 1 COMMENT '状态 1-启用 0-停用',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首次建档问卷题目表';
第二张是用户结构化档案表:
CREATE TABLE IF NOT EXISTS `user_onboarding_profile` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '建档档案ID',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
`basic_summary` TEXT COMMENT '基础情况摘要',
`bowel_habit_summary` TEXT COMMENT '排便习惯摘要',
`diet_water_summary` TEXT COMMENT '饮食饮水摘要',
`medication_summary` TEXT COMMENT '用药情况摘要',
`risk_factors` TEXT COMMENT '风险关注点',
`personalization_guide` TEXT COMMENT '个性化回答指导',
`md_content` LONGTEXT COMMENT 'Markdown档案内容',
`status` TINYINT DEFAULT 1 COMMENT '状态 1-已完成 0-未完成',
`completed_time` DATETIME COMMENT '完成时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户首次建档档案表';
第三张是原始问答明细表:
CREATE TABLE IF NOT EXISTS `user_onboarding_answer` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '建档回答ID',
`profile_id` BIGINT NOT NULL COMMENT '建档档案ID',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
`question_id` BIGINT COMMENT '题目ID',
`question_text` VARCHAR(500) COMMENT '题目内容',
`answer_text` TEXT COMMENT '文字回答',
`audio_text` TEXT COMMENT '语音转写文本',
`audio_id` VARCHAR(64) COMMENT '音频ID'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户首次建档问答明细表';
这里我没有只保存 AI 整理后的结果,而是同时保存了原始问答。这样做有两个原因:
第一,AI 整理结果可能会有偏差,保留原始回答便于之后人工检查。
第二,后续如果优化了 AI 提示词,可以基于原始回答重新生成结构化档案。
六、AI整理与工具调用
本次功能的核心不是简单保存问卷,而是要让 AI 把用户回答整理成结构化内容。
后端在收到用户回答后,会构造 prompt,让 AI 输出严格 JSON:
private String buildFormatPrompt(List<OnboardingAnswerSubmitDTO> answers) {
StringBuilder builder = new StringBuilder();
builder.append("请把老年人便秘小程序首次建档问卷整理为严格JSON,不要输出解释文字。")
.append("你必须输出一个工具调用对象,格式为:")
.append("{\"action\":\"SAVE_ONBOARDING_PROFILE\",\"payload\":{\"basicSummary\":\"\",\"bowelHabitSummary\":\"\",\"dietWaterSummary\":\"\",\"medicationSummary\":\"\",\"riskFactors\":\"\",\"personalizationGuide\":\"\"}}。\n")
.append("要求:中文、简洁、适合后续AI个性化回答使用;若信息缺失请写“未提及”。\n\n问答如下:\n");
for (OnboardingAnswerSubmitDTO answer : answers) {
builder.append("问题:").append(blankToDefault(answer.getQuestionText(), "未命名问题")).append("\n")
.append("回答:").append(resolveAnswerText(answer)).append("\n\n");
}
return builder.toString();
}
AI 返回后,后端会解析 JSON,然后执行内部工具动作:
public Object executeAgentTool(String action, Map<String, Object> payload) {
if (SAVE_ONBOARDING_PROFILE.equals(action)) {
return saveProfileByTool(payload);
}
return Map.of("action", action, "message", "Unsupported action");
}
这里的 SAVE_ONBOARDING_PROFILE 相当于一个内部工具调用动作。AI 负责把用户非结构化回答整理成结构化字段,真正的数据库写入仍然由后端 Service 控制。
这样设计比较安全,也比较符合后端分层:AI 不直接操作数据库,而是输出结构化意图,由后端执行。
保存档案时,会同时写入结构化字段、原始问答和 Markdown 内容:
private UserOnboardingProfile saveProfileByTool(Map<String, Object> payload) {
String userId = String.valueOf(payload.getOrDefault("userId", ""));
UserOnboardingProfile profile = new UserOnboardingProfile();
profile.setUserId(userId);
profile.setBasicSummary(valueOf(payload, "basicSummary"));
profile.setBowelHabitSummary(valueOf(payload, "bowelHabitSummary"));
profile.setDietWaterSummary(valueOf(payload, "dietWaterSummary"));
profile.setMedicationSummary(valueOf(payload, "medicationSummary"));
profile.setRiskFactors(valueOf(payload, "riskFactors"));
profile.setPersonalizationGuide(valueOf(payload, "personalizationGuide"));
profile.setStatus(1);
profile.setCompletedTime(LocalDateTime.now());
profile.setMdContent(buildMarkdown(profile));
userOnboardingProfileMapper.insert(profile);
saveAnswers(userId, profile.getId(), payload.get("answers"));
writeMarkdownFile(userId, profile.getMdContent());
return userOnboardingProfileMapper.selectById(profile.getId());
}
如果 AI 调用失败,系统也不会直接中断,而是用本地规则兜底生成基础档案。这一点在实际开发中很重要,因为 AI 服务不是百分百稳定的,核心业务流程要尽量保证能完成。
七、Markdown用户档案
为了让后续 AI 回复更个性化,后端会生成一份 Markdown 档案。
大致格式如下:
private String buildMarkdown(UserOnboardingProfile profile) {
return "# 用户便秘健康建档\n\n"
+ "## 基础摘要\n" + blankToDefault(profile.getBasicSummary(), "未提及") + "\n\n"
+ "## 排便习惯\n" + blankToDefault(profile.getBowelHabitSummary(), "未提及") + "\n\n"
+ "## 饮食饮水\n" + blankToDefault(profile.getDietWaterSummary(), "未提及") + "\n\n"
+ "## 用药情况\n" + blankToDefault(profile.getMedicationSummary(), "未提及") + "\n\n"
+ "## 风险关注\n" + blankToDefault(profile.getRiskFactors(), "未提及") + "\n\n"
+ "## 后续AI回答指导\n" + blankToDefault(profile.getPersonalizationGuide(), "未提及") + "\n";
}
Markdown 内容一方面存入数据库的 md_content 字段,另一方面写入后端本地文件:
private void writeMarkdownFile(String userId, String content) {
try {
Path path = resolveProfilePath(userId);
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
} catch (Exception e) {
log.warn("写入用户建档Markdown失败: {}", e.getMessage());
}
}
后续 AI 对话时,会根据用户 ID 读取这份档案,并追加到 System Prompt 中。这样 AI 在回答时就能知道用户的排便习惯、饮食饮水情况、用药情况和风险点,而不是每次都从零开始。
八、管理员端功能
管理端主要新增了两个页面。
第一个是问卷题目管理页面,用来维护建档问题,包括:
- 新增题目
- 编辑题目
- 删除题目
- 启用/停用题目
- 设置排序
- 设置是否必填
第二个是用户档案查看页面,用来查看用户提交后的建档信息,包括:
- 用户基本信息
- 建档完成时间
- 结构化字段
- 原始问答
- Markdown 内容预览
由于数据库已经部署到云端,开发时不能直接查看数据库内容,所以管理端还增加了一个调试入口,用来查看建档相关三张表的数据。这只是开发阶段的辅助功能,后续正式上线时可以移除或隐藏。
九、总结收获
目前建档功能已经完成了主要闭环,但还有一些可以继续优化的地方:
第一,管理端可以增加“原始回答”和“AI 结构化结果”的对照展示。这样可以更直观看出 AI 是否理解正确。
第二,支持管理员手动修订档案。因为 AI 整理结果不一定百分百准确,如果管理员可以修改结构化字段和 Markdown,会更适合后续运营。
第三,支持重新调用 AI 整理档案。后续如果优化了 prompt,可以基于已有原始问答重新生成档案,而不用用户重新填写。
这次首次登录建档功能,不只是增加了几个问卷问题,而是完成了一条比较完整的业务链路:
用户首次登录 → 小程序对话式提问 → 用户文字或语音回答 → 后端调用 AI 整理 → 工具动作保存数据库 → 生成 Markdown 用户档案 → 后续 AI 回复读取个性化信息 → 管理端查看和维护。
通过这次开发,我对前后端联调、AI 结构化处理、数据库设计、管理端调试工具以及用户体验迭代都有了更完整的理解。
这个功能后续还可以继续完善,但目前已经为个性化 AI 健康建议打下了基础。后面如果继续扩展用户画像、风险识别和长期健康管理,这个建档模块会成为很重要的入口。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)