📡 WebSocket API 文档 - 智能问卷系统

📋 概述: NianNian Survey 提供基于 WebSocket 的实时问卷交互系统,采用复杂状态机机制和流式输出技术,支持智能对话、个性化分析和AI伴侣生成。

🔗 WebSocket 端点

WebSocket /survey/ws/{session_id}
路径参数:
session_id (string, 必需): 会话唯一标识符,通过HTTP API创建会话获得
⚠️ 重要变更: 新版本使用路径参数传递session_id,不再支持查询参数方式。请确保先通过 POST /api/survey/start 创建会话获得有效的session_id。

🔄 连接建立流程

创建会话

通过 HTTP API POST /api/survey/start 创建新的问卷会话,获得session_id。注意:HTTP接口仅返回session_id,不包含问题内容。

建立WebSocket连接

使用获得的session_id连接到 /survey/ws/{session_id}

连接验证

服务器验证session_id有效性,无效会话将被拒绝连接(错误码4004)

连接确认

服务器发送 connected 消息确认连接,并自动推送第一个问题。所有问题都通过WebSocket实时推送,确保数据流的一致性。

📨 消息格式规范

客户端发送消息

1. 用户回答消息

{ "type": "answer", "data": { "answer_text": "用户的回答内容" } }

说明: 发送用户对当前问题的回答,系统将根据回答内容智能判断是否需要追问。

2. 获取当前问题

{ "type": "get_current_question" }

说明: 请求获取当前问题信息,用于重连后恢复状态。

3. 心跳检测

{ "type": "ping" }

说明: 发送心跳消息保持连接活跃。

4. 断开连接

{ "type": "disconnect" }

说明: 主动断开WebSocket连接。

服务器发送消息

1. 连接确认 (connected)

{ "type": "connected", "session_id": "6ec0a0b2-6b4b-4c39-8746-544f511c166d", "message": "Connected to survey session" }

说明: 连接建立后的确认消息,包含会话ID和确认信息。注意:session_id 直接在消息根级别,不在 data 字段中。

2. 问题推送 (question)

{ "type": "question", "data": { "question_id": "q1", "text": "请简单介绍一下您自己", "options": [ {"key": "A", "value": "我是一个外向的人"}, {"key": "B", "value": "我比较内向"} ], "question_index": 0, "total_questions": 8, "sub_state": "awaiting_initial_answer" } }

说明: 推送问题内容,可能包含选择选项。sub_state指示当前问题的子状态。

3. 流式内容块 (stream_chunk)

{ "type": "stream_chunk", "data": { "type": "ai_response", "text_chunk": "我理解您的感受,这确实是一个很有趣的话题。", "is_complete": true } }

说明: 统一的流式输出格式,支持AI回复、报告生成和AI伴侣设置等所有流式内容。

📝 重要变更: 从v2.0开始,所有流式内容(包括AI回复)都统一使用 stream_chunk 消息类型,不再单独使用 ai_response 消息类型。

5. 状态更新 (status_update)

{ "type": "status_update", "data": { "status": "generating_report", "message": "正在生成您的人格分析报告...", "progress": 75 } }

说明: 系统状态更新,包含进度信息。

6. 错误消息 (error)

{ "type": "error", "data": { "message": "错误描述", "code": "ERROR_CODE", "details": "详细错误信息" } }

说明: 处理过程中发生错误时的错误信息。

🎯 智能状态机机制

问题子状态说明

子状态 描述 用户操作 系统行为
awaiting_initial_answer 等待用户初始回答 发送answer消息 分析回答质量,决定后续流程
awaiting_option_selection 等待用户选择选项 选择提供的选项 基于选项进行追问
awaiting_follow_up 等待用户追问回答 回答追问问题 完成当前问题,进入下一题

状态转换流程

用户回答 → 回答质量分析 ├─ 回答模糊 → 提供选项 → awaiting_option_selection └─ 回答明确 → 直接追问 → awaiting_follow_up ↓ 完成当前问题 → 下一题 → awaiting_initial_answer

🌊 流式输出机制

流式内容类型

内容类型 type 值 用途 触发时机
AI回复 ai_response 共情回应、追问 用户回答后
人格报告 report_chunk 个性分析报告 问卷完成后
AI伴侣设置 prompt_chunk 个性化AI配置 报告生成后

统一流式输出处理

// 所有流式内容都通过 stream_chunk 消息统一处理 ws.onmessage = function(event) { const message = JSON.parse(event.data); if (message.type === 'stream_chunk') { const { type, text_chunk, is_complete } = message.data; // 累积流式内容 appendStreamContent(type, text_chunk); if (is_complete) { // 流式输出完成 finalizeStreamContent(type); } } };
✨ 优势: 统一的流式处理机制简化了客户端代码,所有类型的流式内容都使用相同的处理逻辑。

🔧 错误处理

常见错误代码:
INVALID_SESSION: 会话无效或已过期
INVALID_MESSAGE_FORMAT: 消息格式错误
MISSING_ANSWER_TEXT: 缺少回答内容
ANSWER_PROCESSING_ERROR: 回答处理失败
LLM_SERVICE_ERROR: AI服务异常
DATABASE_ERROR: 数据库操作失败
INTERNAL_SERVER_ERROR: 服务器内部错误

💻 客户端集成示例

JavaScript (Web)

// 1. 创建会话 async function createSession() { const response = await fetch('/api/survey/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 'user123' }) }); const data = await response.json(); return data.session_id; } // 2. 建立WebSocket连接 async function connectWebSocket() { const sessionId = await createSession(); const ws = new WebSocket(`ws://localhost:8000/survey/ws/${sessionId}`); ws.onopen = function(event) { console.log('WebSocket连接已建立'); }; ws.onmessage = function(event) { const message = JSON.parse(event.data); handleMessage(message); }; return ws; } // 3. 消息处理 function handleMessage(message) { switch(message.type) { case 'connected': console.log('连接确认:', message.session_id, message.message); break; case 'question': displayQuestion(message.data); break; case 'stream_chunk': handleStreamChunk(message.data); break; case 'status_update': updateStatus(message.data); break; case 'error': handleError(message.data); break; } } // 4. 发送回答 function sendAnswer(ws, answerText) { const message = { type: 'answer', data: { answer_text: answerText } }; ws.send(JSON.stringify(message)); } // 5. 流式内容处理 let streamBuffers = {}; function handleStreamChunk(data) { const { type, text_chunk, is_complete } = data; if (!streamBuffers[type]) { streamBuffers[type] = ''; } streamBuffers[type] += text_chunk; // 实时显示累积内容 updateStreamDisplay(type, streamBuffers[type]); if (is_complete) { // 流式输出完成 finalizeStream(type, streamBuffers[type]); delete streamBuffers[type]; } }

🏗️ 系统架构概览

三层架构设计

架构层 主要组件 职责 关键文件
接口层 FastAPI Routes HTTP/WebSocket接口,请求路由,参数验证 app/routes/survey.py
服务层 SurveyService, ConnectionManager 业务逻辑,状态管理,AI交互,连接管理 app/services/survey_service.py
数据层 Pydantic Models 数据模型,验证,序列化 app/models/survey.py

数据流向

客户端 → HTTP API → SurveyService → SessionState (内存) ↓ WebSocket连接 → ConnectionManager → SurveyService ↓ AI处理 → ModelService → 流式响应 → 客户端 ↓ 状态更新 → SessionState → MongoDB (持久化)

📱 UniApp 完整集成示例

页面结构 (survey.vue)

<template> <view class="survey-container"> <!-- 连接状态 --> <view class="connection-status"> <view class="status-indicator" :class="connectionStatus"></view> <text>{{ statusText }}</text> </view> <!-- 问题显示区域 --> <view class="question-section" v-if="currentQuestion"> <view class="progress-bar"> <view class="progress-fill" :style="{width: progressPercentage + '%'}"></view> </view> <text class="progress-text">{{ currentQuestion.question_index + 1 }}/{{ currentQuestion.total_questions }}</text> <view class="question-content"> <text class="question-text">{{ currentQuestion.text }}</text> <!-- 选项显示 --> <view class="options-container" v-if="currentQuestion.options"> <view class="option-item" v-for="option in currentQuestion.options" :key="option.key" @click="selectOption(option)" > <text>{{ option.key }}. {{ option.value }}</text> </view> </view> </view> </view> <!-- 流式内容显示 --> <view class="stream-content" v-if="streamContent"> <text class="stream-text">{{ streamContent }}</text> </view> <!-- 答案输入 --> <view class="answer-section" v-if="canAnswer"> <textarea class="answer-input" v-model="answerText" placeholder="请输入您的回答..." :disabled="isProcessing" ></textarea> <button class="send-button" @click="sendAnswer" :disabled="!answerText.trim() || isProcessing" > {{ isProcessing ? '处理中...' : '发送' }} </button> </view> <!-- 报告显示 --> <view class="report-section" v-if="surveyReport"> <view class="report-title">📋 您的人格分析报告</view> <view class="report-content"> <text>{{ surveyReport }}</text> </view> <view class="companion-section" v-if="companionPrompt"> <view class="companion-title">🤖 专属AI伴侣设定</view> <text class="companion-text">{{ companionPrompt }}</text> </view> </view> </view> </template>

JavaScript 逻辑

<script> export default { data() { return { // 连接相关 socketTask: null, sessionId: '', connectionStatus: 'disconnected', // disconnected, connecting, connected statusText: '未连接', // 问卷相关 currentQuestion: null, answerText: '', isProcessing: false, canAnswer: false, // 流式内容 streamContent: '', streamBuffers: {}, // 报告相关 surveyReport: '', companionPrompt: '', // 配置 serverUrl: 'wss://niannianservice.sherwenfu.com', // 生产环境 // serverUrl: 'ws://localhost:8000', // 开发环境 userId: 'uniapp_user_' + Date.now() } }, computed: { progressPercentage() { if (!this.currentQuestion) return 0; return ((this.currentQuestion.question_index + 1) / this.currentQuestion.total_questions) * 100; } }, async onLoad() { await this.initSurvey(); }, onUnload() { this.cleanup(); }, methods: { // 初始化问卷 async initSurvey() { try { this.updateConnectionStatus('connecting', '正在连接...'); await this.createSessionAndConnect(); } catch (error) { console.error('初始化失败:', error); this.updateConnectionStatus('disconnected', '连接失败'); uni.showToast({ title: '连接失败,请重试', icon: 'error' }); } }, // 创建会话并建立连接 async createSessionAndConnect() { try { // 1. 创建会话 const httpUrl = this.serverUrl.replace('ws://', 'http://').replace('wss://', 'https://'); const sessionResponse = await uni.request({ url: `${httpUrl}/api/survey/start`, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { user_id: this.userId } }); if (sessionResponse.statusCode !== 200) { throw new Error('创建会话失败'); } this.sessionId = sessionResponse.data.session_id; console.log('会话创建成功:', this.sessionId); // 2. 建立WebSocket连接 const wsUrl = `${this.serverUrl}/api/survey/ws/${this.sessionId}`; this.socketTask = uni.connectSocket({ url: wsUrl, success: () => { console.log('WebSocket连接请求发送成功'); }, fail: (error) => { console.error('WebSocket连接失败:', error); throw new Error('WebSocket连接失败'); } }); // 3. 设置WebSocket事件监听 this.setupWebSocketListeners(); } catch (error) { console.error('创建会话或连接失败:', error); throw error; } }, // 设置WebSocket事件监听 setupWebSocketListeners() { // 连接打开 this.socketTask.onOpen(() => { console.log('WebSocket连接已建立'); this.updateConnectionStatus('connected', '已连接'); }); // 接收消息 this.socketTask.onMessage((res) => { try { const message = JSON.parse(res.data); this.handleMessage(message); } catch (error) { console.error('消息解析失败:', error); } }); // 连接关闭 this.socketTask.onClose((res) => { console.log('WebSocket连接已关闭:', res); this.updateConnectionStatus('disconnected', '连接已断开'); }); // 连接错误 this.socketTask.onError((error) => { console.error('WebSocket错误:', error); this.updateConnectionStatus('disconnected', '连接错误'); uni.showToast({ title: '连接出现错误', icon: 'error' }); }); }, // 处理接收到的消息 handleMessage(message) { console.log('收到消息:', message.type, message); switch(message.type) { case 'connected': this.handleConnected(message); break; case 'question': this.handleQuestion(message); break; case 'response': this.handleResponse(message); break; case 'status_update': this.handleStatusUpdate(message); break; case 'survey_completed': this.handleSurveyCompleted(message); break; case 'error': this.handleError(message); break; default: console.log('未知消息类型:', message.type); } }, // 处理连接确认 handleConnected(message) { console.log('连接确认:', message); uni.showToast({ title: '连接成功', icon: 'success' }); }, // 处理问题消息 handleQuestion(message) { const { data, metadata } = message; if (metadata && metadata.status === 'start') { // 问题开始,清空流式内容 this.streamContent = ''; this.canAnswer = false; this.isProcessing = true; } else if (metadata && metadata.is_complete === false) { // 流式内容 this.streamContent += data; } else if (metadata && metadata.is_complete === true) { // 流式完成 this.streamContent += data; this.finalizeQuestion(metadata); } else if (data && typeof data === 'object') { // 完整问题对象 this.currentQuestion = data; this.canAnswer = true; this.isProcessing = false; this.answerText = ''; } }, // 处理AI回应 handleResponse(message) { const { data, metadata } = message; if (metadata && metadata.status === 'start') { this.streamContent = ''; } else if (metadata && metadata.is_complete === false) { this.streamContent += data; } else if (metadata && metadata.is_complete === true) { this.streamContent += data; // AI回应完成,等待下一个问题 } }, // 完成问题处理 finalizeQuestion(metadata) { this.canAnswer = true; this.isProcessing = false; this.answerText = ''; // 如果有问题信息,更新当前问题 if (metadata.question_id) { this.currentQuestion = { ...this.currentQuestion, question_id: metadata.question_id, question_index: metadata.question_index, sub_state: metadata.sub_state }; } }, // 处理状态更新 handleStatusUpdate(message) { const { data, metadata } = message || {}; const md = metadata || {}; let statusText = ''; let progress = null; // 握手后:是否已完成 if (typeof md.has_completed === 'boolean') { statusText = md.has_completed ? '已完成过问卷' : '尚未完成问卷,准备开始'; if (md.progress_percentage !== undefined && md.progress_percentage !== null) { progress = md.progress_percentage; } } // 即时完成标志(不等待报告) const isCompletedNow = md.complete === true || md.status === 'completed'; if (isCompletedNow) { statusText = '问卷已完成,报告生成中...'; progress = 100; // UI切换至完成态 this.canAnswer = false; this.currentQuestion = null; } // 兼容旧格式 if (!statusText && data) { if (data.message) statusText = data.message; if (data.progress !== undefined && data.progress !== null) { progress = data.progress; } } if (statusText) { this.statusText = statusText; } if (progress !== null) { this.progressText = `${progress}%`; } console.log('状态更新:', statusText, progress); }, // 处理问卷完成 handleSurveyCompleted(message) { const { report, prompt } = message.data; this.surveyReport = report; this.companionPrompt = prompt; this.canAnswer = false; this.currentQuestion = null; uni.showToast({ title: '问卷完成!', icon: 'success' }); // 滚动到报告区域 this.$nextTick(() => { uni.pageScrollTo({ selector: '.report-section', duration: 500 }); }); }, // 处理错误 handleError(message) { const { message: errorMessage, code } = message.data; console.error('收到错误:', code, errorMessage); uni.showToast({ title: errorMessage || '发生错误', icon: 'error' }); }, // 发送答案 sendAnswer() { if (!this.answerText.trim() || this.isProcessing) { return; } const message = { type: 'answer', data: { answer_text: this.answerText.trim() } }; this.socketTask.send({ data: JSON.stringify(message), success: () => { console.log('答案发送成功'); this.isProcessing = true; this.canAnswer = false; }, fail: (error) => { console.error('答案发送失败:', error); uni.showToast({ title: '发送失败,请重试', icon: 'error' }); } }); }, // 选择选项 selectOption(option) { this.answerText = option.value; this.sendAnswer(); }, // 更新连接状态 updateConnectionStatus(status, text) { this.connectionStatus = status; this.statusText = text; }, // 清理资源 cleanup() { if (this.socketTask) { this.socketTask.close(); this.socketTask = null; } } } } </script>

样式定义 (CSS)

<style scoped> .survey-container { padding: 20rpx; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } /* 连接状态 */ .connection-status { display: flex; align-items: center; padding: 20rpx; background: white; border-radius: 16rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1); } .status-indicator { width: 24rpx; height: 24rpx; border-radius: 50%; margin-right: 16rpx; background: #e74c3c; } .status-indicator.connecting { background: #f39c12; animation: pulse 1s infinite; } .status-indicator.connected { background: #27ae60; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* 问题区域 */ .question-section { background: white; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1); } .progress-bar { height: 8rpx; background: #ecf0f1; border-radius: 4rpx; margin-bottom: 20rpx; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #3498db, #2ecc71); transition: width 0.3s ease; } .progress-text { font-size: 24rpx; color: #7f8c8d; margin-bottom: 20rpx; } .question-text { font-size: 32rpx; line-height: 1.6; color: #2c3e50; margin-bottom: 30rpx; } /* 选项 */ .options-container { margin-top: 30rpx; } .option-item { background: #f8f9fa; border: 2rpx solid #e9ecef; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; transition: all 0.3s; } .option-item:active { background: #e3f2fd; border-color: #3498db; transform: scale(0.98); } /* 流式内容 */ .stream-content { background: white; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1); } .stream-text { font-size: 30rpx; line-height: 1.6; color: #2c3e50; } /* 答案输入 */ .answer-section { background: white; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1); } .answer-input { width: 100%; min-height: 200rpx; border: 2rpx solid #e9ecef; border-radius: 12rpx; padding: 20rpx; font-size: 30rpx; line-height: 1.5; margin-bottom: 20rpx; box-sizing: border-box; } .answer-input:focus { border-color: #3498db; outline: none; } .send-button { width: 100%; height: 88rpx; background: linear-gradient(135deg, #3498db, #2980b9); color: white; border: none; border-radius: 12rpx; font-size: 32rpx; font-weight: 600; } .send-button:disabled { background: #bdc3c7; } /* 报告区域 */ .report-section { background: white; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1); } .report-title { font-size: 36rpx; font-weight: 600; color: #2c3e50; margin-bottom: 30rpx; text-align: center; } .report-content { font-size: 30rpx; line-height: 1.8; color: #34495e; margin-bottom: 40rpx; } .companion-section { border-top: 2rpx solid #ecf0f1; padding-top: 30rpx; } .companion-title { font-size: 32rpx; font-weight: 600; color: #8e44ad; margin-bottom: 20rpx; } .companion-text { font-size: 28rpx; line-height: 1.6; color: #7f8c8d; background: #f8f9fa; padding: 20rpx; border-radius: 8rpx; border-left: 6rpx solid #8e44ad; } </style>

页面配置 (pages.json)

{ "pages": [ { "path": "pages/survey/survey", "style": { "navigationBarTitleText": "性格测试", "navigationBarBackgroundColor": "#3498db", "navigationBarTextStyle": "white", "backgroundColor": "#f8f9fa", "enablePullDownRefresh": false, "disableScroll": false } } ] }

关键特性说明

🎯 核心功能:
⚠️ 注意事项:

🔗 相关 HTTP API

POST /api/survey/start

创建新的问卷会话,返回session_id用于WebSocket连接

GET /api/survey/sessions/{session_id}

查询指定会话的状态信息

GET /api/survey/profiles/session/{session_id}

获取会话关联的用户档案和分析报告

🛠️ 最佳实践

✅ 推荐做法

⚠️ 注意事项

🔧 测试工具

我们提供了完整的 WebSocket 测试工具,您可以: