feat: 优化静态文件服务和部署配置
主要改进: - 重构静态文件服务: 实现自定义 StaticFileHandler,完善 SPA 路由支持和 Content-Type 处理 - 优化 Docker 构建: 简化前端资源复制逻辑,直接复制整个 dist 目录 - 添加 K8s 部署配置: 包含健康检查探针、资源限制和服务配置 - 前端配置优化: Vite 使用相对路径 base,确保打包后资源路径正确 - 代码清理: 删除已废弃的数据迁移脚本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
30c5236bea
commit
53d3ebe318
@ -60,11 +60,9 @@ ENV TZ=Asia/Shanghai
|
|||||||
COPY --from=backend-builder /app/server ./server
|
COPY --from=backend-builder /app/server ./server
|
||||||
|
|
||||||
# 从前端构建阶段复制构建产物
|
# 从前端构建阶段复制构建产物
|
||||||
# 根据 main.go 配置:
|
# 直接将整个 dist 目录复制为 web 目录
|
||||||
# - r.NoRoute() 返回 ./web/index.html
|
# dist 目录包含:index.html, assets/, icon.svg 等所有构建产物
|
||||||
# - r.Static("/assets", "./web/static") 提供静态资源
|
COPY --from=frontend-builder /app/web/dist ./web
|
||||||
COPY --from=frontend-builder /app/web/dist/index.html ./web/index.html
|
|
||||||
COPY --from=frontend-builder /app/web/dist/assets ./web/static
|
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import (
|
|||||||
"ankao/internal/database"
|
"ankao/internal/database"
|
||||||
"ankao/internal/models"
|
"ankao/internal/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -80,3 +83,70 @@ func GetDailyRanking(c *gin.Context) {
|
|||||||
"data": rankings,
|
"data": rankings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StaticFileHandler 静态文件处理器,用于服务前端静态资源
|
||||||
|
// 使用 NoRoute 避免与 API 路由冲突
|
||||||
|
func StaticFileHandler(root string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 获取请求路径
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// 构建完整文件路径
|
||||||
|
fullPath := filepath.Join(root, path)
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
// 文件不存在,尝试返回 index.html(SPA 应用)
|
||||||
|
indexPath := filepath.Join(root, "index.html")
|
||||||
|
if _, err := os.Stat(indexPath); err == nil {
|
||||||
|
http.ServeFile(w, r, indexPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是目录,尝试返回目录下的 index.html
|
||||||
|
if info.IsDir() {
|
||||||
|
indexPath := filepath.Join(fullPath, "index.html")
|
||||||
|
if _, err := os.Stat(indexPath); err == nil {
|
||||||
|
http.ServeFile(w, r, indexPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置正确的 Content-Type
|
||||||
|
setContentType(w, fullPath)
|
||||||
|
|
||||||
|
// 返回文件
|
||||||
|
http.ServeFile(w, r, fullPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setContentType 根据文件扩展名设置正确的 Content-Type
|
||||||
|
func setContentType(w http.ResponseWriter, filePath string) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
contentTypes := map[string]string{
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType, ok := contentTypes[ext]; ok {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
123
k8s-deployment.yaml
Normal file
123
k8s-deployment.yaml
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
kind: Deployment
|
||||||
|
apiVersion: apps/v1
|
||||||
|
metadata:
|
||||||
|
name: ankao
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ankao
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ankao
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: aliyun
|
||||||
|
containers:
|
||||||
|
- name: ankao
|
||||||
|
image: registry.cn-qingdao.aliyuncs.com/yuchat/ankao:0.0.9
|
||||||
|
env:
|
||||||
|
- name: DB_HOST
|
||||||
|
value: pgsql
|
||||||
|
- name: DB_USERNAME
|
||||||
|
value: postgres
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
value: longqi@1314
|
||||||
|
- name: AI_BASE_URL
|
||||||
|
value: http://new-api
|
||||||
|
- name: AI_API_KEY
|
||||||
|
value: sk-OKBmOpJx855juSOPU14cWG6Iz87tZQuv3Xg9PiaJYXdHoKcN
|
||||||
|
- name: AI_MODEL
|
||||||
|
value: deepseek-v3
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: tcp-8080
|
||||||
|
|
||||||
|
# 存活探针 - 检测容器是否正在运行
|
||||||
|
# 如果失败,Kubernetes 会重启容器
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 8080
|
||||||
|
scheme: HTTP
|
||||||
|
initialDelaySeconds: 30 # 容器启动后等待30秒再开始探测
|
||||||
|
periodSeconds: 10 # 每10秒探测一次
|
||||||
|
timeoutSeconds: 5 # 探测超时时间5秒
|
||||||
|
successThreshold: 1 # 成功1次即认为成功
|
||||||
|
failureThreshold: 3 # 连续失败3次后重启容器
|
||||||
|
|
||||||
|
# 就绪探针 - 检测容器是否准备好接收流量
|
||||||
|
# 如果失败,会从 Service 负载均衡中移除
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 8080
|
||||||
|
scheme: HTTP
|
||||||
|
initialDelaySeconds: 10 # 容器启动后等待10秒再开始探测
|
||||||
|
periodSeconds: 5 # 每5秒探测一次
|
||||||
|
timeoutSeconds: 3 # 探测超时时间3秒
|
||||||
|
successThreshold: 1 # 成功1次即认为就绪
|
||||||
|
failureThreshold: 3 # 连续失败3次后标记为未就绪
|
||||||
|
|
||||||
|
# 启动探针 - 检测容器应用是否已经启动(可选)
|
||||||
|
# 启动探针成功后,存活探针和就绪探针才会接管
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 8080
|
||||||
|
scheme: HTTP
|
||||||
|
initialDelaySeconds: 0 # 立即开始探测
|
||||||
|
periodSeconds: 5 # 每5秒探测一次
|
||||||
|
timeoutSeconds: 3 # 探测超时时间3秒
|
||||||
|
successThreshold: 1 # 成功1次即认为启动完成
|
||||||
|
failureThreshold: 12 # 最多失败12次(60秒)才判定启动失败
|
||||||
|
|
||||||
|
# 资源限制(建议配置)
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
|
||||||
|
volumeMounts:
|
||||||
|
- name: timezone
|
||||||
|
mountPath: /etc/timezone
|
||||||
|
readOnly: true
|
||||||
|
- name: localtime
|
||||||
|
mountPath: /etc/localtime
|
||||||
|
readOnly: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: timezone
|
||||||
|
hostPath:
|
||||||
|
path: /etc/timezone
|
||||||
|
type: File
|
||||||
|
- name: localtime
|
||||||
|
hostPath:
|
||||||
|
path: /etc/localtime
|
||||||
|
type: File
|
||||||
|
restartPolicy: Always
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: ankao
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ankao
|
||||||
|
ports:
|
||||||
|
- name: tcp-80
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
type: LoadBalancer
|
||||||
|
ipFamilyPolicy: PreferDualStack
|
||||||
|
ipFamilies:
|
||||||
|
- IPv4
|
||||||
|
- IPv6
|
||||||
49
main.go
49
main.go
@ -40,12 +40,12 @@ func main() {
|
|||||||
auth := api.Group("", middleware.Auth())
|
auth := api.Group("", middleware.Auth())
|
||||||
{
|
{
|
||||||
// 用户相关API
|
// 用户相关API
|
||||||
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
|
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
|
||||||
auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息
|
auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息
|
||||||
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
|
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
|
||||||
|
|
||||||
// 排行榜API
|
// 排行榜API
|
||||||
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
|
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
|
||||||
|
|
||||||
// 练习题相关API(需要登录)
|
// 练习题相关API(需要登录)
|
||||||
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
||||||
@ -54,27 +54,27 @@ func main() {
|
|||||||
|
|
||||||
// 练习题提交(需要登录才能记录错题)
|
// 练习题提交(需要登录才能记录错题)
|
||||||
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
||||||
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
||||||
|
|
||||||
// 错题本相关API
|
// 错题本相关API
|
||||||
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序)
|
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序)
|
||||||
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势)
|
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势)
|
||||||
auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题
|
auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题
|
||||||
auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情
|
auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情
|
||||||
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题
|
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题
|
||||||
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
|
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
|
||||||
auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签
|
auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签
|
||||||
|
|
||||||
// 标签管理API
|
// 标签管理API
|
||||||
auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表
|
auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表
|
||||||
auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签
|
auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签
|
||||||
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
|
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
|
||||||
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
|
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
|
||||||
|
|
||||||
// 考试相关API
|
// 考试相关API
|
||||||
auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试
|
auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试
|
||||||
auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情
|
auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情
|
||||||
auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试
|
auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题库管理API(需要管理员权限)
|
// 题库管理API(需要管理员权限)
|
||||||
@ -88,19 +88,14 @@ func main() {
|
|||||||
// 用户管理API(仅yanlongqi用户可访问)
|
// 用户管理API(仅yanlongqi用户可访问)
|
||||||
userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly())
|
userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly())
|
||||||
{
|
{
|
||||||
userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计
|
userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计
|
||||||
userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计
|
userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态文件服务(必须在 API 路由之后)
|
// 静态文件服务(使用 NoRoute 避免路由冲突)
|
||||||
// 提供静态资源(CSS、JS、图片等)
|
// 当没有匹配到任何 API 路由时,尝试提供静态文件
|
||||||
r.Static("/assets", "./web/static")
|
r.NoRoute(gin.WrapH(handlers.StaticFileHandler("./web")))
|
||||||
|
|
||||||
// SPA 支持:所有非 API 路由都返回 index.html,让前端路由处理
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
|
||||||
c.File("./web/index.html")
|
|
||||||
})
|
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
port := ":8080"
|
port := ":8080"
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"ankao/internal/database"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 直接设置数据库配置
|
|
||||||
os.Setenv("DB_HOST", "localhost")
|
|
||||||
os.Setenv("DB_PORT", "5432")
|
|
||||||
os.Setenv("DB_USER", "postgres")
|
|
||||||
os.Setenv("DB_PASSWORD", "root")
|
|
||||||
os.Setenv("DB_NAME", "ankao")
|
|
||||||
|
|
||||||
// 连接数据库
|
|
||||||
if err := database.InitDB(); err != nil {
|
|
||||||
log.Fatalf("数据库连接失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
log.Println("开始迁移:删除 wrong_questions 表的旧字段...")
|
|
||||||
|
|
||||||
// 删除 wrong_answer 和 correct_answer 字段
|
|
||||||
// 这些字段在新版本中已移至 wrong_question_history 表
|
|
||||||
migrations := []string{
|
|
||||||
"ALTER TABLE wrong_questions DROP COLUMN IF EXISTS wrong_answer",
|
|
||||||
"ALTER TABLE wrong_questions DROP COLUMN IF EXISTS correct_answer",
|
|
||||||
"ALTER TABLE wrong_questions DROP COLUMN IF EXISTS wrong_count", // 也删除旧的 wrong_count 字段(如果存在)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
log.Printf("执行: %s", migration)
|
|
||||||
if err := db.Exec(migration).Error; err != nil {
|
|
||||||
log.Printf("警告: 执行失败 - %v (字段可能已不存在)", err)
|
|
||||||
} else {
|
|
||||||
log.Println("✓ 执行成功")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("迁移完成!")
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"ankao/internal/database"
|
|
||||||
"ankao/internal/models"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 数据迁移脚本:从旧版错题本迁移到新版
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 初始化数据库
|
|
||||||
if err := database.InitDB(); err != nil {
|
|
||||||
log.Fatalf("数据库初始化失败: %v", err)
|
|
||||||
}
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
fmt.Println("开始错题本数据迁移...")
|
|
||||||
|
|
||||||
// 1. 创建新表
|
|
||||||
fmt.Println("1. 创建新表结构...")
|
|
||||||
if err := db.AutoMigrate(&models.WrongQuestion{}, &models.WrongQuestionHistory{}, &models.WrongQuestionTag{}); err != nil {
|
|
||||||
log.Fatalf("创建新表失败: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Println(" ✓ 新表创建成功")
|
|
||||||
|
|
||||||
// 2. 迁移数据
|
|
||||||
fmt.Println("2. 迁移旧数据到新表...")
|
|
||||||
if err := migrateOldData(db); err != nil {
|
|
||||||
log.Fatalf("数据迁移失败: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Println(" ✓ 数据迁移成功")
|
|
||||||
|
|
||||||
// 3. 统计信息
|
|
||||||
fmt.Println("3. 统计迁移结果...")
|
|
||||||
printStats(db)
|
|
||||||
|
|
||||||
fmt.Println("\n迁移完成!")
|
|
||||||
fmt.Println("\n注意事项:")
|
|
||||||
fmt.Println("- 旧表 'wrong_questions' 已保留,不会删除")
|
|
||||||
fmt.Println("- 新表 'wrong_questions_v2' 包含所有迁移后的数据")
|
|
||||||
fmt.Println("- 如需回滚,可以删除新表并继续使用旧表")
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateOldData(db *gorm.DB) error {
|
|
||||||
// 查询所有旧错题记录
|
|
||||||
var oldWrongQuestions []models.WrongQuestion
|
|
||||||
if err := db.Find(&oldWrongQuestions).Error; err != nil {
|
|
||||||
return fmt.Errorf("查询旧数据失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(oldWrongQuestions) == 0 {
|
|
||||||
fmt.Println(" 没有需要迁移的数据")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" 找到 %d 条旧记录,开始迁移...\n", len(oldWrongQuestions))
|
|
||||||
|
|
||||||
// 逐条迁移
|
|
||||||
for i, old := range oldWrongQuestions {
|
|
||||||
if err := migrateOneRecord(db, &old); err != nil {
|
|
||||||
log.Printf(" 警告: 迁移记录 %d 失败: %v", old.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (i+1)%100 == 0 {
|
|
||||||
fmt.Printf(" 已迁移: %d/%d\n", i+1, len(oldWrongQuestions))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" ✓ 迁移完成: %d/%d\n", len(oldWrongQuestions), len(oldWrongQuestions))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateOneRecord(db *gorm.DB, old *models.WrongQuestion) error {
|
|
||||||
// 检查是否已存在
|
|
||||||
var existing models.WrongQuestion
|
|
||||||
if err := db.Where("user_id = ? AND question_id = ?", old.UserID, old.QuestionID).First(&existing).Error; err == nil {
|
|
||||||
// 已存在,跳过
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新记录
|
|
||||||
newRecord := models.WrongQuestion{
|
|
||||||
UserID: old.UserID,
|
|
||||||
QuestionID: old.QuestionID,
|
|
||||||
FirstWrongTime: old.LastWrongTime, // 旧版只有最后错误时间
|
|
||||||
LastWrongTime: old.LastWrongTime,
|
|
||||||
TotalWrongCount: old.WrongCount,
|
|
||||||
MasteryLevel: 0,
|
|
||||||
ConsecutiveCorrect: 0,
|
|
||||||
IsMastered: old.IsMastered,
|
|
||||||
Tags: []string{}, // 默认无标签
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算下次复习时间
|
|
||||||
newRecord.CalculateNextReviewTime()
|
|
||||||
|
|
||||||
// 保存新记录
|
|
||||||
if err := db.Create(&newRecord).Error; err != nil {
|
|
||||||
return fmt.Errorf("创建新记录失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建历史记录(基于旧数据)
|
|
||||||
history := models.WrongQuestionHistory{
|
|
||||||
WrongQuestionID: newRecord.ID,
|
|
||||||
UserAnswer: old.WrongAnswer,
|
|
||||||
CorrectAnswer: old.CorrectAnswer,
|
|
||||||
AnsweredAt: old.LastWrongTime,
|
|
||||||
TimeSpent: 0,
|
|
||||||
IsCorrect: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&history).Error; err != nil {
|
|
||||||
log.Printf("警告: 创建历史记录失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printStats(db *gorm.DB) {
|
|
||||||
var oldCount, newCount, historyCount, tagCount int64
|
|
||||||
|
|
||||||
db.Model(&models.WrongQuestion{}).Count(&oldCount)
|
|
||||||
db.Model(&models.WrongQuestion{}).Count(&newCount)
|
|
||||||
db.Model(&models.WrongQuestionHistory{}).Count(&historyCount)
|
|
||||||
db.Model(&models.WrongQuestionTag{}).Count(&tagCount)
|
|
||||||
|
|
||||||
fmt.Printf("\n 旧表记录数: %d\n", oldCount)
|
|
||||||
fmt.Printf(" 新表记录数: %d\n", newCount)
|
|
||||||
fmt.Printf(" 历史记录数: %d\n", historyCount)
|
|
||||||
fmt.Printf(" 标签数: %d\n", tagCount)
|
|
||||||
|
|
||||||
// 统计掌握度分布
|
|
||||||
var masteryStats []struct {
|
|
||||||
MasteryLevel int
|
|
||||||
Count int64
|
|
||||||
}
|
|
||||||
db.Model(&models.WrongQuestion{}).
|
|
||||||
Select("mastery_level, COUNT(*) as count").
|
|
||||||
Group("mastery_level").
|
|
||||||
Scan(&masteryStats)
|
|
||||||
|
|
||||||
if len(masteryStats) > 0 {
|
|
||||||
fmt.Println("\n 掌握度分布:")
|
|
||||||
for _, stat := range masteryStats {
|
|
||||||
fmt.Printf(" - 掌握度 %d%%: %d 题\n", stat.MasteryLevel, stat.Count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计需要复习的题目
|
|
||||||
var needReview int64
|
|
||||||
now := time.Now()
|
|
||||||
db.Model(&models.WrongQuestion{}).
|
|
||||||
Where("is_mastered = ? AND next_review_time IS NOT NULL AND next_review_time <= ?", false, now).
|
|
||||||
Count(&needReview)
|
|
||||||
fmt.Printf("\n 需要复习的题目: %d\n", needReview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:解析JSON答案
|
|
||||||
func parseAnswer(answerStr string) interface{} {
|
|
||||||
var answer interface{}
|
|
||||||
if err := json.Unmarshal([]byte(answerStr), &answer); err != nil {
|
|
||||||
return answerStr
|
|
||||||
}
|
|
||||||
return answer
|
|
||||||
}
|
|
||||||
@ -11,7 +11,7 @@ interface QuestionFloatButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QuestionFloatButton: React.FC<QuestionFloatButtonProps> = ({
|
const QuestionFloatButton: React.FC<QuestionFloatButtonProps> = ({
|
||||||
currentIndex,
|
currentIndex: _currentIndex, // 保留参数但表示未使用
|
||||||
totalQuestions,
|
totalQuestions,
|
||||||
onClick,
|
onClick,
|
||||||
correctCount,
|
correctCount,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import path from 'path'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: './', // 使用相对路径,确保打包后资源路径正确
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user