feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码) - 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据 - 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离 - 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记 - 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404 - 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
+149
-36
@@ -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(¬e, id).Error
|
||||
@@ -201,7 +203,17 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
||||
return ¬e, 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(¬e, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¬e, nil
|
||||
}
|
||||
|
||||
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复,不校验归属)
|
||||
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
|
||||
var note model.Note
|
||||
err := r.db.Unscoped().First(¬e, id).Error
|
||||
@@ -211,6 +223,16 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
|
||||
return ¬e, 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(¬es).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
|
||||
|
||||
Reference in New Issue
Block a user