From 53d3ebe318eb88cab249ece3231f28de6ca967f9 Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Mon, 10 Nov 2025 13:12:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=9D=99=E6=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=9C=8D=E5=8A=A1=E5=92=8C=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改进: - 重构静态文件服务: 实现自定义 StaticFileHandler,完善 SPA 路由支持和 Content-Type 处理 - 优化 Docker 构建: 简化前端资源复制逻辑,直接复制整个 dist 目录 - 添加 K8s 部署配置: 包含健康检查探针、资源限制和服务配置 - 前端配置优化: Vite 使用相对路径 base,确保打包后资源路径正确 - 代码清理: 删除已废弃的数据迁移脚本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile | 8 +- internal/handlers/handlers.go | 70 +++++++++ k8s-deployment.yaml | 123 +++++++++++++++ main.go | 49 +++--- scripts/migrate_drop_old_columns.go | 44 ------ scripts/migrate_wrong_questions.go | 173 --------------------- web/src/components/QuestionFloatButton.tsx | 2 +- web/vite.config.ts | 1 + 8 files changed, 220 insertions(+), 250 deletions(-) create mode 100644 k8s-deployment.yaml delete mode 100644 scripts/migrate_drop_old_columns.go delete mode 100644 scripts/migrate_wrong_questions.go diff --git a/Dockerfile b/Dockerfile index 27b8efa..f956e76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,11 +60,9 @@ ENV TZ=Asia/Shanghai COPY --from=backend-builder /app/server ./server # 从前端构建阶段复制构建产物 -# 根据 main.go 配置: -# - r.NoRoute() 返回 ./web/index.html -# - r.Static("/assets", "./web/static") 提供静态资源 -COPY --from=frontend-builder /app/web/dist/index.html ./web/index.html -COPY --from=frontend-builder /app/web/dist/assets ./web/static +# 直接将整个 dist 目录复制为 web 目录 +# dist 目录包含:index.html, assets/, icon.svg 等所有构建产物 +COPY --from=frontend-builder /app/web/dist ./web # 暴露端口 EXPOSE 8080 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 66d230c..213fa2c 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -4,7 +4,10 @@ import ( "ankao/internal/database" "ankao/internal/models" "net/http" + "os" + "path/filepath" "strconv" + "strings" "github.com/gin-gonic/gin" ) @@ -80,3 +83,70 @@ func GetDailyRanking(c *gin.Context) { "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) + } +} diff --git a/k8s-deployment.yaml b/k8s-deployment.yaml new file mode 100644 index 0000000..6dd91dc --- /dev/null +++ b/k8s-deployment.yaml @@ -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 diff --git a/main.go b/main.go index fe213b8..00d982c 100644 --- a/main.go +++ b/main.go @@ -40,12 +40,12 @@ func main() { auth := api.Group("", middleware.Auth()) { // 用户相关API - auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型 - auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息 - auth.PUT("/user/password", handlers.ChangePassword) // 修改密码 + auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型 + auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息 + auth.PUT("/user/password", handlers.ChangePassword) // 修改密码 // 排行榜API - auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜 + auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜 // 练习题相关API(需要登录) auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表 @@ -54,27 +54,27 @@ func main() { // 练习题提交(需要登录才能记录错题) auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 - auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 + auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 // 错题本相关API - auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序) - auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势) + auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序) + auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势) auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题 - auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情 - auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题 - auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 - auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签 + auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情 + auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题 + auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 + auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签 // 标签管理API - auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表 - auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签 - auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签 + auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表 + auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签 + auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签 auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签 // 考试相关API - auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试 - auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情 - auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试 + auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试 + auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情 + auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试 } // 题库管理API(需要管理员权限) @@ -88,19 +88,14 @@ func main() { // 用户管理API(仅yanlongqi用户可访问) userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly()) { - userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计 - userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计 + userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计 + userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计 } } - // 静态文件服务(必须在 API 路由之后) - // 提供静态资源(CSS、JS、图片等) - r.Static("/assets", "./web/static") - - // SPA 支持:所有非 API 路由都返回 index.html,让前端路由处理 - r.NoRoute(func(c *gin.Context) { - c.File("./web/index.html") - }) + // 静态文件服务(使用 NoRoute 避免路由冲突) + // 当没有匹配到任何 API 路由时,尝试提供静态文件 + r.NoRoute(gin.WrapH(handlers.StaticFileHandler("./web"))) // 启动服务器 port := ":8080" diff --git a/scripts/migrate_drop_old_columns.go b/scripts/migrate_drop_old_columns.go deleted file mode 100644 index 182d57a..0000000 --- a/scripts/migrate_drop_old_columns.go +++ /dev/null @@ -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("迁移完成!") -} diff --git a/scripts/migrate_wrong_questions.go b/scripts/migrate_wrong_questions.go deleted file mode 100644 index f399e94..0000000 --- a/scripts/migrate_wrong_questions.go +++ /dev/null @@ -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 -} diff --git a/web/src/components/QuestionFloatButton.tsx b/web/src/components/QuestionFloatButton.tsx index 7d78057..94db144 100644 --- a/web/src/components/QuestionFloatButton.tsx +++ b/web/src/components/QuestionFloatButton.tsx @@ -11,7 +11,7 @@ interface QuestionFloatButtonProps { } const QuestionFloatButton: React.FC = ({ - currentIndex, + currentIndex: _currentIndex, // 保留参数但表示未使用 totalQuestions, onClick, correctCount, diff --git a/web/vite.config.ts b/web/vite.config.ts index 18dd6b9..9ba3f51 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,6 +5,7 @@ import path from 'path' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + base: './', // 使用相对路径,确保打包后资源路径正确 resolve: { alias: { '@': path.resolve(__dirname, './src'),