feat: 多租户账号体系 + 前台收藏按钮

- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
Your Name
2026-08-11 12:58:12 +08:00
parent 2196189791
commit d9793300f9
16 changed files with 1216 additions and 278 deletions
+67 -16
View File
@@ -10,32 +10,38 @@ import (
"github.com/gin-gonic/gin"
)
// ---- 服务端会话存储(内存) ----
// 用随机 token 代替之前的固定字符串 cookie,登出/过期即失效。
// ---- 服务端会话存储(内存)----
// 用随机 token 代替固定字符串 cookie,登出/过期即失效。
// 会话绑定到具体用户 ID(多租户)。
// session 会话数据
type session struct {
userID uint
expiry time.Time
}
var (
sessions = make(map[string]time.Time) // token -> 过期时间
sessions = make(map[string]session) // token -> 会话
sessionsMu sync.RWMutex
)
const (
CookieName = "admin_token"
SessionTTL = 7 * 24 * time.Hour // 会话有效期 7 天
CookieMaxAge = 7 * 24 * 3600 // cookie 有效期(秒)
sessionCleanT = 10 // 清理过期会话的间隔(分钟)
CookieName = "note_token" // 会话 cookie
SessionTTL = 7 * 24 * time.Hour
CookieMaxAge = 7 * 24 * 3600
sessionCleanT = 10
)
// NewSessionToken 生成一个新的会话 token 并注册
func NewSessionToken() (string, time.Time) {
// NewSession 创建并绑定一个用户会话
func NewSession(userID uint) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// 兜底:用时间戳+纳秒(理论上不会发生)
b = []byte(time.Now().Format("20060102150405.000000000"))
}
token := hex.EncodeToString(b)
exp := time.Now().Add(SessionTTL)
sessionsMu.Lock()
sessions[token] = exp
sessions[token] = session{userID: userID, expiry: exp}
sessionsMu.Unlock()
go cleanExpiredSessions()
return token, exp
@@ -54,30 +60,58 @@ func IsValidSession(token string) bool {
return false
}
sessionsMu.RLock()
exp, ok := sessions[token]
s, ok := sessions[token]
sessionsMu.RUnlock()
if !ok {
return false
}
if time.Now().After(exp) {
if time.Now().After(s.expiry) {
RevokeSession(token)
return false
}
return true
}
// SessionUserID 获取 token 对应的用户 ID(无效返回 0)
func SessionUserID(token string) uint {
if token == "" {
return 0
}
sessionsMu.RLock()
s, ok := sessions[token]
sessionsMu.RUnlock()
if !ok {
return 0
}
if time.Now().After(s.expiry) {
RevokeSession(token)
return 0
}
return s.userID
}
// cleanExpiredSessions 定期清理过期会话,防止内存泄漏
func cleanExpiredSessions() {
sessionsMu.Lock()
defer sessionsMu.Unlock()
for token, exp := range sessions {
if time.Now().After(exp) {
for token, s := range sessions {
if time.Now().After(s.expiry) {
delete(sessions, token)
}
}
}
// AuthRequired 管理接口认证中间件
// CurrentUser 解析当前登录用户 ID 并写入 context(可为 0 表示游客)。
// 供公开路由/混合路由使用。
func CurrentUser() gin.HandlerFunc {
return func(c *gin.Context) {
uid := SessionUserID(GetAuthToken(c))
c.Set("user_id", uid)
c.Next()
}
}
// AuthRequired 认证中间件:必须登录,否则返回 401。
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(CookieName)
@@ -86,10 +120,27 @@ func AuthRequired() gin.HandlerFunc {
c.Abort()
return
}
uid := SessionUserID(token)
c.Set("user_id", uid)
c.Next()
}
}
// GetUserID 从 context 取当前用户 ID(游客为 0)
func GetUserID(c *gin.Context) uint {
if v, ok := c.Get("user_id"); ok {
if uid, ok2 := v.(uint); ok2 {
return uid
}
}
return 0
}
// IsLoggedIn 判断当前请求是否已登录
func IsLoggedIn(c *gin.Context) bool {
return GetUserID(c) != 0
}
// SetAuthCookie 设置认证 cookieSameSite=Lax 防 CSRFhttps 下应启用 Secure
func SetAuthCookie(c *gin.Context, token string, secure bool) {
c.SetSameSite(http.SameSiteLaxMode)