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
+149 -36
View File
@@ -35,7 +35,7 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}); err != nil {
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}, &model.User{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
@@ -138,8 +138,8 @@ func (r *NoteRepository) rebuildAllFTS() error {
}
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录)
func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 返回匹配的笔记列表(排除已删除与目录),并按 userID 做数据隔离
func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
@@ -152,8 +152,9 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil {
if err := r.db.Raw(countQuery, keyword, userID).Scan(&total).Error; err != nil {
return nil, 0, err
}
@@ -167,10 +168,11 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, pageSize, offset,
keyword, userID, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
@@ -180,7 +182,7 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
res := r.db.Select("UserID", "Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil {
return res.Error
}
@@ -191,7 +193,7 @@ func (r *NoteRepository) Create(note *model.Note) error {
return nil
}
// GetByID 根据 ID 获取笔记(排除已删除)
// GetByID 根据 ID 获取笔记(排除已删除,不校验归属
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
var note model.Note
err := r.db.First(&note, id).Error
@@ -201,7 +203,17 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
return &note, nil
}
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复
// GetByIDScoped 根据 ID 获取笔记并校验归属(多租户隔离,防止跨用户访问
func (r *NoteRepository) GetByIDScoped(userID, id uint) (*model.Note, error) {
var note model.Note
err := r.db.Where("user_id = ?", userID).First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复,不校验归属)
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
var note model.Note
err := r.db.Unscoped().First(&note, id).Error
@@ -211,6 +223,16 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
return &note, nil
}
// Scoped 返回带用户隔离的查询(供需要复用 db 的场景)
func (r *NoteRepository) Scoped(userID uint) *gorm.DB {
return r.db.Where("user_id = ?", userID)
}
// DB 暴露底层数据库连接(供其它仓库复用)
func (r *NoteRepository) DB() *gorm.DB {
return r.db
}
// Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error {
if err := r.db.Save(note).Error; err != nil {
@@ -251,6 +273,8 @@ func (r *NoteRepository) Delete(id uint) error {
// ListQuery 列表查询参数
type ListQuery struct {
UserID uint
LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示)
Page int
PageSize int
Category string
@@ -267,6 +291,14 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
query := r.db.Model(&model.Note{})
if q.LoggedIn {
// 登录用户:数据隔离,只看自己的
query = query.Where("user_id = ?", q.UserID)
} else {
// 游客:只看所有公开无密码的笔记(跨租户展示)
query = query.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
if q.Category != "" {
query = query.Where("category = ?", q.Category)
}
@@ -297,30 +329,40 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
return items, total, err
}
// GetAllTree 获取所有笔记的树形结构(含分享信息)
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
// GetAllTree 获取所有笔记的树形结构(含分享信息,当前用户
func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
// GetPublicTree 获取公开可见的树形结构(多租户下:登录用户看自己全部笔记;游客看所有公开笔记
func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
q := r.db.Model(&model.Note{})
if loggedIn {
// 登录用户:展示自己名下所有未删除笔记(含私有,用于其个人笔记空间)
q = q.Where("user_id = ?", userID)
} else {
// 游客:仅展示跨租户的全部公开无密码笔记
q = q.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
err := q.
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetByParentID 获取指定父目录下的所有项目(排除已删除)
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
// GetByParentID 获取指定父目录下的所有项目(排除已删除,按用户隔离
func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("parent_id = ?", parentID).
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
@@ -328,10 +370,10 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err
return items, err
}
// GetChildrenCount 获取子项数量(排除已删除)
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
// GetChildrenCount 获取子项数量(排除已删除,按用户隔离
func (r *NoteRepository) GetChildrenCount(userID, parentID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
err := r.db.Model(&model.Note{}).Where("user_id = ?", userID).Where("parent_id = ?", parentID).Count(&count).Error
return count, err
}
@@ -367,10 +409,11 @@ func (r *NoteRepository) collectDescendants(id uint) []uint {
return ids
}
// ListTrash 回收站列表(只含已软删除项)
func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) {
// ListTrash 回收站列表(只含已软删除项,按用户隔离
func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}).
Where("user_id = ?", userID).
Where("deleted_at IS NOT NULL").
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("deleted_at DESC").
@@ -445,13 +488,15 @@ func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
return ids
}
// Search 搜索笔记(按标题和内容)
func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// Search 搜索笔记(按标题和内容,按用户隔离
func (r *NoteRepository) Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
query := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
@@ -467,20 +512,47 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
return items, total, err
}
// GetCategories 获取所有分类(排除已删除和目录
func (r *NoteRepository) GetCategories() ([]string, error) {
// SearchPublic 游客搜索(仅搜索所有公开无密码笔记
func (r *NoteRepository) SearchPublic(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("(password IS NULL OR password = '')").
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// GetCategories 获取所有分类(排除已删除和目录,按用户隔离)
func (r *NoteRepository) GetCategories(userID uint) ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetTags 获取所有标签(排除已删除和目录)
func (r *NoteRepository) GetTags() ([]string, error) {
// GetTags 获取所有标签(排除已删除和目录,按用户隔离
func (r *NoteRepository) GetTags(userID uint) ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
@@ -501,11 +573,49 @@ func (r *NoteRepository) GetTags() ([]string, error) {
// ─────────────── 标签管理 ───────────────
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除。
// GetPublicCategories 游客获取所有公开笔记的分类
func (r *NoteRepository) GetPublicCategories() ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetPublicTags 游客获取所有公开笔记的标签
func (r *NoteRepository) GetPublicTags() ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
return dedupeTags(tagsJSON), nil
}
// dedupeTags 去重并拼接标签 JSON 为字符串切片
func dedupeTags(tagsJSON []string) []string {
seen := make(map[string]bool)
var result []string
for _, t := range tagsJSON {
if !seen[t] {
seen[t] = true
result = append(result, t)
}
}
return result
}
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除(按用户隔离)。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
if err := r.db.Where("user_id = ?", userID).
Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
@@ -547,18 +657,19 @@ func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计)
func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) {
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计,按用户隔离
func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱)
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱,按用户隔离
// 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight() ([]struct {
func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
@@ -569,18 +680,20 @@ func (r *NoteRepository) GetAllContentLight() ([]struct {
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在)
func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) {
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在,按用户隔离
func (r *NoteRepository) GetByIDs(userID uint, ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("id IN ?", ids).
Select("id, title").Find(&items).Error
return items, err