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:
yanlongqi 2025-11-10 13:12:20 +08:00
parent 30c5236bea
commit 53d3ebe318
8 changed files with 220 additions and 250 deletions

View File

@ -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

View File

@ -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.htmlSPA 应用)
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
View 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
View File

@ -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"

View File

@ -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("迁移完成!")
}

View File

@ -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
}

View File

@ -11,7 +11,7 @@ interface QuestionFloatButtonProps {
}
const QuestionFloatButton: React.FC<QuestionFloatButtonProps> = ({
currentIndex,
currentIndex: _currentIndex, // 保留参数但表示未使用
totalQuestions,
onClick,
correctCount,

View File

@ -5,6 +5,7 @@ import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: './', // 使用相对路径,确保打包后资源路径正确
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),