survey/survey_server.py
2025-11-15 23:51:08 +08:00

745 lines
27 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sqlite3
import json
import uuid
from datetime import datetime, timezone, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import cgi
def get_east8_time_string():
"""获取东八区时间字符串格式,用于数据库存储"""
east8_tz = timezone(timedelta(hours=8))
return datetime.now(east8_tz).strftime('%Y-%m-%d %H:%M:%S')
class SurveyAPI:
def __init__(self, db_path='data/survey.db'):
self.db_path = db_path
def get_connection(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def create_student(self, name, school, grade, phone, selected_tag=''):
"""创建学员记录"""
student_id = str(uuid.uuid4())
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO students (id, name, school, grade, phone, selected_tag)
VALUES (?, ?, ?, ?, ?, ?)
''', (student_id, name, school, grade, phone, selected_tag))
conn.commit()
conn.close()
return student_id
def create_quiz_session(self, student_id, questions_config):
"""创建答题会话"""
session_id = str(uuid.uuid4())
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO quiz_sessions (id, student_id, questions_config, status)
VALUES (?, ?, ?, 'created')
''', (session_id, student_id, json.dumps(questions_config)))
conn.commit()
conn.close()
return session_id
def save_answers(self, session_id, answers):
"""保存答题结果"""
conn = self.get_connection()
cursor = conn.cursor()
total_score = 0
for answer in answers:
answer_id = str(uuid.uuid4())
user_answer = answer.get('user_answer', '')
correct_answer = answer.get('correct_answer', '')
is_correct = user_answer == correct_answer
score = answer['score'] if is_correct else 0
total_score += score
cursor.execute('''
INSERT INTO quiz_answers
(id, session_id, question_id, question_text, question_type,
user_answer, correct_answer, is_correct, score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (answer_id, session_id, answer.get('question_id', ''), answer.get('question_text', ''),
answer.get('question_type', ''), user_answer, correct_answer,
is_correct, score))
# 更新会话状态
cursor.execute('''
UPDATE quiz_sessions
SET status = 'completed', total_score = ?, completed_at = ?
WHERE id = ?
''', (total_score, get_east8_time_string(), session_id))
conn.commit()
conn.close()
return total_score
class SurveyHandler(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs):
self.api = SurveyAPI()
super().__init__(*args, **kwargs)
def do_OPTIONS(self):
"""处理预检请求"""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def send_cors_headers(self):
"""发送CORS头"""
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def do_POST(self):
"""处理POST请求"""
parsed_path = urlparse(self.path)
path = parsed_path.path
try:
if path == '/api/create-session':
self.handle_create_session()
elif path == '/api/save-answers':
self.handle_save_answers()
else:
self.send_error(404, "API endpoint not found")
except Exception as e:
self.send_error(500, str(e))
def handle_create_session(self):
"""创建答题会话"""
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
name = data.get('name', '').strip()
school = data.get('school', '').strip()
grade = data.get('grade', '').strip()
phone = data.get('phone', '').strip()
selected_subject = data.get('selectedSubject', '').strip()
selected_semester = data.get('selectedSemester', '').strip()
selected_exam_type = data.get('selectedExamType', '').strip()
# 构建描述性标签
selected_tag = ""
if selected_subject and grade and selected_semester and selected_exam_type:
selected_tag = f"{selected_subject}-{grade}{selected_semester}-{selected_exam_type}考试"
elif selected_subject and grade and selected_semester:
selected_tag = f"{selected_subject}-{grade}{selected_semester}"
elif selected_subject and grade:
selected_tag = f"{selected_subject}-{grade}"
elif selected_subject:
selected_tag = selected_subject
questions_config = data.get('questionsConfig', {})
if not name or not school or not grade or not phone:
self.send_error(400, "姓名、学校、年级和手机号不能为空")
return
# 创建学员记录
student_id = self.api.create_student(name, school, grade, phone, selected_tag)
# 创建答题会话
session_id = self.api.create_quiz_session(student_id, questions_config)
response = {
'success': True,
'sessionId': session_id,
'studentId': student_id
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps(response, ensure_ascii=False).encode('utf-8'))
def handle_save_answers(self):
"""保存答题结果"""
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
session_id = data.get('sessionId')
answers = data.get('answers', [])
if not session_id or not answers:
self.send_error(400, "会话ID和答题结果不能为空")
return
print(f"Debug: session_id = {session_id}")
print(f"Debug: answers count = {len(answers)}")
print(f"Debug: first answer keys = {list(answers[0].keys()) if answers else 'None'}")
total_score = self.api.save_answers(session_id, answers)
response = {
'success': True,
'totalScore': total_score
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps(response, ensure_ascii=False).encode('utf-8'))
def do_GET(self):
"""处理GET请求"""
parsed_path = urlparse(self.path)
path = parsed_path.path
try:
if path.startswith('/quiz/'):
self.handle_quiz_page(path)
else:
self.send_error(404, "Page not found")
except Exception as e:
self.send_error(500, str(e))
def handle_quiz_page(self, path):
"""处理答题页面"""
session_id = path.split('/')[-1]
if not session_id:
self.send_error(400, "Session ID is required")
return
# 验证会话是否存在
conn = self.api.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT s.*, qs.questions_config, qs.status
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_data = cursor.fetchone()
conn.close()
if not session_data:
self.send_error(404, "Session not found")
return
# 生成答题页面HTML
html_content = self.generate_quiz_page(session_data)
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_cors_headers()
self.end_headers()
self.wfile.write(html_content.encode('utf-8'))
def generate_quiz_page(self, session_data):
"""生成答题页面HTML"""
questions_config = json.loads(session_data['questions_config'])
student_name = session_data['name']
school = session_data['school']
grade = session_data['grade']
phone = session_data['phone']
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{student_name} - 学科能力测评</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Microsoft YaHei', sans-serif;
}}
body {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}}
.student-info {{
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e9ecef;
}}
.info-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}}
.info-item {{
background: white;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #4facfe;
}}
.info-label {{
font-weight: 600;
color: #2d5a3d;
margin-bottom: 5px;
}}
.info-value {{
color: #333;
font-size: 16px;
}}
.progress-bar {{
background: #e9ecef;
height: 8px;
border-radius: 4px;
margin: 20px 30px;
overflow: hidden;
}}
.progress-fill {{
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}}
.questions-container {{
padding: 30px;
}}
.question-card {{
background: white;
border: 2px solid #e9ecef;
border-radius: 12px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}}
.question-header {{
display: flex;
align-items: center;
margin-bottom: 20px;
}}
.question-number {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-right: 15px;
}}
.question-type {{
background: #e3f2fd;
color: #1976d2;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
margin-left: auto;
}}
.question-text {{
font-size: 18px;
color: #333;
line-height: 1.6;
margin-bottom: 20px;
font-weight: 500;
}}
.options-list {{
list-style: none;
}}
.option-item {{
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
}}
.option-item:hover {{
background: #e3f2fd;
border-color: #4facfe;
transform: translateY(-1px);
}}
.option-item.selected {{
background: #e3f2fd;
border-color: #4facfe;
}}
.option-item input[type="radio"] {{
margin-right: 12px;
transform: scale(1.2);
}}
.option-text {{
flex: 1;
font-size: 16px;
color: #333;
}}
.submit-btn {{
background: linear-gradient(135deg, #4CAF50, #66BB6A);
color: white;
border: none;
padding: 15px 30px;
border-radius: 8px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: block;
margin: 30px auto;
}}
.submit-btn:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(76, 175, 80, 0.4);
}}
.submit-btn:disabled {{
background: #ccc;
cursor: not-allowed;
transform: none;
}}
.loading {{
text-align: center;
padding: 50px;
color: #666;
}}
.error {{
background: #ffebee;
color: #c62828;
padding: 20px;
border-radius: 8px;
margin: 20px;
border-left: 4px solid #f44336;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎯 学科能力测评</h1>
<p>请认真回答以下问题</p>
</div>
<div class="student-info">
<div class="info-grid">
<div class="info-item">
<div class="info-label">姓名</div>
<div class="info-value">{student_name}</div>
</div>
<div class="info-item">
<div class="info-label">学校</div>
<div class="info-value">{school}</div>
</div>
<div class="info-item">
<div class="info-label">年级</div>
<div class="info-value">{grade}</div>
</div>
<div class="info-item">
<div class="info-label">手机号</div>
<div class="info-value">{phone}</div>
</div>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="questions-container" id="questionsContainer">
<div class="loading">正在加载题目...</div>
</div>
</div>
<script>
class QuizSystem {{
constructor() {{
this.sessionId = '{session_id}';
this.questions = [];
this.answers = {{}};
this.init();
}}
async init() {{
try {{
await this.loadQuestions();
this.renderQuestions();
}} catch (error) {{
this.showError('系统初始化失败: ' + error.message);
}}
}}
async loadQuestions() {{
const response = await fetch('questions.json');
if (!response.ok) {{
throw new Error('题库加载失败');
}}
const allQuestions = await response.json();
// 根据配置选择题目
const questionsConfig = {json.dumps(questions_config)};
this.questions = this.selectQuestions(allQuestions, questionsConfig);
if (this.questions.length === 0) {{
throw new Error('没有可用的题目');
}}
}}
selectQuestions(allQuestions, config) {{
const selected = [];
Object.keys(config).forEach(type => {{
const count = config[type];
const availableQuestions = allQuestions[type] || [];
// 随机选择题目
const shuffled = [...availableQuestions].sort(() => 0.5 - Math.random());
const selectedForType = shuffled.slice(0, Math.min(count, availableQuestions.length));
selectedForType.forEach(question => {{
selected.push({{
...question,
questionType: type,
questionId: `${{type}}_${{question['序号']}}`
}});
}});
}});
return selected.sort(() => 0.5 - Math.random());
}}
renderQuestions() {{
const container = document.getElementById('questionsContainer');
container.innerHTML = '';
this.questions.forEach((question, index) => {{
const questionCard = document.createElement('div');
questionCard.className = 'question-card';
questionCard.innerHTML = `
<div class="question-header">
<div class="question-number">${{index + 1}}</div>
<div class="question-type ${{question.questionType}}">${{question.questionType}}</div>
</div>
<div class="question-text">${{question['题干']}}</div>
<ul class="options-list">
${{this.renderOptions(question, index)}}
</ul>
`;
container.appendChild(questionCard);
}});
this.bindOptionEvents();
}}
renderOptions(question, questionIndex) {{
const options = [];
const labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
for (let i = 0; i < 8; i++) {{
const optionKey = `选项 ${{labels[i]}}`.trim();
const optionText = question[optionKey];
if (optionText && optionText.trim()) {{
options.push(`
<li class="option-item" data-question="${{questionIndex}}" data-option="${{labels[i]}}">
<input type="radio" name="question_${{questionIndex}}" value="${{labels[i]}}" id="option_${{questionIndex}}_${{i}}">
<label for="option_${{questionIndex}}_${{i}}" class="option-text">${{labels[i]}}. ${{optionText}}</label>
</li>
`);
}}
}}
return options.join('');
}}
bindOptionEvents() {{
document.querySelectorAll('.option-item').forEach(item => {{
item.addEventListener('click', function() {{
const questionIndex = this.dataset.question;
const option = this.dataset.option;
const radio = this.querySelector('input[type="radio"]');
// 清除同一题目的其他选中状态
document.querySelectorAll(`.option-item[data-question="${{questionIndex}}"]`).forEach(otherItem => {{
otherItem.classList.remove('selected');
}});
// 设置当前选中状态
this.classList.add('selected');
radio.checked = true;
// 记录答案
window.quiz.answers[questionIndex] = option;
window.quiz.updateProgress();
window.quiz.checkCompletion();
}});
}});
// 添加提交按钮
const container = document.getElementById('questionsContainer');
const submitBtn = document.createElement('button');
submitBtn.className = 'submit-btn';
submitBtn.textContent = '提交答题';
submitBtn.disabled = true;
submitBtn.onclick = () => this.submitQuiz();
container.appendChild(submitBtn);
}}
updateProgress() {{
const total = this.questions.length;
const answered = Object.keys(this.answers).length;
const percentage = total > 0 ? (answered / total) * 100 : 0;
document.getElementById('progressFill').style.width = percentage + '%';
}}
checkCompletion() {{
const total = this.questions.length;
const answered = Object.keys(this.answers).length;
const submitBtn = document.querySelector('.submit-btn');
submitBtn.disabled = answered !== total;
}}
async submitQuiz() {{
try {{
const submitBtn = document.querySelector('.submit-btn');
submitBtn.textContent = '正在提交...';
submitBtn.disabled = true;
const answersData = this.questions.map((question, index) => ({{
questionId: question.questionId,
questionText: question['题干'],
questionType: question.questionType,
userAnswer: this.answers[index] || '',
correctAnswer: question['答案'],
score: parseInt(question['分数']) || 0
}}));
const response = await fetch('/api/save-answers', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
sessionId: this.sessionId,
answers: answersData
}})
}});
if (response.ok) {{
const result = await response.json();
this.showResults(result.totalScore);
}} else {{
throw new Error('提交失败');
}}
}} catch (error) {{
this.showError('提交失败: ' + error.message);
const submitBtn = document.querySelector('.submit-btn');
submitBtn.textContent = '提交答题';
submitBtn.disabled = false;
}}
}}
showResults(totalScore) {{
const container = document.getElementById('questionsContainer');
const totalPossible = this.questions.reduce((sum, q) => sum + (parseInt(q['分数']) || 0), 0);
const correctCount = this.calculateCorrectCount();
container.innerHTML = `
<div style="text-align: center; padding: 50px;">
<h2 style="color: #2d5a3d; margin-bottom: 20px;">🎉 答题完成!</h2>
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 15px; margin: 20px 0;">
<div style="font-size: 48px; font-weight: 700; margin-bottom: 10px;">${{totalScore}}分</div>
<div style="font-size: 18px; opacity: 0.9;">总分 ${{totalPossible}}分</div>
<div style="margin-top: 10px;">正确率: ${{correctCount}}/${{this.questions.length}}</div>
</div>
<p style="color: #666; margin-top: 20px;">感谢您的参与!测评结果已保存。</p>
</div>
`;
}}
calculateCorrectCount() {{
let correctCount = 0;
this.questions.forEach((question, index) => {{
if (this.answers[index] === question['答案']) {{
correctCount++;
}}
}});
return correctCount;
}}
showError(message) {{
const container = document.getElementById('questionsContainer');
container.innerHTML = `<div class="error">${{message}}</div>`;
}}
}}
// 初始化答题系统
window.quiz = new QuizSystem();
</script>
</body>
</html>'''
def run_server(port=5678):
"""运行HTTP服务器"""
server_address = ('', port)
httpd = HTTPServer(server_address, SurveyHandler)
print(f"Survey API服务器已启动: http://localhost:{port}")
httpd.serve_forever()
if __name__ == "__main__":
run_server()