Files
ops-assistant/internal/web/server.go
openclaw 52b0d742a7 feat: Web后台登录认证
- 新增登录页面 (templates/login.html)
- HMAC-SHA256 cookie 认证中间件
- 所有页面和API需登录访问
- /health 保持公开
- 首页右上角退出按钮
- 默认账号 admin/admin123
- Cookie 有效期7天
- 版本升级至 v1.1.0
2026-02-16 16:44:42 +08:00

209 lines
5.4 KiB
Go

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)
}
}