package web import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "strconv" "time" "xiaji-go/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type WebServer struct { 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, 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() { gin.SetMode(gin.ReleaseMode) r := gin.Default() r.LoadHTMLGlob("templates/*") // 公开路由(无需登录) 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 { fmt.Printf("❌ Web服务启动失败: %v\n", err) } } 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) { username, _ := c.Cookie("xiaji_user") c.HTML(http.StatusOK, "index.html", gin.H{"username": username}) } func (s *WebServer) handleRecords(c *gin.Context) { var items []models.Transaction s.db.Where("is_deleted = ?", false).Order("id desc").Limit(50).Find(&items) type txResponse struct { ID uint `json:"id"` UserID int64 `json:"user_id"` Amount float64 `json:"amount"` Category string `json:"category"` Note string `json:"note"` Date string `json:"date"` } resp := make([]txResponse, len(items)) for i, item := range items { resp[i] = txResponse{ ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date, } } c.JSON(http.StatusOK, resp) } func (s *WebServer) handleDelete(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) return } result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"}) return } c.JSON(http.StatusOK, gin.H{"status": "success"}) } 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", fmt.Sprintf("attachment; filename=%s", filename)) // BOM for Excel c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) c.Writer.WriteString("ID,日期,分类,金额(元),备注\n") for _, item := range items { line := fmt.Sprintf("%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.Date, item.Category, item.AmountYuan(), item.Note) c.Writer.WriteString(line) } }