feat: 添加用户注册登录功能,实现数据和配置的用户隔离
- 新增 users 和 user_settings 表,repositories/analyses/review_notes 添加 user_id 列 - 实现基于邮箱+密码的注册登录,密码使用 bcrypt 哈希 - 使用 gin-contrib/sessions cookie-based session 管理 - 所有仓库、分析记录、review notes 按用户隔离 - 用户设置(LLM 配置、review 参数、缓存配置)独立存储 - 新增登录/注册页面,导航栏显示用户邮箱和退出按钮 - 前端 fetch 请求统一添加 credentials: 'same-origin' - 支持 SESSION_SECRET 环境变量配置会话密钥
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *sql.DB) *AuthHandler {
|
||||
return &AuthHandler{db: db}
|
||||
}
|
||||
|
||||
// Login renders the login page.
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/login.html", gin.H{})
|
||||
}
|
||||
|
||||
// Register renders the registration page.
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/register.html", gin.H{})
|
||||
}
|
||||
|
||||
// HandleLogin processes POST /api/auth/login.
|
||||
func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱和密码为必填项"})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
|
||||
var user struct {
|
||||
ID int64
|
||||
PasswordHash string
|
||||
}
|
||||
err := h.db.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, req.Email).
|
||||
Scan(&user.ID, &user.PasswordHash)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set session
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", user.ID)
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "会话保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegister processes POST /api/auth/register.
|
||||
func (h *AuthHandler) HandleRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱和密码为必填项"})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
|
||||
// Validate email format (basic check)
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if len(req.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码长度至少为 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
var exists int
|
||||
h.db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = ?`, req.Email).Scan(&exists)
|
||||
if exists > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Insert user
|
||||
now := time.Now().UTC()
|
||||
result, err := h.db.Exec(`INSERT INTO users (email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
req.Email, string(hash), now, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := result.LastInsertId()
|
||||
|
||||
// Seed default settings for the user
|
||||
for key, val := range defaultUserSettings {
|
||||
h.db.Exec(`INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, userID, key, val)
|
||||
}
|
||||
|
||||
// Auto-login: set session
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", userID)
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "会话保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleLogout processes POST /api/auth/logout.
|
||||
func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
// defaultUserSettings mirrors models.DefaultSettings for seeding new users.
|
||||
var defaultUserSettings = map[string]string{
|
||||
"llm.endpoint": "https://api.openai.com/v1",
|
||||
"llm.api_key": "",
|
||||
"llm.model": "gpt-4o",
|
||||
"review.top_n": "20",
|
||||
"review.concurrency": "5",
|
||||
"cache.max_age_days": "7",
|
||||
"cache.max_size_mb": "5000",
|
||||
}
|
||||
Reference in New Issue
Block a user