diff --git a/Makefile b/Makefile index 37a0934..27c753f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ APP_NAME := xiaji-go -VERSION := 1.0.0 +VERSION := 1.1.0 GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \ diff --git a/internal/web/server.go b/internal/web/server.go index 57f5ffe..9d82460 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,9 +1,13 @@ package web import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "fmt" "net/http" "strconv" + "time" "xiaji-go/models" @@ -12,14 +16,55 @@ import ( ) type WebServer struct { - db *gorm.DB - port int - username string - password string + db *gorm.DB + port int + username string + password string + secretKey string } func NewWebServer(db *gorm.DB, port int, username, password string) *WebServer { - return &WebServer{db: db, port: port, username: username, password: password} + return &WebServer{ + db: db, + port: port, + username: username, + password: password, + secretKey: "xiaji-go-session-" + password, // 简单派生 + } +} + +// generateToken 生成登录 token +func (s *WebServer) generateToken(username string) string { + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(username)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// validateToken 验证 token +func (s *WebServer) validateToken(username, token string) bool { + expected := s.generateToken(username) + return hmac.Equal([]byte(expected), []byte(token)) +} + +// authRequired 登录认证中间件 +func (s *WebServer) authRequired() gin.HandlerFunc { + return func(c *gin.Context) { + username, _ := c.Cookie("xiaji_user") + token, _ := c.Cookie("xiaji_token") + + if username == "" || token == "" || !s.validateToken(username, token) { + // 判断是 API 请求还是页面请求 + path := c.Request.URL.Path + if len(path) >= 4 && path[:4] == "/api" || c.Request.Method == "POST" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + } else { + c.Redirect(http.StatusFound, "/login") + } + c.Abort() + return + } + c.Next() + } } func (s *WebServer) Start() { @@ -27,17 +72,26 @@ func (s *WebServer) Start() { r := gin.Default() r.LoadHTMLGlob("templates/*") - // 页面 - r.GET("/", s.handleIndex) - r.GET("/api/records", s.handleRecords) - r.POST("/delete/:id", s.handleDelete) - r.GET("/export", s.handleExport) + // 公开路由(无需登录) + r.GET("/login", s.handleLoginPage) + r.POST("/login", s.handleLogin) + r.GET("/logout", s.handleLogout) - // 健康检查 + // 健康检查(公开) r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + // 需要登录的路由 + auth := r.Group("/") + auth.Use(s.authRequired()) + { + auth.GET("/", s.handleIndex) + auth.GET("/api/records", s.handleRecords) + auth.POST("/delete/:id", s.handleDelete) + auth.GET("/export", s.handleExport) + } + logAddr := fmt.Sprintf(":%d", s.port) fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr) if err := r.Run(logAddr); err != nil { @@ -45,8 +99,43 @@ func (s *WebServer) Start() { } } +func (s *WebServer) handleLoginPage(c *gin.Context) { + // 已登录则跳转首页 + username, _ := c.Cookie("xiaji_user") + token, _ := c.Cookie("xiaji_token") + if username != "" && token != "" && s.validateToken(username, token) { + c.Redirect(http.StatusFound, "/") + return + } + c.HTML(http.StatusOK, "login.html", gin.H{"error": ""}) +} + +func (s *WebServer) handleLogin(c *gin.Context) { + username := c.PostForm("username") + password := c.PostForm("password") + + if username == s.username && password == s.password { + token := s.generateToken(username) + maxAge := 7 * 24 * 3600 // 7天 + + c.SetCookie("xiaji_user", username, maxAge, "/", "", false, true) + c.SetCookie("xiaji_token", token, maxAge, "/", "", false, true) + c.Redirect(http.StatusFound, "/") + return + } + + c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"}) +} + +func (s *WebServer) handleLogout(c *gin.Context) { + c.SetCookie("xiaji_user", "", -1, "/", "", false, true) + c.SetCookie("xiaji_token", "", -1, "/", "", false, true) + c.Redirect(http.StatusFound, "/login") +} + func (s *WebServer) handleIndex(c *gin.Context) { - c.HTML(http.StatusOK, "index.html", nil) + username, _ := c.Cookie("xiaji_user") + c.HTML(http.StatusOK, "index.html", gin.H{"username": username}) } func (s *WebServer) handleRecords(c *gin.Context) { @@ -102,8 +191,11 @@ func (s *WebServer) handleExport(c *gin.Context) { var items []models.Transaction s.db.Where("is_deleted = ?", false).Order("date asc, id asc").Find(&items) + now := time.Now().Format("20060102") + filename := fmt.Sprintf("xiaji_%s.csv", now) + c.Header("Content-Type", "text/csv; charset=utf-8") - c.Header("Content-Disposition", "attachment; filename=transactions.csv") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) // BOM for Excel c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) diff --git a/templates/index.html b/templates/index.html index 09f6f8f..94dceda 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,6 +11,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans- .header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; text-align: center; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); } .header h1 { font-size: 24px; margin-bottom: 4px; } .header .subtitle { font-size: 13px; opacity: .85; } +.header { position: relative; } +.logout-btn { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,.2); color: #fff; text-decoration: none; padding: 5px 14px; border-radius: 6px; font-size: 13px; transition: background .2s; } +.logout-btn:hover { background: rgba(255,255,255,.35); } .stats { display: flex; gap: 10px; padding: 15px; overflow-x: auto; } .stat-card { flex: 1; min-width: 120px; background: #fff; border-radius: 12px; padding: 15px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.06); } @@ -67,6 +70,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-