实现完整的用户统计功能和认证系统

**统计功能**:
- 新增UserAnswerRecord模型记录用户答题历史
- 实现GetStatistics接口,统计题库总数、已答题数、正确率
- 在提交答案时自动记录答题历史
- 前端连接真实统计接口,显示实时数据

**认证系统优化**:
- 新增Auth中间件,实现基于Token的身份验证
- 登录和注册时自动生成并保存Token到数据库
- 所有需要登录的接口都通过Auth中间件保护
- 统一处理未授权请求,返回401状态码

**错题练习功能**:
- 新增GetRandomWrongQuestion接口,随机获取错题
- 支持错题练习模式(/question?mode=wrong)
- 优化错题本页面UI,移除已掌握功能
- 新增"开始错题练习"按钮,直接进入练习模式

**数据库迁移**:
- 新增user_answer_records表,记录用户答题历史
- User表新增token字段,存储用户登录凭证

**技术改进**:
- 统一错误处理,区分401未授权和404未找到
- 优化答题流程,记录历史和错题分离处理
- 移除异步记录错题,改为同步处理保证数据一致性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-04 15:26:27 +08:00
parent 9ca8f123e7
commit 2bcf6bdacc
11 changed files with 362 additions and 168 deletions

View File

@ -34,6 +34,7 @@ func InitDB() error {
&models.User{},
&models.PracticeQuestion{},
&models.WrongQuestion{}, // 添加错题表
&models.UserAnswerRecord{}, // 添加用户答题记录表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)

View File

@ -4,8 +4,10 @@ import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
@ -143,8 +145,15 @@ func SubmitPracticeAnswer(c *gin.Context) {
return
}
// 获取用户ID如果已登录
userID, _ := c.Get("user_id")
// 获取用户ID认证中间件已确保存在
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
db := database.GetDB()
var question models.PracticeQuestion
@ -170,11 +179,28 @@ func SubmitPracticeAnswer(c *gin.Context) {
// 验证答案
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
// 如果答错且用户已登录,记录到错题本
if !correct && userID != nil {
// 记录用户答题历史
if uid, ok := userID.(uint); ok {
// 异步记录错题,不影响主流程
go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer)
record := models.UserAnswerRecord{
UserID: uid,
QuestionID: question.ID,
IsCorrect: correct,
AnsweredAt: time.Now(),
}
// 记录到数据库(忽略错误,不影响主流程)
if err := db.Create(&record).Error; err != nil {
log.Printf("记录答题历史失败: %v", err)
}
}
// 如果答错,记录到错题本
if !correct {
if uid, ok := userID.(uint); ok {
// 记录错题
if err := recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer); err != nil {
// 记录错题失败不影响主流程,只记录日志
log.Printf("记录错题失败: %v", err)
}
}
}
@ -370,3 +396,67 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
return dto
}
// GetStatistics 获取用户统计数据
func GetStatistics(c *gin.Context) {
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
uid, ok := userID.(uint)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "用户ID格式错误",
})
return
}
db := database.GetDB()
// 获取题库总数
var totalQuestions int64
db.Model(&models.PracticeQuestion{}).Count(&totalQuestions)
// 获取用户已答题数(去重)
var answeredQuestions int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ?", uid).
Distinct("question_id").
Count(&answeredQuestions)
// 获取用户答对题数
var correctAnswers int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ? AND is_correct = ?", uid, true).
Count(&correctAnswers)
// 计算正确率
var accuracy float64
if answeredQuestions > 0 {
// 正确率 = 答对题数 / 总答题数
var totalAnswers int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ?", uid).
Count(&totalAnswers)
accuracy = float64(correctAnswers) / float64(totalAnswers) * 100
}
stats := models.UserStatistics{
TotalQuestions: int(totalQuestions),
AnsweredQuestions: int(answeredQuestions),
CorrectAnswers: int(correctAnswers),
Accuracy: accuracy,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}

View File

@ -66,6 +66,16 @@ func Login(c *gin.Context) {
// 生成token
token := generateToken(req.Username)
// 保存token到数据库
user.Token = token
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "token保存失败",
})
return
}
// 返回用户信息(不包含密码)
userInfo := models.UserInfoResponse{
Username: user.Username,
@ -131,6 +141,12 @@ func Register(c *gin.Context) {
return
}
// 生成token
token := generateToken(req.Username)
// 设置token
newUser.Token = token
// 保存到数据库
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@ -141,9 +157,6 @@ func Register(c *gin.Context) {
return
}
// 生成token
token := generateToken(req.Username)
// 返回用户信息
userInfo := models.UserInfoResponse{
Username: newUser.Username,

View File

@ -231,3 +231,35 @@ func recordWrongQuestion(userID, questionID uint, userAnswer, correctAnswer inte
return db.Create(&newWrong).Error
}
// GetRandomWrongQuestion 获取随机错题进行练习
func GetRandomWrongQuestion(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
db := database.GetDB()
var wrongQuestion models.WrongQuestion
// 随机获取一个错题
if err := db.Where("user_id = ?", userID).Order("RANDOM()").Preload("PracticeQuestion").First(&wrongQuestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "暂无错题",
})
return
}
// 转换为DTO返回
dto := convertToDTO(wrongQuestion.PracticeQuestion)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto,
})
}

View File

@ -0,0 +1,57 @@
package middleware
import (
"ankao/internal/database"
"ankao/internal/models"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Auth 认证中间件
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头获取token
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 解析Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "token格式错误",
})
c.Abort()
return
}
token := parts[1]
// 从数据库查找token对应的用户
db := database.GetDB()
var user models.User
if err := db.Where("token = ?", token).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "token无效或已过期",
})
c.Abort()
return
}
// 将用户ID设置到上下文
c.Set("user_id", user.ID)
c.Set("username", user.Username)
c.Next()
}
}

View File

@ -0,0 +1,29 @@
package models
import (
"time"
"gorm.io/gorm"
)
// UserAnswerRecord 用户答题记录
type UserAnswerRecord struct {
gorm.Model
UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID
QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID
IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对
AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间
}
// TableName 指定表名
func (UserAnswerRecord) TableName() string {
return "user_answer_records"
}
// UserStatistics 用户统计数据
type UserStatistics struct {
TotalQuestions int `json:"total_questions"` // 题库总数
AnsweredQuestions int `json:"answered_questions"` // 已答题数
CorrectAnswers int `json:"correct_answers"` // 答对题数
Accuracy float64 `json:"accuracy"` // 正确率
}

View File

@ -12,6 +12,7 @@ type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码
Token string `gorm:"size:255;index" json:"-"` // 用户登录token
Avatar string `gorm:"size:255" json:"avatar"`
Nickname string `gorm:"size:50" json:"nickname"`
CreatedAt time.Time `json:"created_at"`

18
main.go
View File

@ -41,14 +41,22 @@ func main() {
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
// 需要认证的路由
auth := api.Group("", middleware.Auth())
{
// 练习题提交(需要登录才能记录错题)
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
// 错题本相关API
api.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
api.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
api.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
api.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
auth.GET("/wrong-questions/random", handlers.GetRandomWrongQuestion) // 获取随机错题
auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
}
}
// 启动服务器

View File

@ -21,18 +21,9 @@ export const submitAnswer = (data: SubmitAnswer) => {
return request.post<ApiResponse<AnswerResult>>('/practice/submit', data)
}
// 获取统计数据 (暂时返回模拟数据,后续实现)
export const getStatistics = async () => {
// TODO: 实现真实的统计接口
return {
success: true,
data: {
total_questions: 0,
answered_questions: 0,
correct_answers: 0,
accuracy: 0,
}
}
// 获取统计数据
export const getStatistics = () => {
return request.get<ApiResponse<Statistics>>('/practice/statistics')
}
// 重置进度 (暂时返回模拟数据,后续实现)
@ -56,6 +47,11 @@ export const getWrongQuestionStats = () => {
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
}
// 获取随机错题
export const getRandomWrongQuestion = () => {
return request.get<ApiResponse<Question>>('/wrong-questions/random')
}
// 标记错题为已掌握
export const markWrongQuestionMastered = (id: number) => {
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/mastered`)

View File

@ -69,7 +69,12 @@ const QuestionPage: React.FC = () => {
const loadRandomQuestion = async () => {
setLoading(true)
try {
const res = await questionApi.getRandomQuestion()
// 检查是否是错题练习模式
const mode = searchParams.get('mode')
const res = mode === 'wrong'
? await questionApi.getRandomWrongQuestion()
: await questionApi.getRandomQuestion()
if (res.success && res.data) {
setCurrentQuestion(res.data)
setSelectedAnswer(res.data.type === 'multiple' ? [] : '')
@ -77,8 +82,14 @@ const QuestionPage: React.FC = () => {
setShowResult(false)
setAnswerResult(null)
}
} catch (error) {
} catch (error: any) {
if (error.response?.status === 401) {
message.error('请先登录')
} else if (error.response?.status === 404) {
message.error('暂无错题')
} else {
message.error('加载题目失败')
}
} finally {
setLoading(false)
}
@ -220,7 +231,15 @@ const QuestionPage: React.FC = () => {
useEffect(() => {
const typeParam = searchParams.get('type')
const categoryParam = searchParams.get('category')
const mode = searchParams.get('mode')
// 错题练习模式
if (mode === 'wrong') {
loadRandomQuestion()
return
}
// 普通练习模式
if (typeParam || categoryParam) {
loadQuestions(typeParam || undefined, categoryParam || undefined)
} else {

View File

@ -1,85 +1,50 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, List, Button, Tag, Typography, Space, message, Modal, Statistic, Row, Col, Tabs, Empty } from 'antd'
import { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Statistic } from 'antd'
import {
BookOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
ArrowLeftOutlined,
PlayCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { WrongQuestion, WrongQuestionStats } from '../types/question'
import type { WrongQuestion } from '../types/question'
import styles from './WrongQuestions.module.less'
const { Title, Text, Paragraph } = Typography
const { Title, Text } = Typography
const WrongQuestions: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
const [stats, setStats] = useState<WrongQuestionStats | null>(null)
const [activeTab, setActiveTab] = useState<string>('all')
// 加载错题列表
const loadWrongQuestions = async (params?: { is_mastered?: boolean }) => {
const loadWrongQuestions = async () => {
try {
setLoading(true)
const res = await questionApi.getWrongQuestions(params)
const res = await questionApi.getWrongQuestions()
if (res.success && res.data) {
setWrongQuestions(res.data)
}
} catch (error) {
} catch (error: any) {
console.error('加载错题列表失败:', error)
if (error.response?.status === 401) {
message.error('请先登录')
navigate('/login')
} else {
message.error('加载错题列表失败')
}
} finally {
setLoading(false)
}
}
// 加载错题统计
const loadStats = async () => {
try {
const res = await questionApi.getWrongQuestionStats()
if (res.success && res.data) {
setStats(res.data)
}
} catch (error) {
console.error('加载统计失败:', error)
}
}
useEffect(() => {
loadWrongQuestions()
loadStats()
}, [])
// 切换标签
const handleTabChange = (key: string) => {
setActiveTab(key)
if (key === 'all') {
loadWrongQuestions()
} else if (key === 'not_mastered') {
loadWrongQuestions({ is_mastered: false })
} else if (key === 'mastered') {
loadWrongQuestions({ is_mastered: true })
}
}
// 标记为已掌握
const handleMarkMastered = async (id: number) => {
try {
const res = await questionApi.markWrongQuestionMastered(id)
if (res.success) {
message.success('已标记为掌握')
loadWrongQuestions()
loadStats()
}
} catch (error) {
message.error('操作失败')
}
}
// 清空错题本
const handleClear = () => {
Modal.confirm({
@ -94,7 +59,6 @@ const WrongQuestions: React.FC = () => {
if (res.success) {
message.success('已清空错题本')
loadWrongQuestions()
loadStats()
}
} catch (error) {
message.error('清空失败')
@ -103,10 +67,10 @@ const WrongQuestions: React.FC = () => {
})
}
// 重做题目
const handleRedo = (questionId: number) => {
// 跳转到答题页面,指定题目ID
navigate(`/question?id=${questionId}`)
// 开始错题练习
const handlePractice = () => {
// 跳转到答题页面,错题练习模式
navigate('/question?mode=wrong')
}
// 格式化答案显示
@ -120,13 +84,37 @@ const WrongQuestions: React.FC = () => {
return String(answer)
}
// 获取题型标签颜色
const getTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
single: 'blue',
multiple: 'green',
fill: 'cyan',
judge: 'orange',
short: 'purple',
}
return colorMap[type] || 'default'
}
// 获取题型名称
const getTypeName = (type: string) => {
const nameMap: Record<string, string> = {
single: '单选题',
multiple: '多选题',
fill: '填空题',
judge: '判断题',
short: '简答题',
}
return nameMap[type] || type
}
return (
<div className={styles.container}>
{/* 头部 */}
<div className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/profile')}
onClick={() => navigate('/')}
type="text"
className={styles.backButton}
>
@ -138,115 +126,75 @@ const WrongQuestions: React.FC = () => {
</div>
{/* 统计卡片 */}
{stats && (
<Card className={styles.statsCard}>
<Row gutter={[16, 16]}>
<Col xs={8} sm={8} md={8}>
<Space size="large">
<Statistic
title="错题总数"
value={stats.total_wrong}
valueStyle={{ color: '#ff4d4f', fontSize: '24px' }}
value={wrongQuestions.length}
valueStyle={{ color: '#ff4d4f', fontSize: '32px' }}
prefix={<CloseCircleOutlined />}
/>
</Col>
<Col xs={8} sm={8} md={8}>
<Statistic
title="已掌握"
value={stats.mastered}
valueStyle={{ color: '#52c41a', fontSize: '24px' }}
prefix={<CheckCircleOutlined />}
/>
</Col>
<Col xs={8} sm={8} md={8}>
<Statistic
title="待掌握"
value={stats.not_mastered}
valueStyle={{ color: '#faad14', fontSize: '24px' }}
/>
</Col>
</Row>
</Space>
</Card>
)}
{/* 操作按钮 */}
<div className={styles.actions}>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handlePractice}
disabled={!wrongQuestions.length}
size="large"
>
</Button>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleClear}
disabled={!wrongQuestions.length}
size="large"
>
</Button>
</Space>
</div>
{/* 错题列表 */}
<Card className={styles.listCard}>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={[
{ key: 'all', label: '全部错题' },
{ key: 'not_mastered', label: '未掌握' },
{ key: 'mastered', label: '已掌握' },
]}
/>
{wrongQuestions.length === 0 ? (
<Empty description="暂无错题" />
<Empty description="暂无错题,继续加油!" />
) : (
<List
loading={loading}
dataSource={wrongQuestions}
renderItem={(item) => (
<List.Item
key={item.id}
className={styles.listItem}
actions={[
<Button
key="redo"
type="link"
icon={<ReloadOutlined />}
onClick={() => handleRedo(item.question.id)}
>
</Button>,
!item.is_mastered && (
<Button
key="master"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => handleMarkMastered(item.id)}
>
</Button>
),
].filter(Boolean)}
>
<List.Item key={item.id} className={styles.listItem}>
<List.Item.Meta
title={
<Space>
<Text strong> {item.question.question_id || item.question.id}</Text>
<Tag color={item.question.type === 'single' ? 'blue' : item.question.type === 'multiple' ? 'green' : 'orange'}>
{item.question.type === 'single' ? '单选' : item.question.type === 'multiple' ? '多选' : '判断'}
<Tag color={getTypeColor(item.question.type)}>
{getTypeName(item.question.type)}
</Tag>
{item.is_mastered && <Tag color="success"></Tag>}
<Tag> {item.wrong_count} </Tag>
<Tag color="error"> {item.wrong_count} </Tag>
</Space>
}
description={
<div className={styles.questionContent}>
<Paragraph>{item.question.content}</Paragraph>
<div style={{ marginBottom: 12 }}>
<Text>{item.question.content}</Text>
</div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text type="danger">
: {formatAnswer(item.wrong_answer, item.question.type)}
<strong></strong>{formatAnswer(item.wrong_answer, item.question.type)}
</Text>
<Text type="success">
: {formatAnswer(item.correct_answer, item.question.type)}
<strong></strong>{formatAnswer(item.correct_answer, item.question.type)}
</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
: {new Date(item.last_wrong_time).toLocaleString()}
{new Date(item.last_wrong_time).toLocaleString()}
</Text>
</Space>
</div>