From 5d1b088e06d2f315c9d7c27545ae7f9a0ddc750e Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Wed, 5 Nov 2025 09:37:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8C=85=E6=88=90docker=E9=95=9C?= =?UTF-8?q?=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 49 ++++++ DOCKER.md | 253 +++++++++++++++++++++++++++ Dockerfile | 73 ++++++++ main.go | 15 +- pkg/config/config.go | 41 ++++- web/src/pages/Question.tsx | 12 +- web/src/pages/QuestionManagement.tsx | 4 +- web/src/pages/WrongQuestions.tsx | 12 +- web/src/types/question.ts | 2 +- 9 files changed, 434 insertions(+), 27 deletions(-) create mode 100644 .dockerignore create mode 100644 DOCKER.md create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3ee0c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Git 相关 +.git +.gitignore +.github + +# 文档 +*.md +LICENSE + +# IDE 配置 +.vscode +.idea +*.swp +*.swo + +# Go 相关 +bin/ +*.exe +*.test +*.out + +# 前端相关 +web/node_modules/ +web/dist/ +web/.vite/ +web/yarn-error.log +web/package-lock.json + +# 环境变量和配置 +.env +.env.local +*.local + +# 日志 +*.log + +# 临时文件 +tmp/ +temp/ +*.tmp + +# OS 相关 +.DS_Store +Thumbs.db + +# Docker 相关 +Dockerfile +.dockerignore +docker-compose.yml diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..d3d483a --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,253 @@ +# Docker 部署指南 + +本文档说明如何使用 Docker 部署 AnKao 应用。 + +## 快速开始 + +### 使用 Docker Compose (推荐) + +最简单的方式是使用 docker-compose,它会自动启动数据库和应用: + +```bash +# 构建并启动所有服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f + +# 停止服务 +docker-compose down + +# 停止并删除数据 +docker-compose down -v +``` + +访问应用: http://localhost:8080 + +### 手动使用 Docker + +如果你想单独构建和运行容器: + +#### 1. 构建镜像 + +```bash +docker build -t ankao:latest . +``` + +#### 2. 启动 PostgreSQL 数据库 + +```bash +docker run -d \ + --name ankao-postgres \ + -e POSTGRES_DB=ankao \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres:16-alpine +``` + +#### 3. 启动应用 + +```bash +docker run -d \ + --name ankao-app \ + -p 8080:8080 \ + -e DB_HOST=host.docker.internal \ + -e DB_PORT=5432 \ + -e DB_USER=postgres \ + -e DB_PASSWORD=postgres \ + -e DB_NAME=ankao \ + -e DB_SSLMODE=disable \ + ankao:latest +``` + +**注意**: 在 Windows 和 Mac 上使用 `host.docker.internal` 连接宿主机的数据库。在 Linux 上需要使用 `--network host` 或创建自定义网络。 + +## 环境变量配置 + +应用支持以下环境变量配置: + +### 数据库配置 + +| 环境变量 | 描述 | 默认值 | +|---------|------|--------| +| `DB_HOST` | 数据库主机地址 | pgsql.yuchat.top | +| `DB_PORT` | 数据库端口 | 5432 | +| `DB_USER` | 数据库用户名 | postgres | +| `DB_PASSWORD` | 数据库密码 | longqi@1314 | +| `DB_NAME` | 数据库名称 | ankao | +| `DB_SSLMODE` | SSL 模式 | disable | + +### 应用配置 + +| 环境变量 | 描述 | 默认值 | +|---------|------|--------| +| `GIN_MODE` | Gin 运行模式 (debug/release) | debug | + +## Docker Compose 配置说明 + +`docker-compose.yml` 文件定义了两个服务: + +1. **postgres**: PostgreSQL 16 数据库 + - 端口: 5432 + - 数据持久化: 使用 Docker volume `postgres_data` + - 健康检查: 确保数据库就绪后再启动应用 + +2. **app**: AnKao 应用 + - 端口: 8080 + - 依赖 postgres 服务 + - 自动重启策略 + +## 生产环境部署建议 + +### 1. 修改默认密码 + +**重要**: 生产环境中务必修改数据库密码! + +编辑 `docker-compose.yml`: +```yaml +environment: + POSTGRES_PASSWORD: 你的强密码 + DB_PASSWORD: 你的强密码 +``` + +### 2. 使用外部数据库 + +如果使用外部 PostgreSQL 数据库,只需启动 app 服务: + +```bash +docker-compose up -d app +``` + +并配置相应的环境变量。 + +### 3. 配置反向代理 + +生产环境建议使用 Nginx 作为反向代理: + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 4. 启用 HTTPS + +使用 Let's Encrypt 免费 SSL 证书: + +```bash +# 安装 certbot +apt-get install certbot python3-certbot-nginx + +# 获取证书 +certbot --nginx -d your-domain.com +``` + +### 5. 备份数据 + +定期备份 PostgreSQL 数据: + +```bash +# 备份数据库 +docker exec ankao-postgres pg_dump -U postgres ankao > backup.sql + +# 恢复数据库 +cat backup.sql | docker exec -i ankao-postgres psql -U postgres ankao +``` + +## 故障排查 + +### 查看应用日志 + +```bash +# 查看所有服务日志 +docker-compose logs + +# 查看应用日志 +docker-compose logs app + +# 查看数据库日志 +docker-compose logs postgres + +# 实时查看日志 +docker-compose logs -f app +``` + +### 进入容器调试 + +```bash +# 进入应用容器 +docker exec -it ankao-app sh + +# 进入数据库容器 +docker exec -it ankao-postgres sh +``` + +### 常见问题 + +#### 1. 应用无法连接数据库 + +检查: +- 数据库是否已启动: `docker-compose ps` +- 网络配置是否正确 +- 环境变量配置是否正确 + +#### 2. 端口被占用 + +修改 `docker-compose.yml` 中的端口映射: +```yaml +ports: + - "8081:8080" # 将宿主机端口改为 8081 +``` + +#### 3. 构建失败 + +清理缓存重新构建: +```bash +docker-compose build --no-cache +``` + +## 镜像优化 + +当前 Dockerfile 使用多阶段构建,最终镜像基于 Alpine Linux,体积小且安全。 + +镜像大小约: +- 前端构建阶段: ~200MB (不包含在最终镜像) +- 后端构建阶段: ~300MB (不包含在最终镜像) +- 最终运行镜像: ~30MB + +## 更新应用 + +```bash +# 拉取最新代码 +git pull + +# 重新构建并启动 +docker-compose up -d --build + +# 或者分步执行 +docker-compose build +docker-compose up -d +``` + +## 清理资源 + +```bash +# 停止并删除容器 +docker-compose down + +# 删除容器和数据卷 +docker-compose down -v + +# 删除镜像 +docker rmi ankao:latest +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27b8efa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# ============================================ +# 第一阶段: 构建前端应用 +# ============================================ +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/web + +# 复制前端依赖文件 +COPY web/package.json web/yarn.lock ./ + +# 安装依赖 +RUN yarn install --frozen-lockfile + +# 复制前端源代码 +COPY web/ ./ + +# 构建前端应用 +RUN yarn build + +# ============================================ +# 第二阶段: 构建 Go 后端应用 +# ============================================ +FROM golang:1.25.1-alpine AS backend-builder + +WORKDIR /app + +# 安装必要的构建工具 +RUN apk add --no-cache git + +# 配置 Go 代理(使用国内镜像加速) +ENV GOPROXY=https://goproxy.cn,https://goproxy.io,https://mirrors.aliyun.com/goproxy/,direct +ENV GOSUMDB=off + +# 复制 Go 依赖文件 +COPY go.mod go.sum ./ + +# 下载依赖 +RUN go mod download + +# 复制后端源代码 +COPY . . + +# 构建 Go 应用 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server main.go + +# ============================================ +# 第三阶段: 创建最终运行镜像 +# ============================================ +FROM alpine:latest + +WORKDIR /app + +# 安装运行时依赖 +RUN apk --no-cache add ca-certificates tzdata + +# 设置时区为上海 +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 + +# 暴露端口 +EXPOSE 8080 + +# 启动应用 +CMD ["./server"] diff --git a/main.go b/main.go index fcd905a..909fc88 100644 --- a/main.go +++ b/main.go @@ -23,11 +23,7 @@ func main() { r.Use(middleware.CORS()) r.Use(middleware.Logger()) - // 静态文件服务 - r.Static("/static", "./web/static") - r.StaticFile("/", "./web/index.html") - - // API路由组 + // API路由组(必须在静态文件服务之前注册) api := r.Group("/api") { // 健康检查 @@ -65,6 +61,15 @@ func main() { } } + // 静态文件服务(必须在 API 路由之后) + // 提供静态资源(CSS、JS、图片等) + r.Static("/assets", "./web/static") + + // SPA 支持:所有非 API 路由都返回 index.html,让前端路由处理 + r.NoRoute(func(c *gin.Context) { + c.File("./web/index.html") + }) + // 启动服务器 port := ":8080" if err := r.Run(port); err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index c7a1807..04f5694 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "os" + "strconv" ) // DatabaseConfig 数据库配置结构 @@ -15,17 +17,44 @@ type DatabaseConfig struct { } // GetDatabaseConfig 获取数据库配置 +// 优先使用环境变量,如果没有设置则使用默认值 func GetDatabaseConfig() *DatabaseConfig { + // 从环境变量获取配置,如果未设置则使用默认值 + host := getEnv("DB_HOST", "pgsql.yuchat.top") + port := getEnvAsInt("DB_PORT", 5432) + user := getEnv("DB_USER", "postgres") + password := getEnv("DB_PASSWORD", "longqi@1314") + dbname := getEnv("DB_NAME", "ankao") + sslmode := getEnv("DB_SSLMODE", "disable") + return &DatabaseConfig{ - Host: "pgsql.yuchat.top", - Port: 5432, - User: "postgres", - Password: "longqi@1314", - DBName: "ankao", - SSLMode: "disable", + Host: host, + Port: port, + User: user, + Password: password, + DBName: dbname, + SSLMode: sslmode, } } +// getEnv 获取环境变量,如果不存在则返回默认值 +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvAsInt 获取整型环境变量,如果不存在或转换失败则返回默认值 +func getEnvAsInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + // GetDSN 返回数据库连接字符串 func (c *DatabaseConfig) GetDSN() string { return fmt.Sprintf( diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 459076a..1846a74 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -98,10 +98,10 @@ const QuestionPage: React.FC = () => { }; // 加载题目列表(从第一题开始) - const loadQuestions = async (type?: string, category?: string) => { + const loadQuestions = async (type?: string) => { setLoading(true); try { - const res = await questionApi.getQuestions({ type, category }); + const res = await questionApi.getQuestions({ type }); if (res.success && res.data) { setAllQuestions(res.data); @@ -155,7 +155,7 @@ const QuestionPage: React.FC = () => { setLoading(true); try { // 处理判断题答案:将字符串 "true"/"false" 转换为布尔值 - let answerToSubmit = selectedAnswer; + let answerToSubmit: string | string[] | boolean = selectedAnswer; if (currentQuestion.type === "true-false" && typeof selectedAnswer === "string") { answerToSubmit = selectedAnswer === "true"; } @@ -225,7 +225,6 @@ const QuestionPage: React.FC = () => { // 初始化 useEffect(() => { const typeParam = searchParams.get("type"); - const categoryParam = searchParams.get("category"); const mode = searchParams.get("mode"); // 错题练习模式 @@ -235,7 +234,7 @@ const QuestionPage: React.FC = () => { } // 普通练习模式 - 从第一题开始 - loadQuestions(typeParam || undefined, categoryParam || undefined); + loadQuestions(typeParam || undefined); }, [searchParams]); // 重试处理 @@ -246,8 +245,7 @@ const QuestionPage: React.FC = () => { setWrongCount(0); localStorage.removeItem(getStorageKey()); const typeParam = searchParams.get("type"); - const categoryParam = searchParams.get("category"); - loadQuestions(typeParam || undefined, categoryParam || undefined); + loadQuestions(typeParam || undefined); }; return ( diff --git a/web/src/pages/QuestionManagement.tsx b/web/src/pages/QuestionManagement.tsx index c0d6b7f..18c22bf 100644 --- a/web/src/pages/QuestionManagement.tsx +++ b/web/src/pages/QuestionManagement.tsx @@ -290,7 +290,7 @@ const QuestionManagement: React.FC = () => { {(fields, { add, remove }, { errors }) => ( <> - {fields.map((field, index) => ( + {fields.map((field) => ( { {(fields, { add, remove }, { errors }) => ( <> - {fields.map((field, index) => ( + {fields.map((field) => ( { // 获取题型标签颜色 const getTypeColor = (type: string) => { const colorMap: Record = { - single: 'blue', - multiple: 'green', - fill: 'cyan', - judge: 'orange', - short: 'purple', + 'multiple-choice': 'blue', + 'multiple-selection': 'green', + 'fill-in-blank': 'cyan', + 'true-false': 'orange', + 'short-answer': 'purple', } return colorMap[type] || 'default' } @@ -199,7 +199,7 @@ const WrongQuestions: React.FC = () => { 题目 {item.question.question_id || item.question.id} - {item.question.category} + {item.question.category || item.question.type} 错误 {item.wrong_count} 次 diff --git a/web/src/types/question.ts b/web/src/types/question.ts index 77df209..5a03328 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -21,7 +21,7 @@ export interface Question { // 提交答案 export interface SubmitAnswer { question_id: number - answer: string | string[] + answer: string | string[] | boolean } // 答案结果