打包成docker镜像

This commit is contained in:
燕陇琪 2025-11-05 09:37:29 +08:00
parent 7c407db78b
commit 5d1b088e06
9 changed files with 434 additions and 27 deletions

49
.dockerignore Normal file
View File

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

253
DOCKER.md Normal file
View File

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

73
Dockerfile Normal file
View File

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

15
main.go
View File

@ -23,11 +23,7 @@ func main() {
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.Logger()) r.Use(middleware.Logger())
// 静态文件服务 // API路由组必须在静态文件服务之前注册
r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
// API路由组
api := r.Group("/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" port := ":8080"
if err := r.Run(port); err != nil { if err := r.Run(port); err != nil {

View File

@ -2,6 +2,8 @@ package config
import ( import (
"fmt" "fmt"
"os"
"strconv"
) )
// DatabaseConfig 数据库配置结构 // DatabaseConfig 数据库配置结构
@ -15,17 +17,44 @@ type DatabaseConfig struct {
} }
// GetDatabaseConfig 获取数据库配置 // GetDatabaseConfig 获取数据库配置
// 优先使用环境变量,如果没有设置则使用默认值
func GetDatabaseConfig() *DatabaseConfig { 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{ return &DatabaseConfig{
Host: "pgsql.yuchat.top", Host: host,
Port: 5432, Port: port,
User: "postgres", User: user,
Password: "longqi@1314", Password: password,
DBName: "ankao", DBName: dbname,
SSLMode: "disable", 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 返回数据库连接字符串 // GetDSN 返回数据库连接字符串
func (c *DatabaseConfig) GetDSN() string { func (c *DatabaseConfig) GetDSN() string {
return fmt.Sprintf( return fmt.Sprintf(

View File

@ -98,10 +98,10 @@ const QuestionPage: React.FC = () => {
}; };
// 加载题目列表(从第一题开始) // 加载题目列表(从第一题开始)
const loadQuestions = async (type?: string, category?: string) => { const loadQuestions = async (type?: string) => {
setLoading(true); setLoading(true);
try { try {
const res = await questionApi.getQuestions({ type, category }); const res = await questionApi.getQuestions({ type });
if (res.success && res.data) { if (res.success && res.data) {
setAllQuestions(res.data); setAllQuestions(res.data);
@ -155,7 +155,7 @@ const QuestionPage: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
// 处理判断题答案:将字符串 "true"/"false" 转换为布尔值 // 处理判断题答案:将字符串 "true"/"false" 转换为布尔值
let answerToSubmit = selectedAnswer; let answerToSubmit: string | string[] | boolean = selectedAnswer;
if (currentQuestion.type === "true-false" && typeof selectedAnswer === "string") { if (currentQuestion.type === "true-false" && typeof selectedAnswer === "string") {
answerToSubmit = selectedAnswer === "true"; answerToSubmit = selectedAnswer === "true";
} }
@ -225,7 +225,6 @@ const QuestionPage: React.FC = () => {
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
const typeParam = searchParams.get("type"); const typeParam = searchParams.get("type");
const categoryParam = searchParams.get("category");
const mode = searchParams.get("mode"); const mode = searchParams.get("mode");
// 错题练习模式 // 错题练习模式
@ -235,7 +234,7 @@ const QuestionPage: React.FC = () => {
} }
// 普通练习模式 - 从第一题开始 // 普通练习模式 - 从第一题开始
loadQuestions(typeParam || undefined, categoryParam || undefined); loadQuestions(typeParam || undefined);
}, [searchParams]); }, [searchParams]);
// 重试处理 // 重试处理
@ -246,8 +245,7 @@ const QuestionPage: React.FC = () => {
setWrongCount(0); setWrongCount(0);
localStorage.removeItem(getStorageKey()); localStorage.removeItem(getStorageKey());
const typeParam = searchParams.get("type"); const typeParam = searchParams.get("type");
const categoryParam = searchParams.get("category"); loadQuestions(typeParam || undefined);
loadQuestions(typeParam || undefined, categoryParam || undefined);
}; };
return ( return (

View File

@ -290,7 +290,7 @@ const QuestionManagement: React.FC = () => {
{(fields, { add, remove }, { errors }) => ( {(fields, { add, remove }, { errors }) => (
<> <>
<Form.Item label="选项" required> <Form.Item label="选项" required>
{fields.map((field, index) => ( {fields.map((field) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline"> <Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item <Form.Item
{...field} {...field}
@ -349,7 +349,7 @@ const QuestionManagement: React.FC = () => {
{(fields, { add, remove }, { errors }) => ( {(fields, { add, remove }, { errors }) => (
<> <>
<Form.Item label="选项" required> <Form.Item label="选项" required>
{fields.map((field, index) => ( {fields.map((field) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline"> <Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item <Form.Item
{...field} {...field}

View File

@ -108,11 +108,11 @@ const WrongQuestions: React.FC = () => {
// 获取题型标签颜色 // 获取题型标签颜色
const getTypeColor = (type: string) => { const getTypeColor = (type: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
single: 'blue', 'multiple-choice': 'blue',
multiple: 'green', 'multiple-selection': 'green',
fill: 'cyan', 'fill-in-blank': 'cyan',
judge: 'orange', 'true-false': 'orange',
short: 'purple', 'short-answer': 'purple',
} }
return colorMap[type] || 'default' return colorMap[type] || 'default'
} }
@ -199,7 +199,7 @@ const WrongQuestions: React.FC = () => {
<Space> <Space>
<Text strong> {item.question.question_id || item.question.id}</Text> <Text strong> {item.question.question_id || item.question.id}</Text>
<Tag color={getTypeColor(item.question.type)}> <Tag color={getTypeColor(item.question.type)}>
{item.question.category} {item.question.category || item.question.type}
</Tag> </Tag>
<Tag color="error"> {item.wrong_count} </Tag> <Tag color="error"> {item.wrong_count} </Tag>
</Space> </Space>

View File

@ -21,7 +21,7 @@ export interface Question {
// 提交答案 // 提交答案
export interface SubmitAnswer { export interface SubmitAnswer {
question_id: number question_id: number
answer: string | string[] answer: string | string[] | boolean
} }
// 答案结果 // 答案结果