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 健康建议打下了基础。后面如果继续扩展用户画像、风险识别和长期健康管理,这个建档模块会成为很重要的入口。
