从零开始构建一个现代化的任务管理系统,掌握前后端分离架构的完整开发流程
创建、编辑、删除任务,支持任务状态跟踪
用户注册、登录、权限管理
任务状态变更实时推送
首先创建项目目录并初始化Go模块:
mkdir task-manager && cd task-manager
mkdir -p backend/{cmd/{server,worker},internal/{auth,handlers,models,middleware,websocket},pkg/{config,database,utils,logger}}
cd backend
go mod init github.com/yourusername/task-manager
go get -u github.com/gin-gonic/gin
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
go get -u github.com/golang-jwt/jwt/v5
go get -u golang.org/x/crypto
go get -u github.com/gorilla/websocket
go get -u github.com/spf13/viper
go get -u go.uber.org/zap
在 internal/models 目录下创建模型:
package models
import (
"gorm.io/gorm"
"time"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"unique;not null"`
Email string `json:"email" gorm:"unique;not null"`
Password string `json:"-" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// 关联关系
Tasks []Task `json:"tasks,omitempty" gorm:"foreignKey:UserID"`
}
type Task struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"not null"`
Description string `json:"description"`
Status string `json:"status" gorm:"default:'pending'"`
Priority string `json:"priority" gorm:"default:'medium'"`
DueDate *time.Time `json:"due_date,omitempty"`
UserID uint `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// 关联关系
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
}
package models
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log"
)
var DB *gorm.DB
func InitDB() (*gorm.DB, error) {
dsn := "user:password@tcp(127.0.0.1:3306)/taskmanager?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
// 自动迁移
err = db.AutoMigrate(&User{}, &Task{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
DB = db
log.Println("Database connected successfully")
return db, nil
}
package config
import (
"github.com/spf13/viper"
"log"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
}
type ServerConfig struct {
Port string `mapstructure:"port"`
Host string `mapstructure:"host"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port string `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
func LoadConfig() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
viper.SetDefault("server.port", "8080")
viper.SetDefault("server.host", "localhost")
if err := viper.ReadInConfig(); err != nil {
log.Printf("Error reading config file: %v", err)
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/yourusername/taskmanager/internal/auth"
"net/http"
"strings"
)
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
c.Abort()
return
}
claims, err := auth.ParseToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/yourusername/taskmanager/internal/models"
"net/http"
"strconv"
)
type TaskHandler struct {
db *models.DB
}
func NewTaskHandler(db *models.DB) *TaskHandler {
return &TaskHandler{db: db}
}
// GetTasks 获取用户的所有任务
func (h *TaskHandler) GetTasks(c *gin.Context) {
userID := c.GetUint("user_id")
var tasks []models.Task
if err := h.db.Where("user_id = ?", userID).Find(&tasks).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tasks": tasks})
}
// CreateTask 创建新任务
func (h *TaskHandler) CreateTask(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Priority string `json:"priority"`
DueDate *string `json:"due_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
task := models.Task{
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
UserID: c.GetUint("user_id"),
Status: "pending",
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"task": task})
}
// UpdateTask 更新任务
func (h *TaskHandler) UpdateTask(c *gin.Context) {
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var task models.Task
if err := h.db.First(&task, taskID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
// 检查任务所有者
if task.UserID != c.GetUint("user_id") {
c.JSON(http.StatusForbidden, gin.H{"error": "Not authorized"})
return
}
// 更新字段
if req.Title != "" {
task.Title = req.Title
}
if req.Description != "" {
task.Description = req.Description
}
if req.Status != "" {
task.Status = req.Status
}
if req.Priority != "" {
task.Priority = req.Priority
}
if err := h.db.Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"task": task})
}
// DeleteTask 删除任务
func (h *TaskHandler) DeleteTask(c *gin.Context) {
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var task models.Task
if err := h.db.First(&task, taskID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
// 检查任务所有者
if task.UserID != c.GetUint("user_id") {
c.JSON(http.StatusForbidden, gin.H{"error": "Not authorized"})
return
}
if err := h.db.Delete(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"})
}
package routes
import (
"github.com/gin-gonic/gin"
"github.com/yourusername/taskmanager/internal/handlers"
"github.com/yourusername/taskmanager/internal/middleware"
"github.com/yourusername/taskmanager/internal/websocket"
"gorm.io/gorm"
)
func SetupRoutes(db *gorm.DB, hub *websocket.Hub) *gin.Engine {
r := gin.Default()
// 中间件
r.Use(gin.Recovery())
r.Use(gin.Logger())
// CORS配置
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// 公开路由
public := r.Group("/api/v1")
{
public.POST("/register", handlers.Register)
public.POST("/login", handlers.Login)
}
// 受保护路由
protected := r.Group("/api/v1")
protected.Use(middleware.AuthRequired())
{
// 用户相关
protected.GET("/profile", handlers.GetProfile)
protected.PUT("/profile", handlers.UpdateProfile)
// 任务相关
taskHandler := handlers.NewTaskHandler(db)
protected.GET("/tasks", taskHandler.GetTasks)
protected.POST("/tasks", taskHandler.CreateTask)
protected.GET("/tasks/:id", taskHandler.GetTask)
protected.PUT("/tasks/:id", taskHandler.UpdateTask)
protected.DELETE("/tasks/:id", taskHandler.DeleteTask)
}
// WebSocket路由
r.GET("/ws", func(c *gin.Context) {
websocket.HandleWebSocket(c, hub)
})
return r
}
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/yourusername/taskmanager/internal/models"
"github.com/yourusername/taskmanager/internal/routes"
"github.com/yourusername/taskmanager/internal/websocket"
"github.com/yourusername/taskmanager/pkg/config"
)
func main() {
// 加载配置
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 初始化数据库
db, err := models.InitDB()
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// 创建WebSocket Hub
hub := websocket.NewHub()
go hub.Run()
// 设置路由
router := routes.SetupRoutes(db, hub)
// 创建HTTP服务器
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: router,
}
// 启动服务器(协程)
go func() {
log.Printf("Starting server on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// 等待中断信号优雅关闭服务器
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// 设置5秒超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
| 方法 | 路径 | 描述 | 认证 |
|---|---|---|---|
| POST | /api/v1/register | 用户注册 | 否 |
| POST | /api/v1/login | 用户登录 | 否 |
| GET | /api/v1/tasks | 获取任务列表 | 是 |
| POST | /api/v1/tasks | 创建新任务 | 是 |
| PUT | /api/v1/tasks/:id | 更新任务 | 是 |
| DELETE | /api/v1/tasks/:id | 删除任务 | 是 |
JSON Web Token (JWT) 是一种开放标准 (RFC 7519),用于在各方之间安全地传输信息作为JSON对象。 本项目使用JWT实现无状态的身份认证。
package auth
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/yourusername/taskmanager/pkg/config"
"time"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username string, cfg *config.JWTConfig) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.ExpireHours) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "task-manager",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.Secret))
}
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.GetJWTSecret()), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
package auth
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用bcrypt加密密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/yourusername/taskmanager/internal/auth"
"github.com/yourusername/taskmanager/internal/models"
"github.com/yourusername/taskmanager/pkg/config"
"net/http"
)
func Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查用户名或邮箱是否已存在
var existingUser models.User
if err := models.DB.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
return
}
// 加密密码
hashedPassword, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// 创建用户
user := models.User{
Username: req.Username,
Email: req.Email,
Password: hashedPassword,
}
if err := models.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User registered successfully",
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
},
})
}
func Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
var user models.User
if err := models.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// 验证密码
if !auth.CheckPassword(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// 生成JWT Token
cfg, _ := config.LoadConfig()
token, err := auth.GenerateToken(user.ID, user.Username, &cfg.JWT)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Login successful",
"token": token,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
},
})
}
func GetProfile(c *gin.Context) {
userID := c.GetUint("user_id")
var user models.User
if err := models.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"created_at": user.CreatedAt,
},
})
}
server:
port: "8080"
host: "localhost"
database:
host: "localhost"
port: "3306"
username: "root"
password: "your_password"
database: "taskmanager"
jwt:
secret: "your-super-secret-jwt-key-change-this-in-production"
expire_hours: 24
使用Vite创建Vue 3项目:
cd task-manager
npm create vue@latest frontend
cd frontend
npm install
npm install element-plus
npm install axios
npm install pinia
npm install vue-router@4
npm install @element-plus/icons-vue
npm install socket.io-client
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresGuest: true },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
meta: { requiresGuest: true },
},
{
path: '/tasks',
name: 'Tasks',
component: () => import('@/views/Tasks.vue'),
meta: { requiresAuth: true },
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token') || null)
const isAuthenticated = computed(() => !!token.value)
const login = async (credentials) => {
try {
const response = await api.post('/login', credentials)
const { token: userToken, user: userData } = response.data
token.value = userToken
user.value = userData
localStorage.setItem('token', userToken)
api.defaults.headers.common['Authorization'] = `Bearer ${userToken}`
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed'
}
}
}
const register = async (userData) => {
try {
const response = await api.post('/register', userData)
return {
success: true,
message: response.data.message
}
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Registration failed'
}
}
}
const logout = () => {
token.value = null
user.value = null
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
}
const loadUser = async () => {
if (!token.value) return
try {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
const response = await api.get('/profile')
user.value = response.data.user
} catch (error) {
logout()
}
}
// 初始化时加载用户信息
if (token.value) {
loadUser()
}
return {
user,
token,
isAuthenticated,
login,
register,
logout,
loadUser,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/services/api'
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref([])
const loading = ref(false)
const error = ref(null)
const fetchTasks = async () => {
loading.value = true
error.value = null
try {
const response = await api.get('/tasks')
tasks.value = response.data.tasks
} catch (err) {
error.value = err.response?.data?.error || 'Failed to fetch tasks'
} finally {
loading.value = false
}
}
const createTask = async (taskData) => {
try {
const response = await api.post('/tasks', taskData)
tasks.value.unshift(response.data.task)
return { success: true, task: response.data.task }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to create task'
}
}
}
const updateTask = async (taskId, updates) => {
try {
const response = await api.put(`/tasks/${taskId}`, updates)
const index = tasks.value.findIndex(task => task.id === taskId)
if (index !== -1) {
tasks.value[index] = response.data.task
}
return { success: true, task: response.data.task }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to update task'
}
}
}
const deleteTask = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`)
tasks.value = tasks.value.filter(task => task.id !== taskId)
return { success: true }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to delete task'
}
}
}
const getTaskById = (taskId) => {
return tasks.value.find(task => task.id === taskId)
}
const getTasksByStatus = (status) => {
return tasks.value.filter(task => task.status === status)
}
const tasksByStatus = ref({
pending: [],
in_progress: [],
completed: []
})
const updateTasksByStatus = () => {
tasksByStatus.value = {
pending: tasks.value.filter(task => task.status === 'pending'),
in_progress: tasks.value.filter(task => task.status === 'in_progress'),
completed: tasks.value.filter(task => task.status === 'completed')
}
}
return {
tasks,
tasksByStatus,
loading,
error,
fetchTasks,
createTask,
updateTask,
deleteTask,
getTaskById,
getTasksByStatus,
updateTasksByStatus,
}
})
import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response
},
(error) => {
const message = error.response?.data?.error || 'Request failed'
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
} else if (error.response?.status === 403) {
ElMessage.error('You do not have permission to perform this action')
} else {
ElMessage.error(message)
}
return Promise.reject(error)
}
)
export default api
<template>
<div class="tasks-container">
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>任务管理</span>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon> 新建任务
</el-button>
</div>
</template>
<el-row :gutter="20">
<el-col :span="8" v-for="status in statusColumns" :key="status.value">
<div class="task-column">
<h3 class="column-title">{{ status.label }} ({{ getTasksByStatus(status.value).length }})</h3>
<el-card
v-for="task in getTasksByStatus(status.value)"
:key="task.id"
class="task-card"
shadow="hover"
>
<div class="task-content">
<h4>{{ task.title }}</h4>
<p v-if="task.description" class="task-description">{{ task.description }}</p>
<div class="task-meta">
<el-tag :type="getPriorityType(task.priority)" size="small">
{{ task.priority }}
</el-tag>
<span class="task-date">{{ formatDate(task.created_at) }}</span>
</div>
<div class="task-actions">
<el-button
type="primary"
size="small"
@click="editTask(task)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="deleteTask(task.id)"
>
删除
</el-button>
</div>
</div>
</el-card>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 创建/编辑任务对话框 -->
<el-dialog
v-model="showCreateDialog"
:title="editingTask ? '编辑任务' : '新建任务'"
width="500px"
>
<el-form :model="taskForm" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="taskForm.title" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="taskForm.description"
type="textarea"
:rows="3"
/>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="taskForm.priority">
<el-option label="低" value="low" />
<el-option label="中" value="medium" />
<el-option label="高" value="high" />
</el-select>
</el-form-item>
<el-form-item label="状态" v-if="editingTask">
<el-select v-model="taskForm.status">
<el-option label="待处理" value="pending" />
<el-option label="进行中" value="in_progress" />
<el-option label="已完成" value="completed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cancelDialog">取消</el-button>
<el-button type="primary" @click="saveTask" :loading="saving">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
const taskStore = useTaskStore()
const showCreateDialog = ref(false)
const editingTask = ref(null)
const saving = ref(false)
const taskForm = reactive({
title: '',
description: '',
priority: 'medium',
status: 'pending',
})
const statusColumns = ref([
{ value: 'pending', label: '待处理' },
{ value: 'in_progress', label: '进行中' },
{ value: 'completed', label: '已完成' },
])
onMounted(() => {
taskStore.fetchTasks()
})
const getTasksByStatus = (status) => {
return taskStore.getTasksByStatus(status)
}
const getPriorityType = (priority) => {
const types = {
low: 'info',
medium: 'warning',
high: 'danger',
}
return types[priority] || 'info'
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
const editTask = (task) => {
editingTask.value = task
Object.assign(taskForm, task)
showCreateDialog.value = true
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const result = await taskStore.deleteTask(taskId)
if (result.success) {
ElMessage.success('任务删除成功')
} else {
ElMessage.error(result.error)
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const saveTask = async () => {
if (!taskForm.title.trim()) {
ElMessage.warning('请输入任务标题')
return
}
saving.value = true
try {
let result
if (editingTask.value) {
result = await taskStore.updateTask(editingTask.value.id, taskForm)
} else {
result = await taskStore.createTask(taskForm)
}
if (result.success) {
ElMessage.success(editingTask.value ? '任务更新成功' : '任务创建成功')
cancelDialog()
} else {
ElMessage.error(result.error)
}
} catch (error) {
ElMessage.error('操作失败')
} finally {
saving.value = false
}
}
const cancelDialog = () => {
showCreateDialog.value = false
editingTask.value = null
Object.assign(taskForm, {
title: '',
description: '',
priority: 'medium',
status: 'pending',
})
}
</script>
<style scoped>
.tasks-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.task-column {
min-height: 500px;
}
.column-title {
margin-bottom: 16px;
color: #333;
font-size: 16px;
font-weight: 500;
}
.task-card {
margin-bottom: 12px;
cursor: pointer;
}
.task-content h4 {
margin: 0 0 8px 0;
color: #333;
}
.task-description {
color: #666;
font-size: 14px;
margin: 8px 0;
line-height: 1.4;
}
.task-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin: 12px 0;
}
.task-date {
color: #999;
font-size: 12px;
}
.task-actions {
display: flex;
gap: 8px;
}
</style>
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2 class="login-title">用户登录</h2>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="80px"
@submit.prevent="handleLogin"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
native-type="submit"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>还没有账号?</span>
<router-link to="/register" class="link">立即注册</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref()
const loading = ref(false)
const loginForm = reactive({
username: '',
password: '',
})
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少 6 个字符', trigger: 'blur' },
],
}
const handleLogin = async () => {
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const result = await authStore.login(loginForm)
if (result.success) {
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error(result.error)
}
} catch (error) {
ElMessage.error('登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
margin: 20px;
}
.login-title {
text-align: center;
margin: 0;
color: #333;
}
.login-footer {
text-align: center;
margin-top: 20px;
color: #666;
}
.link {
color: #409EFF;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
</style>
当前端和后端运行在不同的域名或端口时,需要处理跨域请求。本项目在Gin中配置CORS中间件:
package middleware
import (
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
在生产环境中,应该将 Access-Control-Allow-Origin
设置为具体的前端域名,而不是使用通配符 *:
c.Writer.Header().Set("Access-Control-Allow-Origin", "https://your-frontend-domain.com")
在Vite中配置代理,解决开发环境的跨域问题:
// vite.config.js
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080', // 后端地址
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
统一处理请求和响应,包括错误处理和Token刷新:
// services/api.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
timeout: 10000,
})
// 请求队列(用于Token刷新)
let isRefreshing = false
let failedQueue = []
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response
},
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
// 这里可以实现刷新Token的逻辑
// const refreshToken = localStorage.getItem('refreshToken')
// const response = await api.post('/refresh-token', { refreshToken })
// const { token } = response.data
// localStorage.setItem('token', token)
// api.defaults.headers.common['Authorization'] = `Bearer ${token}`
// processQueue(null, token)
// 简单处理:直接跳转到登录页
localStorage.removeItem('token')
window.location.href = '/login'
return Promise.reject(error)
} catch (refreshError) {
processQueue(refreshError, null)
localStorage.removeItem('token')
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
// 错误处理
const message = error.response?.data?.error || 'Request failed'
if (error.response?.status === 403) {
ElMessage.error('You do not have permission to perform this action')
} else if (error.response?.status === 404) {
ElMessage.error('Resource not found')
} else if (error.response?.status === 500) {
ElMessage.error('Server error, please try again later')
} else {
ElMessage.error(message)
}
return Promise.reject(error)
}
)
export default api
Network面板查看请求,Console查看日志
API测试工具,独立测试后端接口
TablePlus, DBeaver查看数据
WebSocket是一种在单个TCP连接上进行全双工通信的协议。相比于HTTP轮询, WebSocket能够实时推送数据,大大减少了网络开销和延迟。
package websocket
import (
"encoding/json"
"log"
"github.com/gorilla/websocket"
"net/http"
"time"
)
// Client 表示一个WebSocket客户端连接
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
userID uint
username string
}
// Hub 维护活跃客户端并广播消息
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
// Message 定义消息结构
type Message struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
UserID uint `json:"user_id,omitempty"`
Time time.Time `json:"time"`
}
// TaskUpdateMessage 任务更新消息
type TaskUpdateMessage struct {
TaskID uint `json:"task_id"`
Title string `json:"title"`
Status string `json:"status"`
UpdatedBy string `json:"updated_by"`
}
// NewHub 创建新的Hub实例
func NewHub() *Hub {
return &Hub{
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}
}
// Run 启动Hub的消息处理循环
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
log.Printf("Client %s connected", client.username)
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
log.Printf("Client %s disconnected", client.username)
}
case message := <-h.broadcast:
// 广播消息给所有客户端
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
// BroadcastToUser 向特定用户发送消息
func (h *Hub) BroadcastToUser(userID uint, message []byte) {
for client := range h.clients {
if client.userID == userID {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
// BroadcastTaskUpdate 广播任务更新消息
func (h *Hub) BroadcastTaskUpdate(taskID uint, title, status, updatedBy string) {
message := Message{
Type: "task_update",
Data: mustMarshal(TaskUpdateMessage{
TaskID: taskID,
Title: title,
Status: status,
UpdatedBy: updatedBy,
}),
Time: time.Now(),
}
data, _ := json.Marshal(message)
h.broadcast <- data
}
// mustMarshal 辅助函数,确保JSON序列化不失败
func mustMarshal(v interface{}) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return data
}
package websocket
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"log"
"net/http"
"time"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 生产环境应该检查Origin
return true
},
}
// HandleWebSocket 处理WebSocket连接
func HandleWebSocket(c *gin.Context, hub *Hub) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
username, _ := c.Get("username")
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
userID: userID.(uint),
username: username.(string),
}
client.hub.register <- client
// 启动读写协程
go client.writePump()
go client.readPump()
}
// readPump 从WebSocket连接读取消息
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
// 处理接收到的消息(可以根据需要扩展)
log.Printf("Received message from %s: %s", c.username, string(message))
}
}
// writePump 向WebSocket连接写入消息
func (c *Client) writePump() {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
c.conn.WriteMessage(websocket.TextMessage, message)
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
import { io } from 'socket.io-client'
import { ElMessage } from 'element-plus'
class WebSocketService {
constructor() {
this.socket = null
this.connected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
}
connect(token) {
return new Promise((resolve, reject) => {
this.socket = io(import.meta.env.VITE_WS_URL || 'ws://localhost:8080', {
auth: {
token: token
},
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: this.maxReconnectAttempts,
})
this.socket.on('connect', () => {
console.log('WebSocket connected')
this.connected = true
this.reconnectAttempts = 0
resolve()
})
this.socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason)
this.connected = false
})
this.socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error)
this.reconnectAttempts++
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
ElMessage.error('无法连接到服务器,请检查网络连接')
reject(error)
}
})
// 注册消息处理器
this.setupMessageHandlers()
})
}
disconnect() {
if (this.socket) {
this.socket.disconnect()
this.socket = null
this.connected = false
}
}
setupMessageHandlers() {
// 任务更新消息
this.socket.on('task_update', (data) => {
console.log('Task update received:', data)
ElMessage.info(`任务 "${data.title}" 已被 ${data.updated_by} 更新为 ${data.status}`)
})
// 新任务通知
this.socket.on('new_task', (data) => {
console.log('New task received:', data)
ElMessage.success(`新任务 "${data.title}" 已创建`)
})
// 系统通知
this.socket.on('notification', (data) => {
ElMessage[data.type || 'info'](data.message)
})
}
// 发送消息
sendMessage(event, data) {
if (this.connected && this.socket) {
this.socket.emit(event, data)
} else {
console.warn('WebSocket is not connected, message not sent')
}
}
// 订阅事件
on(event, callback) {
if (this.socket) {
this.socket.on(event, callback)
}
}
// 取消订阅
off(event, callback) {
if (this.socket) {
this.socket.off(event, callback)
}
}
}
export default new WebSocketService()
// 在任务处理器中添加WebSocket通知
func (h *TaskHandler) CreateTask(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Priority string `json:"priority"`
DueDate *string `json:"due_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
task := models.Task{
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
UserID: c.GetUint("user_id"),
Status: "pending",
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 广播新任务通知
username, _ := c.Get("username")
h.hub.BroadcastTaskUpdate(task.ID, task.Title, task.Status, username.(string))
c.JSON(http.StatusCreated, gin.H{"task": task})
}
func (h *TaskHandler) UpdateTask(c *gin.Context) {
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var task models.Task
if err := h.db.First(&task, taskID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
// 检查任务所有者
if task.UserID != c.GetUint("user_id") {
c.JSON(http.StatusForbidden, gin.H{"error": "Not authorized"})
return
}
// 更新字段
oldStatus := task.Status
if req.Title != "" {
task.Title = req.Title
}
if req.Description != "" {
task.Description = req.Description
}
if req.Status != "" {
task.Status = req.Status
}
if req.Priority != "" {
task.Priority = req.Priority
}
if err := h.db.Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 如果状态改变,广播通知
if oldStatus != task.Status {
username, _ := c.Get("username")
h.hub.BroadcastTaskUpdate(task.ID, task.Title, task.Status, username.(string))
}
c.JSON(http.StatusOK, gin.H{"task": task})
}
// stores/tasks.js 增强版
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/services/api'
import websocketService from '@/services/websocket'
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref([])
const loading = ref(false)
const error = ref(null)
const fetchTasks = async () => {
loading.value = true
error.value = null
try {
const response = await api.get('/tasks')
tasks.value = response.data.tasks
} catch (err) {
error.value = err.response?.data?.error || 'Failed to fetch tasks'
} finally {
loading.value = false
}
}
const createTask = async (taskData) => {
try {
const response = await api.post('/tasks', taskData)
tasks.value.unshift(response.data.task)
return { success: true, task: response.data.task }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to create task'
}
}
}
const updateTask = async (taskId, updates) => {
try {
const response = await api.put(`/tasks/${taskId}`, updates)
const index = tasks.value.findIndex(task => task.id === taskId)
if (index !== -1) {
tasks.value[index] = response.data.task
}
return { success: true, task: response.data.task }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to update task'
}
}
}
const deleteTask = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`)
tasks.value = tasks.value.filter(task => task.id !== taskId)
return { success: true }
} catch (err) {
return {
success: false,
error: err.response?.data?.error || 'Failed to delete task'
}
}
}
// 通过WebSocket接收任务更新
const handleTaskUpdate = (data) => {
const index = tasks.value.findIndex(task => task.id === data.task_id)
if (index !== -1) {
tasks.value[index].status = data.status
}
}
// 通过WebSocket接收新任务
const handleNewTask = (data) => {
// 检查任务是否已存在
const exists = tasks.value.some(task => task.id === data.id)
if (!exists) {
tasks.value.unshift(data)
}
}
// 初始化WebSocket监听
const initializeWebSocket = (token) => {
websocketService.connect(token)
// 注册事件监听器
websocketService.on('task_update', handleTaskUpdate)
websocketService.on('new_task', handleNewTask)
}
// 清理WebSocket监听
const cleanupWebSocket = () => {
websocketService.off('task_update', handleTaskUpdate)
websocketService.off('new_task', handleNewTask)
websocketService.disconnect()
}
return {
tasks,
loading,
error,
fetchTasks,
createTask,
updateTask,
deleteTask,
initializeWebSocket,
cleanupWebSocket,
}
})
Docker是一个开源的应用容器引擎,让开发者可以打包应用及其依赖包到一个可移植的容器中, 然后发布到任何流行的Linux机器或Windows机器上。
应用及其依赖打包到轻量级容器中
使用分层文件系统,提高构建效率
一次构建,到处运行
# 构建阶段
FROM golang:1.23-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装构建依赖
RUN apk add --no-cache git
# 复制Go模块文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
# 运行阶段
FROM alpine:latest
# 安装运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 创建非root用户
RUN addgroup -g 1000 -S appuser && \
adduser -u 1000 -S appuser -G appuser
# 设置工作目录
WORKDIR /root/
# 从构建阶段复制二进制文件
COPY --from=builder /app/main .
# 复制配置文件
COPY --from=builder /app/config.yaml ./config/
# 更改文件所有者
RUN chown -R appuser:appuser /root/
# 切换到非root用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# 启动应用
CMD ["./main"]
# 构建阶段
FROM node:18-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 运行阶段
FROM nginx:alpine
# 复制自定义nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 从构建阶段复制构建文件
COPY --from=builder /app/dist /usr/share/nginx/html
# 创建非root用户
RUN adduser -D -s /bin/sh nginx-user
# 更改文件权限
RUN chown -R nginx-user:nginx-user /usr/share/nginx/html
# 切换到非root用户
USER nginx-user
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 安全头部
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 缓存静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API代理(开发环境)
location /api {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
# WebSocket代理
location /ws {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 86400;
}
# 单页应用路由
location / {
try_files $uri $uri/ /index.html;
}
# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
version: '3.8'
services:
# MySQL数据库
mysql:
image: mysql:8.0
container_name: taskmanager-mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: taskmanager
MYSQL_USER: taskuser
MYSQL_PASSWORD: taskpassword
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- taskmanager-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
interval: 10s
start_period: 30s
# 后端API服务
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: taskmanager-backend
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: taskuser
DB_PASSWORD: taskpassword
DB_NAME: taskmanager
JWT_SECRET: your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRE_HOURS: 24
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
networks:
- taskmanager-network
volumes:
- ./backend/config.yaml:/root/config/config.yaml
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
timeout: 10s
retries: 3
interval: 30s
start_period: 40s
# 前端Web服务
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: taskmanager-frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- taskmanager-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
timeout: 10s
retries: 3
interval: 30s
start_period: 40s
# Redis缓存(可选)
redis:
image: redis:7-alpine
container_name: taskmanager-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- taskmanager-network
command: redis-server --appendonly yes
restart: unless-stopped
# Nginx反向代理(生产环境)
nginx:
image: nginx:alpine
container_name: taskmanager-nginx
ports:
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- frontend
- backend
networks:
- taskmanager-network
restart: unless-stopped
profiles:
- production
volumes:
mysql_data:
driver: local
redis_data:
driver: local
networks:
taskmanager-network:
driver: bridge
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=taskuser
DB_PASSWORD=taskpassword
DB_NAME=taskmanager
# JWT配置
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRE_HOURS=24
# 服务器配置
SERVER_PORT=8080
SERVER_HOST=localhost
# 前端API地址
VITE_API_BASE_URL=http://localhost:8080
VITE_WS_URL=ws://localhost:8080
# MySQL Root密码
MYSQL_ROOT_PASSWORD=rootpassword
git clone https://github.com/yourusername/task-manager.git
cd task-manager
cp .env.example .env
# 编辑 .env 文件,设置实际的配置值
# 开发环境
docker-compose up -d
# 生产环境(包含Nginx和SSL)
docker-compose --profile production up -d
# 查看容器状态
docker-compose ps
# 查看日志
docker-compose logs -f backend
docker-compose logs -f frontend
# 测试API
curl http://localhost:8080/health
# 访问前端
open http://localhost
# 启动服务
docker-compose up -d
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 查看日志
docker-compose logs -f [service]
# 构建镜像
docker-compose build
# 重新构建
docker-compose up --build
# 清理镜像
docker image prune
# 查看镜像
docker images
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/yourusername/taskmanager/internal/handlers"
"github.com/yourusername/taskmanager/internal/middleware"
"github.com/yourusername/taskmanager/internal/models"
"github.com/yourusername/taskmanager/internal/websocket"
"github.com/yourusername/taskmanager/pkg/config"
)
var (
cfg *config.Config
db *gorm.DB
)
func init() {
// 加载配置
var err error
cfg, err = loadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 初始化数据库
db, err = initDB(cfg.Database)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// 自动迁移
if err := db.AutoMigrate(&models.User{}, &models.Task{}); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
}
func main() {
// 创建WebSocket Hub
hub := websocket.NewHub()
go hub.Run()
// 设置Gin模式
gin.SetMode(gin.ReleaseMode)
// 创建路由
router := setupRouter(hub)
// 创建HTTP服务器
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: router,
}
// 启动服务器
go func() {
log.Printf("Starting server on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// 优雅关闭
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
func loadConfig() (*config.Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
// 从环境变量读取
viper.AutomaticEnv()
// 设置默认值
viper.SetDefault("server.port", "8080")
viper.SetDefault("server.host", "localhost")
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", "3306")
viper.SetDefault("database.username", "root")
viper.SetDefault("database.password", "")
viper.SetDefault("database.database", "taskmanager")
viper.SetDefault("jwt.secret", "default-secret-change-this")
viper.SetDefault("jwt.expire_hours", 24)
if err := viper.ReadInConfig(); err != nil {
log.Printf("Error reading config file: %v", err)
}
var cfg config.Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func initDB(cfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
log.Println("Database connected successfully")
return db, nil
}
func setupRouter(hub *websocket.Hub) *gin.Engine {
r := gin.Default()
// 中间件
r.Use(middleware.CORSMiddleware())
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 公开路由
public := r.Group("/api/v1")
{
public.POST("/register", handlers.Register)
public.POST("/login", handlers.Login)
}
// 受保护路由
protected := r.Group("/api/v1")
protected.Use(middleware.AuthRequired())
{
protected.GET("/profile", handlers.GetProfile)
protected.PUT("/profile", handlers.UpdateProfile)
taskHandler := handlers.NewTaskHandler(db, hub)
protected.GET("/tasks", taskHandler.GetTasks)
protected.POST("/tasks", taskHandler.CreateTask)
protected.GET("/tasks/:id", taskHandler.GetTask)
protected.PUT("/tasks/:id", taskHandler.UpdateTask)
protected.DELETE("/tasks/:id", taskHandler.DeleteTask)
}
// WebSocket路由
r.GET("/ws", func(c *gin.Context) {
websocket.HandleWebSocket(c, hub)
})
return r
}
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { useTaskStore } from './stores/tasks'
const app = createApp(App)
const pinia = createPinia()
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
// 初始化WebSocket连接
const authStore = useAuthStore()
const taskStore = useTaskStore()
if (authStore.isAuthenticated) {
taskStore.initializeWebSocket(authStore.token)
}
// 监听登录状态变化
authStore.$subscribe((mutation, state) => {
if (state.token) {
taskStore.initializeWebSocket(state.token)
} else {
taskStore.cleanupWebSocket()
}
})
app.mount('#app')
-- 创建数据库
CREATE DATABASE IF NOT EXISTS taskmanager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用数据库
USE taskmanager;
-- 创建用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_username (username),
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建任务表
CREATE TABLE IF NOT EXISTS tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending',
priority ENUM('low', 'medium', 'high') DEFAULT 'medium',
due_date DATETIME NULL DEFAULT NULL,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 插入测试用户(密码: 123456)
-- 注意:生产环境请删除此用户
INSERT IGNORE INTO users (username, email, password) VALUES
('testuser', 'test@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
#!/bin/bash
# 部署脚本
set -e
echo "🚀 开始部署任务管理系统..."
# 检查Docker和Docker Compose
echo "📋 检查环境..."
if ! command -v docker &> /dev/null; then
echo "❌ Docker未安装,请先安装Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose未安装,请先安装Docker Compose"
exit 1
fi
# 读取环境变量
if [ -f .env ]; then
export $(cat .env | xargs)
echo "✅ 环境变量已加载"
else
echo "⚠️ 未找到.env文件,使用默认配置"
fi
# 停止现有服务
echo "🛑 停止现有服务..."
docker-compose down --remove-orphans || true
# 拉取最新镜像
echo "📥 拉取最新镜像..."
docker-compose pull
# 构建镜像
echo "🏗️ 构建镜像..."
docker-compose build
# 启动服务
echo "🚀 启动服务..."
docker-compose up -d
# 等待服务启动
echo "⏳ 等待服务启动..."
sleep 30
# 检查服务状态
echo "📊 检查服务状态..."
docker-compose ps
# 健康检查
echo "🏥 执行健康检查..."
if curl -f http://localhost:${SERVER_PORT:-8080}/health > /dev/null 2>&1; then
echo "✅ 后端服务健康"
else
echo "❌ 后端服务异常"
docker-compose logs backend
fi
if curl -f http://localhost:80/ > /dev/null 2>&1; then
echo "✅ 前端服务健康"
else
echo "❌ 前端服务异常"
docker-compose logs frontend
fi
echo "🎉 部署完成!"
echo "📱 访问地址: http://localhost"
echo "🔧 API地址: http://localhost:${SERVER_PORT:-8080}"
echo "📊 查看日志: docker-compose logs -f"
echo "🛑 停止服务: docker-compose down"
# Task Manager - 任务管理系统
一个现代化的全栈任务管理系统,使用Golang + Vue 3构建。
## ✨ 特性
- 🚀 现代化的前后端分离架构
- 🔐 JWT身份认证
- 📱 响应式设计,支持移动端
- ⚡ 实时通知(WebSocket)
- 🐳 Docker容器化部署
- 🎨 优雅的UI设计(Element Plus)
- 📝 完整的CRUD操作
- 🔍 任务状态跟踪
- 📊 优先级管理
## 🛠️ 技术栈
### 后端
- **Golang** 1.23+
- **Gin** - Web框架
- **GORM** - ORM框架
- **JWT** - 身份认证
- **WebSocket** - 实时通信
- **MySQL** - 数据库
### 前端
- **Vue 3** - 渐进式框架
- **Vite** - 构建工具
- **Pinia** - 状态管理
- **Vue Router 4** - 路由管理
- **Element Plus** - UI组件库
- **Axios** - HTTP客户端
- **Socket.io** - WebSocket客户端
## 📦 项目结构
```
task-manager/
├── backend/ # 后端代码
│ ├── cmd/ # 命令入口
│ ├── internal/ # 内部包
│ │ ├── auth/ # 认证相关
│ │ ├── handlers/# 请求处理器
│ │ ├── middleware/# 中间件
│ │ ├── models/ # 数据模型
│ │ └── websocket/# WebSocket
│ ├── pkg/ # 公共包
│ └── Dockerfile # Docker配置
├── frontend/ # 前端代码
│ ├── src/
│ │ ├── components/# 组件
│ │ ├── views/ # 页面视图
│ │ ├── stores/ # 状态管理
│ │ ├── services/# 服务封装
│ │ └── utils/ # 工具函数
│ └── Dockerfile # Docker配置
└── docker-compose.yml # 容器编排
```
## 🚀 快速开始
### 环境要求
- Go 1.23+
- Node.js 18+
- MySQL 8.0
- Docker & Docker Compose(可选)
### 本地开发
1. **克隆项目**
```bash
git clone https://github.com/yourusername/task-manager.git
cd task-manager
```
2. **配置环境**
```bash
cp .env.example .env
# 编辑 .env 文件
```
3. **启动后端**
```bash
cd backend
go mod download
go run cmd/server/main.go
```
4. **启动前端**
```bash
cd frontend
npm install
npm run dev
```
5. **访问应用**
- 前端: http://localhost:3000
- 后端: http://localhost:8080
### Docker部署
```bash
# 启动所有服务
docker-compose up -d
# 访问应用
# 前端: http://localhost
# API: http://localhost:8080
```
## 📖 API文档
### 认证相关
#### 用户注册
```http
POST /api/v1/register
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123"
}
```
#### 用户登录
```http
POST /api/v1/login
Content-Type: application/json
{
"username": "testuser",
"password": "password123"
}
```
### 任务管理
#### 获取任务列表
```http
GET /api/v1/tasks
Authorization: Bearer {token}
```
#### 创建任务
```http
POST /api/v1/tasks
Authorization: Bearer {token}
Content-Type: application/json
{
"title": "完成项目文档",
"description": "编写API文档",
"priority": "high"
}
```
#### 更新任务
```http
PUT /api/v1/tasks/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"title": "完成项目文档",
"status": "in_progress"
}
```
#### 删除任务
```http
DELETE /api/v1/tasks/{id}
Authorization: Bearer {token}
```
## 🔧 配置说明
### 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| DB_HOST | 数据库主机 | localhost |
| DB_PORT | 数据库端口 | 3306 |
| DB_USER | 数据库用户 | taskuser |
| DB_PASSWORD | 数据库密码 | - |
| DB_NAME | 数据库名 | taskmanager |
| JWT_SECRET | JWT密钥 | - |
| JWT_EXPIRE_HOURS | Token过期时间 | 24 |
| SERVER_PORT | 服务器端口 | 8080 |
## 🧪 测试
### 后端测试
```bash
cd backend
go test ./...
```
### 前端测试
```bash
cd frontend
npm run test
```
## 📱 功能演示
### 用户功能
- ✅ 用户注册和登录
- ✅ JWT身份认证
- ✅ 个人资料管理
### 任务功能
- ✅ 创建任务
- ✅ 编辑任务
- ✅ 删除任务
- ✅ 任务状态管理(待处理/进行中/已完成)
- ✅ 任务优先级设置
- ✅ 实时通知
## 🚀 部署
### 使用脚本部署
```bash
chmod +x deploy.sh
./deploy.sh
```
### 手动部署
```bash
# 构建和启动
docker-compose up -d
# 查看状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
## 🔒 安全建议
1. 使用强密码
2. 配置HTTPS
3. 定期更新依赖
4. 使用防火墙
5. 定期备份数据
6. 监控服务器状态
## 📝 许可证
MIT License
## 🤝 贡献
欢迎提交Issue和Pull Request!
## 📧 联系方式
- Email: your-email@example.com
- GitHub: [@yourusername](https://github.com/yourusername)
---
⭐ 如果这个项目对你有帮助,请给个Star!
完整的项目源码已包含在本教程中,您可以: