📡 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
}
}
]
}
关键特性说明
🎯 核心功能:
- 自动重连: 网络断开时自动尝试重连
- 流式显示: 实时显示AI生成的问题和回复
- 智能交互: 支持文本输入和选项选择两种交互方式
- 进度跟踪: 实时显示问卷进度和状态
- 错误处理: 完善的错误提示和异常处理
- 响应式设计: 适配不同屏幕尺寸
⚠️ 注意事项:
- 确保在
manifest.json 中配置网络权限
- 生产环境请使用 HTTPS/WSS 协议
- 建议添加网络状态检测和重连机制
- 注意处理小程序的生命周期事件
- 测试时注意WebSocket连接数限制
🔗 相关 HTTP API
POST
/api/survey/start
创建新的问卷会话,返回session_id用于WebSocket连接
GET
/api/survey/sessions/{session_id}
查询指定会话的状态信息
GET
/api/survey/profiles/session/{session_id}
获取会话关联的用户档案和分析报告
🛠️ 最佳实践
✅ 推荐做法
- 会话管理: 始终通过HTTP API创建会话,不要直接连接WebSocket
- 流式处理: 正确处理
stream_chunk 消息,累积内容直到 is_complete 为 true
- 状态同步: 监听
status_update 消息,及时更新UI状态
- 错误处理: 实现完善的错误处理机制,提供用户友好的错误提示
- 重连机制: 实现自动重连,处理网络中断情况
- 心跳检测: 定期发送ping消息保持连接活跃
⚠️ 注意事项
- 每个会话只能有一个活跃的 WebSocket 连接
- 会话有效期为24小时,过期后需要重新创建
- 所有流式内容(包括AI回复)现在都通过 stream_chunk 消息类型统一处理
- 在生产环境中使用 WSS (WebSocket Secure) 协议
- 合理设置消息缓冲区大小,避免内存溢出
🔧 测试工具
我们提供了完整的 WebSocket 测试工具,您可以:
- 测试完整的问卷流程
- 验证流式输出效果
- 查看所有消息类型的示例
- 测试错误处理机制
- 验证状态机转换