diff --git a/commands/command.go b/commands/command.go
index 2852d990..b81c6bbc 100644
--- a/commands/command.go
+++ b/commands/command.go
@@ -237,10 +237,23 @@ func RegisterCommand() {
} else if len(os.Args) >= 2 && os.Args[1] == "update" {
Update()
os.Exit(0)
+ } else if len(os.Args) >= 2 && os.Args[1] == "reindex" {
+ ResolveCommand(os.Args[2:])
+ fmt.Println("开始全量重建倒排索引...")
+ if err := models.RebuildAllIndexes(); err != nil {
+ fmt.Println("倒排索引重建失败,索引表可能处于部分重建状态,请重新执行 reindex:", err)
+ os.Exit(1)
+ }
+ fmt.Println("倒排索引重建完成")
+ os.Exit(0)
}
}
+func shouldInitializeMissingIndexes() bool {
+ return !(len(os.Args) >= 2 && os.Args[1] == "reindex")
+}
+
// 注册模板函数
func RegisterFunction() {
err := web.AddFuncMap("config", models.GetOptionValue)
@@ -425,7 +438,9 @@ func ResolveCommand(args []string) {
RegisterCache()
RegisterModel()
RegisterLogger(conf.LogFile)
- models.InitializeMissingIndexes()
+ if shouldInitializeMissingIndexes() {
+ models.InitializeMissingIndexes()
+ }
ModifyPassword()
}
diff --git a/controllers/DocumentController.go b/controllers/DocumentController.go
index d60587ee..f29559ae 100644
--- a/controllers/DocumentController.go
+++ b/controllers/DocumentController.go
@@ -324,6 +324,33 @@ func Flatten(list []*models.DocumentTree, flattened *[]DocumentTreeFlatten) {
return
}
+func (c *DocumentController) resolveEditDocument(bookId int, id string) (*models.Document, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return nil, nil
+ }
+
+ doc := models.NewDocument()
+ if docId, err := strconv.Atoi(id); err == nil {
+ doc, err = doc.FromCacheById(docId)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ var err error
+ doc, err = doc.FromCacheByIdentify(id, bookId)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if doc == nil || doc.DocumentId <= 0 || doc.BookId != bookId {
+ return nil, orm.ErrNoRows
+ }
+
+ return doc, nil
+}
+
// 编辑文档
func (c *DocumentController) Edit() {
c.Prepare()
@@ -396,6 +423,21 @@ func (c *DocumentController) Edit() {
} else {
c.Data["UploadFileSize"] = "undefined"
}
+
+ selectedDocId := 0
+ if doc, err := c.resolveEditDocument(bookResult.BookId, c.Ctx.Input.Param(":id")); err != nil {
+ if err == orm.ErrNoRows || err == models.ErrDataNotExist {
+ c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.doc_not_exist"))
+ } else {
+ logs.Error("resolveEditDocument => ", err)
+ c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.system_error"))
+ }
+ return
+ } else if doc != nil {
+ selectedDocId = doc.DocumentId
+ }
+
+ c.Data["SelectedDocId"] = selectedDocId
}
// 创建一个文档
diff --git a/controllers/SearchController.go b/controllers/SearchController.go
index 8375dd3f..1d024dcd 100644
--- a/controllers/SearchController.go
+++ b/controllers/SearchController.go
@@ -1,9 +1,12 @@
package controllers
import (
+ "fmt"
+ "sort"
"strconv"
"strings"
+ "github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/conf"
@@ -55,6 +58,10 @@ type SearchV2RawResult struct {
// PerformSearchV2Raw 执行倒排索引搜索的底层函数,返回原始结果
func PerformSearchV2Raw(keyword string, pageIndex, pageSize int, memberId int) ([]*SearchV2RawResult, []string, int, error) {
+ pageIndex, pageSize = normalizeSearchPaging(pageIndex, pageSize)
+ offset := (pageIndex - 1) * pageSize
+ targetVisible := offset + pageSize
+
// 使用分词器对关键词进行分词
words := segmenter.Segment(keyword)
if len(words) == 0 {
@@ -62,119 +69,328 @@ func PerformSearchV2Raw(keyword string, pageIndex, pageSize int, memberId int) (
words = []string{keyword}
}
+ // 将原始关键词(小写)加入搜索词列表,确保能匹配索引中存储的完整词条
+ lowerKeyword := strings.ToLower(strings.TrimSpace(keyword))
+ if lowerKeyword != "" {
+ found := false
+ for _, w := range words {
+ if w == lowerKeyword {
+ found = true
+ break
+ }
+ }
+ if !found {
+ words = append(words, lowerKeyword)
+ }
+ }
+ words = normalizeSearchTerms(words)
+
// 使用倒排索引模型进行搜索
index := models.NewContentReverseIndex()
- results, totalCount, err := index.FindByWordsWithPagination(words, pageIndex, pageSize)
+ allResults, _, err := index.FindByWords(words)
if err != nil {
return nil, words, 0, err
}
- // 构建返回结果
- searchResults := make([]*SearchV2RawResult, 0)
- for _, result := range results {
- item := &SearchV2RawResult{
- ContentType: result.ContentType,
- ContentId: result.ContentId,
- Score: result.Score,
- WordCounts: result.WordCounts,
+ if len(allResults) == 0 {
+ return nil, words, 0, nil
+ }
+
+ processedResults := make([]*SearchV2RawResult, 0, targetVisible)
+ totalCount := 0
+ const candidateBatchSize = 200
+ for start := 0; start < len(allResults); start += candidateBatchSize {
+ end := start + candidateBatchSize
+ if end > len(allResults) {
+ end = len(allResults)
+ }
+
+ batchResults, err := buildSearchResults(allResults[start:end], words, lowerKeyword, memberId)
+ if err != nil {
+ return nil, words, 0, err
}
- // 根据内容类型获取详细信息
+ totalCount += len(batchResults)
+ processedResults = append(processedResults, batchResults...)
+ sort.Slice(processedResults, func(i, j int) bool {
+ return compareRawSearchResults(processedResults[i], processedResults[j])
+ })
+ if len(processedResults) > targetVisible {
+ processedResults = processedResults[:targetVisible]
+ }
+ }
+
+ end := offset + pageSize
+ if offset > totalCount {
+ offset = totalCount
+ }
+ if end > totalCount {
+ end = totalCount
+ }
+ if offset >= len(processedResults) || offset >= end {
+ return nil, words, totalCount, nil
+ }
+ if end > len(processedResults) {
+ end = len(processedResults)
+ }
+
+ return processedResults[offset:end], words, totalCount, nil
+}
+
+func normalizeSearchTerms(words []string) []string {
+ result := make([]string, 0, len(words))
+ seen := make(map[string]struct{}, len(words))
+ for _, word := range words {
+ word = strings.TrimSpace(word)
+ if word == "" {
+ continue
+ }
+ if _, ok := seen[word]; ok {
+ continue
+ }
+ seen[word] = struct{}{}
+ result = append(result, word)
+ }
+ return result
+}
+
+func normalizeSearchPaging(pageIndex, pageSize int) (int, int) {
+ if pageIndex <= 0 {
+ pageIndex = 1
+ }
+ if pageSize <= 0 {
+ pageSize = conf.PageSize
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ }
+ return pageIndex, pageSize
+}
+
+func buildSearchResults(results []*models.ContentReverseIndexResult, words []string, lowerKeyword string, memberId int) ([]*SearchV2RawResult, error) {
+ // 收集需要批量查询的ID
+ docIds := make([]int, 0)
+ blogIds := make([]int, 0)
+ for _, result := range results {
if result.ContentType == 1 {
- // Document类型
- doc, err := models.NewDocument().Find(result.ContentId)
- if err == nil {
- // 检查文档权限
- book, bookErr := models.NewBook().Find(doc.BookId)
- if bookErr != nil {
- continue
- }
+ docIds = append(docIds, result.ContentId)
+ } else if result.ContentType == 2 {
+ blogIds = append(blogIds, result.ContentId)
+ }
+ }
- item.SearchType = "document"
- item.DocumentId = doc.DocumentId
- item.DocumentName = doc.DocumentName
- item.BookId = doc.BookId
- item.BookName = book.BookName
- item.Identify = doc.Identify
- item.BookIdentify = book.Identify
- item.CreateTime = doc.CreateTime
- item.ModifyTime = doc.ModifyTime
- item.Content = doc.Release
-
- // 获取作者信息
- if doc.MemberId > 0 {
- member, _ := models.NewMember().Find(doc.MemberId, "real_name", "account")
- if member != nil {
- if member.RealName != "" {
- item.Author = member.RealName
- } else {
- item.Author = member.Account
- }
- }
- }
+ // 批量加载 Document 和 Blog
+ docMap, err := batchLoadByIds(models.NewDocument().TableNameWithPrefix(), "document_id__in", docIds, func(d *models.Document) int { return d.DocumentId })
+ if err != nil {
+ return nil, err
+ }
+ blogMap, err := batchLoadByIds(models.NewBlog().TableNameWithPrefix(), "blog_id__in", blogIds, func(b *models.Blog) int { return b.BlogId })
+ if err != nil {
+ return nil, err
+ }
- // 提取描述
- description := doc.Release
- if description == "" {
- description = doc.Markdown
- }
- // 去除HTML标签
- description = utils.StripTags(description)
- if len([]rune(description)) > 100 {
- description = string([]rune(description)[:100]) + "..."
- }
- item.Description = description
+ bookIds := make([]int, 0)
+ memberIds := make([]int, 0)
+ bookIdSet := make(map[int]bool)
+ memberIdSet := make(map[int]bool)
+ for _, doc := range docMap {
+ if doc.BookId > 0 && !bookIdSet[doc.BookId] {
+ bookIds = append(bookIds, doc.BookId)
+ bookIdSet[doc.BookId] = true
+ }
+ if doc.MemberId > 0 && !memberIdSet[doc.MemberId] {
+ memberIds = append(memberIds, doc.MemberId)
+ memberIdSet[doc.MemberId] = true
+ }
+ }
+ for _, blog := range blogMap {
+ if blog.MemberId > 0 && !memberIdSet[blog.MemberId] {
+ memberIds = append(memberIds, blog.MemberId)
+ memberIdSet[blog.MemberId] = true
+ }
+ }
+
+ bookMap, err := batchLoadByIds(models.NewBook().TableNameWithPrefix(), "book_id__in", bookIds, func(b *models.Book) int { return b.BookId })
+ if err != nil {
+ return nil, err
+ }
+ filterInaccessibleBooks(bookMap, memberId)
+
+ memberMap, err := batchLoadByIds(models.NewMember().TableNameWithPrefix(), "member_id__in", memberIds, func(m *models.Member) int { return m.MemberId }, "member_id", "account", "real_name")
+ if err != nil {
+ return nil, err
+ }
+
+ searchResults := make([]*SearchV2RawResult, 0, len(results))
+ for _, result := range results {
+ item, ok := buildSingleSearchResult(result, words, lowerKeyword, docMap, blogMap, bookMap, memberMap)
+ if !ok {
+ continue
+ }
+ searchResults = append(searchResults, item)
+ }
- searchResults = append(searchResults, item)
+ sort.Slice(searchResults, func(i, j int) bool {
+ return compareRawSearchResults(searchResults[i], searchResults[j])
+ })
+
+ return searchResults, nil
+}
+
+func filterInaccessibleBooks(bookMap map[int]*models.Book, memberId int) {
+ for bid, book := range bookMap {
+ if book.Status == 1 {
+ delete(bookMap, bid)
+ }
+ }
+ if len(bookMap) == 0 {
+ return
+ }
+ if memberId > 0 {
+ privateBookIds := make([]int, 0)
+ for _, book := range bookMap {
+ if book.PrivatelyOwned == 1 {
+ privateBookIds = append(privateBookIds, book.BookId)
}
- } else if result.ContentType == 2 {
- // Blog类型
- blog, err := models.NewBlog().Find(result.ContentId)
- if err == nil {
- item.SearchType = "blog"
- item.BlogId = blog.BlogId
- item.BlogTitle = blog.BlogTitle
- item.DocumentId = blog.BlogId
- item.DocumentName = blog.BlogTitle
- item.BlogIdentify = blog.BlogIdentify
- item.Identify = blog.BlogIdentify
- item.BlogExcerpt = blog.BlogExcerpt
- item.CreateTime = blog.Created
- item.ModifyTime = blog.Modified
- item.Content = blog.BlogRelease
-
- // 获取作者信息
- if blog.MemberId > 0 {
- member, _ := models.NewMember().Find(blog.MemberId, "real_name", "account")
- if member != nil {
- if member.RealName != "" {
- item.Author = member.RealName
- } else {
- item.Author = member.Account
- }
- }
- }
+ }
+ if len(privateBookIds) == 0 {
+ return
+ }
+ roleMap := models.NewBook().FindRoleIdsByBookIds(privateBookIds, memberId)
+ for _, bid := range privateBookIds {
+ if _, ok := roleMap[bid]; !ok {
+ delete(bookMap, bid)
+ }
+ }
+ return
+ }
+ for _, book := range bookMap {
+ if book.PrivatelyOwned == 1 {
+ delete(bookMap, book.BookId)
+ }
+ }
+}
- // 提取描述
- description := blog.BlogExcerpt
- if description == "" {
- description = blog.BlogRelease
- if description == "" {
- description = blog.BlogContent
- }
- }
- description = utils.StripTags(description)
- if len([]rune(description)) > 100 {
- description = string([]rune(description)[:100]) + "..."
- }
- item.Description = description
+func buildSingleSearchResult(result *models.ContentReverseIndexResult, words []string, lowerKeyword string, docMap map[int]*models.Document, blogMap map[int]*models.Blog, bookMap map[int]*models.Book, memberMap map[int]*models.Member) (*SearchV2RawResult, bool) {
+ item := &SearchV2RawResult{
+ ContentType: result.ContentType,
+ ContentId: result.ContentId,
+ Score: result.Score,
+ WordCounts: result.WordCounts,
+ }
+
+ if result.ContentType == 1 {
+ doc, ok := docMap[result.ContentId]
+ if !ok {
+ return nil, false
+ }
+ book, ok := bookMap[doc.BookId]
+ if !ok {
+ return nil, false
+ }
- searchResults = append(searchResults, item)
+ item.SearchType = "document"
+ item.DocumentId = doc.DocumentId
+ item.DocumentName = doc.DocumentName
+ item.BookId = doc.BookId
+ item.BookName = book.BookName
+ item.Identify = doc.Identify
+ item.BookIdentify = book.Identify
+ item.CreateTime = doc.CreateTime
+ item.ModifyTime = doc.ModifyTime
+ item.Content = doc.Release
+ if member, ok := memberMap[doc.MemberId]; ok {
+ if member.RealName != "" {
+ item.Author = member.RealName
+ } else {
+ item.Author = member.Account
}
}
+
+ strippedContent := utils.StripTags(doc.Release)
+ if strippedContent == "" {
+ strippedContent = utils.StripTags(doc.Markdown)
+ }
+ item.Description = trimSearchDescription(strippedContent)
+ applySearchBoost(item, words, lowerKeyword, strings.ToLower(doc.DocumentName), strings.ToLower(strippedContent))
+ return item, true
}
- return searchResults, words, totalCount, nil
+ if result.ContentType != 2 {
+ return nil, false
+ }
+ blog, ok := blogMap[result.ContentId]
+ if !ok {
+ return nil, false
+ }
+
+ item.SearchType = "blog"
+ item.BlogId = blog.BlogId
+ item.BlogTitle = blog.BlogTitle
+ item.DocumentId = blog.BlogId
+ item.DocumentName = blog.BlogTitle
+ item.BlogIdentify = blog.BlogIdentify
+ item.Identify = blog.BlogIdentify
+ item.BlogExcerpt = blog.BlogExcerpt
+ item.CreateTime = blog.Created
+ item.ModifyTime = blog.Modified
+ item.Content = blog.BlogRelease
+ if member, ok := memberMap[blog.MemberId]; ok {
+ if member.RealName != "" {
+ item.Author = member.RealName
+ } else {
+ item.Author = member.Account
+ }
+ }
+
+ strippedContent := utils.StripTags(blog.BlogRelease)
+ if strippedContent == "" {
+ strippedContent = utils.StripTags(blog.BlogContent)
+ }
+ description := blog.BlogExcerpt
+ if description == "" {
+ description = strippedContent
+ } else {
+ description = utils.StripTags(description)
+ }
+ item.Description = trimSearchDescription(description)
+ applySearchBoost(item, words, lowerKeyword, strings.ToLower(blog.BlogTitle), strings.ToLower(strippedContent))
+ return item, true
+}
+
+func trimSearchDescription(description string) string {
+ if len([]rune(description)) > 100 {
+ return string([]rune(description)[:100]) + "..."
+ }
+ return description
+}
+
+func applySearchBoost(item *SearchV2RawResult, words []string, lowerKeyword, lowerTitle, lowerContent string) {
+ baseScore := item.Score
+ boost := 0.0
+ titleHits := 0
+ for _, word := range words {
+ if strings.Contains(lowerTitle, word) {
+ titleHits++
+ }
+ }
+ if titleHits > 0 {
+ boost += baseScore * 1.5 * float64(titleHits) / float64(len(words))
+ }
+ if lowerKeyword != "" && strings.Contains(lowerContent, lowerKeyword) {
+ boost += baseScore * 4.0
+ }
+ item.Score += boost
+}
+
+func compareRawSearchResults(left, right *SearchV2RawResult) bool {
+ if left.Score != right.Score {
+ return left.Score > right.Score
+ }
+ if left.ContentType != right.ContentType {
+ return left.ContentType < right.ContentType
+ }
+ return left.ContentId < right.ContentId
}
// performSearchV2 执行倒排索引搜索,返回 SearchV2Result 列表
@@ -469,3 +685,34 @@ func (c *SearchController) SearchV2() {
c.JsonResult(0, "OK", responseData)
}
+
+// batchLoadByIds 通用分片批量加载函数
+func batchLoadByIds[T any](tableName, filterField string, ids []int, getKey func(*T) int, fields ...string) (map[int]*T, error) {
+ result := make(map[int]*T)
+ if len(ids) == 0 {
+ return result, nil
+ }
+ const chunkSize = 500
+ for i := 0; i < len(ids); i += chunkSize {
+ end := i + chunkSize
+ if end > len(ids) {
+ end = len(ids)
+ }
+ var items []*T
+ o := orm.NewOrm()
+ var err error
+ qs := o.QueryTable(tableName).Filter(filterField, ids[i:end])
+ if len(fields) > 0 {
+ _, err = qs.All(&items, fields...)
+ } else {
+ _, err = qs.All(&items)
+ }
+ if err != nil {
+ return result, fmt.Errorf("批量加载 %s 失败: %w", tableName, err)
+ }
+ for _, item := range items {
+ result[getKey(item)] = item
+ }
+ }
+ return result, nil
+}
diff --git a/models/Blog.go b/models/Blog.go
index 142622cf..d6a45b1b 100644
--- a/models/Blog.go
+++ b/models/Blog.go
@@ -252,8 +252,9 @@ func (b *Blog) Save(cols ...string) error {
go func(blogId int, blogTitle, blogRelease, blogContent string) {
content := blogRelease
if content == "" {
- content = blogTitle + "\n" + blogContent
+ content = blogContent
}
+ content = blogTitle + "\n" + content
content = utils.StripTags(content)
if err := BuildIndexForBlog(blogId, content); err != nil {
logs.Error("构建Blog倒排索引失败 ->", blogId, err)
diff --git a/models/BookModel.go b/models/BookModel.go
index a815a06a..68694024 100644
--- a/models/BookModel.go
+++ b/models/BookModel.go
@@ -1102,3 +1102,60 @@ where mtr.book_id = ? and mtm.member_id = ? order by mtm.role_id asc limit 1;`
}
return conf.BookRole(roleId), nil
}
+
+// FindRoleIdsByBookIds 批量查询用户对多个项目的角色,返回 bookId -> BookRole 的映射
+// 未找到关系的项目不会出现在结果中
+func (book *Book) FindRoleIdsByBookIds(bookIds []int, memberId int) map[int]conf.BookRole {
+ result := make(map[int]conf.BookRole)
+ if len(bookIds) == 0 || memberId <= 0 {
+ return result
+ }
+ o := orm.NewOrm()
+
+ // 1. 批量查询 relationship 表(直接成员关系)
+ var rels []Relationship
+ _, err := NewRelationship().QueryTable().Filter("book_id__in", bookIds).Filter("member_id", memberId).All(&rels)
+ if err != nil && err != orm.ErrNoRows {
+ logs.Error("批量查询项目角色失败(relationship) ->", err)
+ }
+ for _, rel := range rels {
+ result[rel.BookId] = rel.RoleId
+ }
+
+ // 2. 对未命中的 bookId,批量查询 team 关系
+ remainIds := make([]int, 0)
+ for _, bid := range bookIds {
+ if _, ok := result[bid]; !ok {
+ remainIds = append(remainIds, bid)
+ }
+ }
+ if len(remainIds) > 0 {
+ // 构建 IN 占位符
+ placeholders := make([]string, len(remainIds))
+ args := make([]interface{}, len(remainIds))
+ for i, id := range remainIds {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+ sql := `SELECT mtr.book_id, MIN(mtm.role_id) AS role_id
+FROM ` + conf.GetDatabasePrefix() + `team_relationship AS mtr
+LEFT JOIN ` + conf.GetDatabasePrefix() + `team_member AS mtm ON mtm.team_id = mtr.team_id AND mtm.member_id = ?
+WHERE mtr.book_id IN (` + strings.Join(placeholders, ",") + `) AND mtm.member_id IS NOT NULL
+GROUP BY mtr.book_id`
+ allArgs := append([]interface{}{memberId}, args...)
+ type teamRoleRow struct {
+ BookId int
+ RoleId int
+ }
+ var rows []teamRoleRow
+ _, err := o.Raw(sql, allArgs...).QueryRows(&rows)
+ if err != nil && err != orm.ErrNoRows {
+ logs.Error("批量查询项目角色失败(team) ->", err)
+ }
+ for _, row := range rows {
+ result[row.BookId] = conf.BookRole(row.RoleId)
+ }
+ }
+
+ return result
+}
diff --git a/models/ContentReverseIndex.go b/models/ContentReverseIndex.go
index 0ce325a4..6ebc87af 100644
--- a/models/ContentReverseIndex.go
+++ b/models/ContentReverseIndex.go
@@ -6,9 +6,13 @@ import (
"errors"
"fmt"
"math"
+ "regexp"
+ "sort"
+ "strings"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
+ "github.com/beego/beego/v2/server/web"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/utils"
"github.com/mindoc-org/mindoc/utils/segmenter"
@@ -18,6 +22,8 @@ func init() {
//go InitializeMissingIndexes()
}
+const emptyIndexWord = "__mindoc_empty_index__"
+
// ContentReverseIndex 倒排索引结构
type ContentReverseIndex struct {
Id string `orm:"pk;column(id);size(64);description(唯一标识ID)" json:"id"`
@@ -102,26 +108,31 @@ type ContentReverseIndexResult struct {
WordCounts []int `json:"word_counts"` // 各个词的词频
}
-// FindByWordsWithPagination 根据多个分词词汇分页批量查询结果,按IDF值排序
+// FindByWords 根据多个分词词汇查询结果,按TF-IDF值排序,返回全部匹配结果(不分页)
// words: 分词词汇列表
-// pageIndex: 页码,从1开始
-// pageSize: 每页数量
-func (c *ContentReverseIndex) FindByWordsWithPagination(words []string, pageIndex, pageSize int) ([]*ContentReverseIndexResult, int, error) {
+// 返回值: 结果列表, 匹配的总文档数(截断前), error
+func (c *ContentReverseIndex) FindByWords(words []string) ([]*ContentReverseIndexResult, int, error) {
if len(words) == 0 {
return nil, 0, errors.New("分词词汇列表不能为空")
}
- if pageIndex <= 0 {
- pageIndex = 1
+ words = normalizeIndexWords(words)
+ if len(words) == 0 {
+ return nil, 0, errors.New("分词词汇列表不能为空")
}
- if pageSize <= 0 {
- pageSize = 10
+ // 限制查询词数量,防止恶意超长关键词生成巨大IN子句
+ const maxWords = 50
+ if len(words) > maxWords {
+ words = words[:maxWords]
}
o := orm.NewOrm()
tableName := c.TableNameWithPrefix()
+ if !validTableName.MatchString(tableName) {
+ return nil, 0, errors.New("非法表名: " + tableName)
+ }
// 计算总文档数
- totalDocsSql := "SELECT COUNT(DISTINCT CONCAT(content_type, '-', content_id)) FROM " + tableName
+ totalDocsSql := "SELECT COUNT(*) FROM (SELECT DISTINCT content_type, content_id FROM " + tableName + ") AS t"
var totalDocs int
err := o.Raw(totalDocsSql).QueryRow(&totalDocs)
if err != nil {
@@ -152,99 +163,103 @@ func (c *ContentReverseIndex) FindByWordsWithPagination(words []string, pageInde
return nil, 0, err
}
- // 计算各文档的总词数
- sql = "SELECT content_type, content_id, count(word_count) total_word_count FROM " + tableName +
- " GROUP BY content_type, content_id"
- type docWordCountRecord struct {
- ContentType int
- ContentId int
- TotalWordCount int
- }
- var docWordCountRecords []docWordCountRecord
- _, err = o.Raw(sql).QueryRows(&docWordCountRecords)
- if err != nil {
- return nil, 0, err
- }
-
- docTotalWordCountMap := make(map[string]int)
- for _, record := range docWordCountRecords {
- key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId)
- docTotalWordCountMap[key] = record.TotalWordCount
- }
-
- // 聚合每个(content_type, content_id)的词频和计算TF-IDF
- contentMap := make(map[string]*ContentReverseIndexResult)
+ // 计算每个词的文档频率(DF):每个词出现在多少个文档中
+ wordDocFreq := make(map[string]map[string]bool)
for _, record := range records {
key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId)
- if result, exists := contentMap[key]; exists {
- result.WordCounts = append(result.WordCounts, record.WordCount)
- } else {
- contentMap[key] = &ContentReverseIndexResult{
- ContentId: record.ContentId,
- ContentType: record.ContentType,
- WordCounts: []int{record.WordCount},
- }
+ if wordDocFreq[record.Word] == nil {
+ wordDocFreq[record.Word] = make(map[string]bool)
}
+ wordDocFreq[record.Word][key] = true
}
- docMapWithWords := make(map[string]int) // 用于计算包含搜索词的文档数
- // 计算每个文档包含多少个查询词
- docWordCount := make(map[string]int)
+ // 聚合每个文档的匹配词信息
+ type docWordInfo struct {
+ Word string
+ WordCount int
+ }
+ docWords := make(map[string][]docWordInfo)
for _, record := range records {
key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId)
- docWordCount[key] += record.WordCount
- docMapWithWords[key] += 1
- }
-
- // 计算IDF并生成结果
- results := make([]*ContentReverseIndexResult, 0, len(contentMap))
- for key := range contentMap {
- result := contentMap[key]
- // 计算TF:词频之和
- tf := float64(docWordCount[key]) / float64(docTotalWordCountMap[key]+1)
- // 计算DF:包含该词的文档数(简化处理,使用该文档包含的查询词数量)
- df := len(docMapWithWords)
- // 计算IDF
- idf := 0.0
- if df > 0 && totalDocs > 0 {
- idf = math.Log(float64(totalDocs+1) / float64(df))
+ docWords[key] = append(docWords[key], docWordInfo{
+ Word: record.Word,
+ WordCount: record.WordCount,
+ })
+ }
+
+ // 计算每个文档的TF-IDF分数(使用正确的per-word IDF)
+ results := make([]*ContentReverseIndexResult, 0, len(docWords))
+ for key, wordInfos := range docWords {
+ var contentType, contentId int
+ if _, err := fmt.Sscanf(key, "%d-%d", &contentType, &contentId); err != nil {
+ logs.Error("解析文档key失败 ->", key, err)
+ continue
}
- // 用于根据文档总词数调整TF-IDF的权重,避免总词数过小的文档权重过高
- alpha := math.Log(1.0+float64(docTotalWordCountMap[key])*0.01) * 100
- // TF-IDF分数
- result.Score = float64(tf) * idf * float64(alpha)
- results = append(results, result)
+ score := 0.0
+ wordCounts := make([]int, 0, len(wordInfos))
+
+ for _, wi := range wordInfos {
+ wordCounts = append(wordCounts, wi.WordCount)
+ // TF: 使用对数TF(sublinear TF),避免长文档被不合理惩罚
+ tf := 1.0 + math.Log(float64(wi.WordCount)+1)
+ // IDF: 每个词独立计算,稀有词权重更高
+ df := len(wordDocFreq[wi.Word])
+ idf := 0.0
+ if df > 0 && totalDocs > 0 {
+ idf = math.Log(float64(totalDocs+1) / float64(df+1))
+ }
+ // 词长权重:长词(更具体的词)贡献更大
+ wordLen := float64(len([]rune(wi.Word)))
+ lengthWeight := math.Log2(1.0 + wordLen)
+ score += tf * idf * lengthWeight
+ }
+
+ // 查询词覆盖率加成:匹配的查询词越多,分数越高
+ coverage := float64(len(wordInfos)) / float64(len(words))
+ score *= (1.0 + coverage)
+
+ results = append(results, &ContentReverseIndexResult{
+ ContentId: contentId,
+ ContentType: contentType,
+ Score: score,
+ WordCounts: wordCounts,
+ })
}
// 按Score降序排序
sortResultsByScore(results)
- totalCount := len(results)
- // 分页
- offset := (pageIndex - 1) * pageSize
- start := offset
- end := offset + pageSize
- if start > totalCount {
- start = totalCount
- }
- if end > totalCount {
- end = totalCount
- }
- if start >= end {
- return nil, totalCount, nil
- }
- return results[start:end], totalCount, nil
+ return results, len(results), nil
}
-func sortResultsByScore(results []*ContentReverseIndexResult) {
- for i := 0; i < len(results)-1; i++ {
- for j := i + 1; j < len(results); j++ {
- if results[i].Score < results[j].Score {
- results[i], results[j] = results[j], results[i]
- }
+func normalizeIndexWords(words []string) []string {
+ result := make([]string, 0, len(words))
+ seen := make(map[string]struct{}, len(words))
+ for _, word := range words {
+ word = strings.TrimSpace(word)
+ if word == "" || word == emptyIndexWord {
+ continue
}
+ if _, ok := seen[word]; ok {
+ continue
+ }
+ seen[word] = struct{}{}
+ result = append(result, word)
}
+ return result
+}
+
+func sortResultsByScore(results []*ContentReverseIndexResult) {
+ sort.Slice(results, func(i, j int) bool {
+ if results[i].Score != results[j].Score {
+ return results[i].Score > results[j].Score
+ }
+ if results[i].ContentType != results[j].ContentType {
+ return results[i].ContentType < results[j].ContentType
+ }
+ return results[i].ContentId < results[j].ContentId
+ })
}
func generateIndexId(contentType, contentId int, word string) string {
@@ -255,6 +270,16 @@ func generateIndexId(contentType, contentId int, word string) string {
return hex.EncodeToString(hash)[:32]
}
+func buildEmptyIndexRecord(contentType, contentId int) *ContentReverseIndex {
+ return &ContentReverseIndex{
+ Id: generateIndexId(contentType, contentId, emptyIndexWord),
+ Word: emptyIndexWord,
+ ContentType: contentType,
+ ContentId: contentId,
+ WordCount: 1,
+ }
+}
+
func BuildIndexForDocument(documentId int, content string) error {
if documentId <= 0 {
return errors.New("文档ID必须大于0")
@@ -268,10 +293,6 @@ func BuildIndexForDocument(documentId int, content string) error {
}
words := segmenter.Segment(content)
- if len(words) == 0 {
- return nil
- }
-
wordCountMap := make(map[string]int)
for _, word := range words {
if len(word) > 64 {
@@ -281,6 +302,9 @@ func BuildIndexForDocument(documentId int, content string) error {
}
indices := make([]*ContentReverseIndex, 0, len(wordCountMap))
+ if len(wordCountMap) == 0 {
+ indices = append(indices, buildEmptyIndexRecord(1, documentId))
+ }
for word, count := range wordCountMap {
id := generateIndexId(1, documentId, word)
@@ -317,10 +341,6 @@ func BuildIndexForBlog(blogId int, content string) error {
}
words := segmenter.Segment(content)
- if len(words) == 0 {
- return nil
- }
-
wordCountMap := make(map[string]int)
for _, word := range words {
if len(word) > 64 {
@@ -330,6 +350,9 @@ func BuildIndexForBlog(blogId int, content string) error {
}
indices := make([]*ContentReverseIndex, 0, len(wordCountMap))
+ if len(wordCountMap) == 0 {
+ indices = append(indices, buildEmptyIndexRecord(2, blogId))
+ }
for word, count := range wordCountMap {
id := generateIndexId(2, blogId, word)
@@ -380,6 +403,10 @@ func GetUnindexedDocuments(limit int) ([]*Document, error) {
docTable := NewDocument().TableNameWithPrefix()
indexTable := NewContentReverseIndex().TableNameWithPrefix()
+ if !validTableName.MatchString(docTable) || !validTableName.MatchString(indexTable) {
+ return nil, errors.New("非法表名")
+ }
+
sql := "SELECT d.* FROM " + docTable + " d " +
"LEFT JOIN " + indexTable + " i ON i.content_type = 1 AND i.content_id = d.document_id " +
"WHERE i.id IS NULL " +
@@ -401,6 +428,10 @@ func GetUnindexedBlogs(limit int) ([]*Blog, error) {
blogTable := NewBlog().TableNameWithPrefix()
indexTable := NewContentReverseIndex().TableNameWithPrefix()
+ if !validTableName.MatchString(blogTable) || !validTableName.MatchString(indexTable) {
+ return nil, errors.New("非法表名")
+ }
+
sql := "SELECT b.* FROM " + blogTable + " b " +
"LEFT JOIN " + indexTable + " i ON i.content_type = 2 AND i.content_id = b.blog_id " +
"WHERE i.id IS NULL " +
@@ -446,9 +477,7 @@ func InitializeMissingDocumentIndexes() {
if content == "" {
content = doc.Markdown
}
- for i := 0; i < 10; i++ { // 标题内容"十分"重要
- content = doc.DocumentName + "\n" + content
- }
+ content = doc.DocumentName + "\n" + content
content = utils.StripTags(content)
err := BuildIndexForDocument(doc.DocumentId, content)
if err != nil {
@@ -481,9 +510,7 @@ func InitializeMissingBlogIndexes() {
if content == "" {
content = blog.BlogContent
}
- for i := 0; i < 10; i++ { // 标题内容"十分"重要
- content = blog.BlogTitle + "\n" + content
- }
+ content = blog.BlogTitle + "\n" + content
content = utils.StripTags(content)
err := BuildIndexForBlog(blog.BlogId, content)
@@ -496,3 +523,153 @@ func InitializeMissingBlogIndexes() {
}
}
}
+
+// validTableName 校验表名仅包含安全字符,防止 SQL 注入
+var validTableName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+
+// RebuildAllIndexes 全量重建倒排索引(先清空再重建)。
+// 注意:MySQL/Postgres 使用 TRUNCATE,SQLite 使用 DELETE,若后续重建阶段发生错误,
+// 索引表将处于部分重建状态,此时搜索结果可能不完整。
+// 建议在业务低峰期执行,并在返回 error 时手动重新执行本命令。
+func RebuildAllIndexes() error {
+ logs.Info("开始全量重建倒排索引...")
+
+ // 清空倒排索引表
+ o := orm.NewOrm()
+ tableName := NewContentReverseIndex().TableNameWithPrefix()
+ if !validTableName.MatchString(tableName) {
+ err := errors.New("非法表名,拒绝执行: " + tableName)
+ logs.Error(err)
+ return err
+ }
+ err := clearReverseIndexTable(o, tableName)
+ if err != nil {
+ logs.Error("清空倒排索引表失败 ->", err)
+ return err
+ }
+ logs.Info("倒排索引表已清空")
+
+ // 重建文档索引
+ if err := rebuildDocumentIndexes(); err != nil {
+ logs.Error("文档索引重建失败,索引表处于部分重建状态,请重新执行 reindex ->", err)
+ return err
+ }
+ // 重建博客索引
+ if err := rebuildBlogIndexes(); err != nil {
+ logs.Error("博客索引重建失败,索引表处于部分重建状态,请重新执行 reindex ->", err)
+ return err
+ }
+
+ logs.Info("全量重建倒排索引完成")
+ return nil
+}
+
+func clearReverseIndexTable(o orm.Ormer, tableName string) error {
+ dbadapter, _ := web.AppConfig.String("db_adapter")
+ if strings.EqualFold(dbadapter, "sqlite3") {
+ _, err := o.Raw("DELETE FROM " + tableName).Exec()
+ return err
+ }
+ _, err := o.Raw("TRUNCATE TABLE " + tableName).Exec()
+ return err
+}
+
+func rebuildDocumentIndexes() error {
+ o := orm.NewOrm()
+ batchSize := 100
+ offset := 0
+ total := 0
+ failed := 0
+ var firstErr error
+
+ for {
+ var documents []*Document
+ _, err := o.QueryTable(NewDocument().TableNameWithPrefix()).
+ OrderBy("document_id").
+ Limit(batchSize, offset).
+ All(&documents)
+ if err != nil {
+ logs.Error("查询文档失败 ->", err)
+ return err
+ }
+ if len(documents) == 0 {
+ break
+ }
+
+ for _, doc := range documents {
+ content := doc.Release
+ if content == "" {
+ content = doc.Markdown
+ }
+ content = doc.DocumentName + "\n" + content
+ content = utils.StripTags(content)
+ if err := BuildIndexForDocument(doc.DocumentId, content); err != nil {
+ logs.Error("重建文档倒排索引失败 ->", doc.DocumentId, err)
+ failed++
+ if firstErr == nil {
+ firstErr = fmt.Errorf("document_id=%d: %w", doc.DocumentId, err)
+ }
+ } else {
+ total++
+ }
+ }
+
+ offset += batchSize
+ logs.Info("已重建文档索引:", total, "失败:", failed)
+ }
+ logs.Info("文档索引重建完成, 成功:", total, "失败:", failed)
+ if failed > 0 {
+ return fmt.Errorf("文档索引重建存在 %d 条失败,首个错误: %w", failed, firstErr)
+ }
+ return nil
+}
+
+func rebuildBlogIndexes() error {
+ o := orm.NewOrm()
+ batchSize := 100
+ offset := 0
+ total := 0
+ failed := 0
+ var firstErr error
+
+ for {
+ var blogs []*Blog
+ _, err := o.QueryTable(NewBlog().TableNameWithPrefix()).
+ OrderBy("blog_id").
+ Limit(batchSize, offset).
+ All(&blogs)
+ if err != nil {
+ logs.Error("查询博客失败 ->", err)
+ return err
+ }
+ if len(blogs) == 0 {
+ break
+ }
+
+ for _, blog := range blogs {
+ content := blog.BlogRelease
+ if content == "" {
+ content = blog.BlogContent
+ }
+ content = blog.BlogTitle + "\n" + content
+ content = utils.StripTags(content)
+ if err := BuildIndexForBlog(blog.BlogId, content); err != nil {
+ logs.Error("重建Blog倒排索引失败 ->", blog.BlogId, err)
+ failed++
+ if firstErr == nil {
+ firstErr = fmt.Errorf("blog_id=%d: %w", blog.BlogId, err)
+ }
+ } else {
+ total++
+ }
+ }
+
+ offset += batchSize
+ logs.Info("已重建Blog索引:", total, "失败:", failed)
+ }
+ logs.Info("Blog索引重建完成, 成功:", total, "失败:", failed)
+ if failed > 0 {
+ return fmt.Errorf("博客索引重建存在 %d 条失败,首个错误: %w", failed, firstErr)
+ }
+ return nil
+}
diff --git a/models/DocumentModel.go b/models/DocumentModel.go
index 2533b744..8b543185 100644
--- a/models/DocumentModel.go
+++ b/models/DocumentModel.go
@@ -268,10 +268,11 @@ func (item *Document) ReleaseContent() error {
// 刷新倒排索引
go func(docId int, docName, release, markdown string) {
- content := docName + "\n" + release
+ content := release
if content == "" {
content = markdown
}
+ content = docName + "\n" + content
content = utils.StripTags(content)
if err := BuildIndexForDocument(docId, content); err != nil {
logs.Error("error: 构建文档倒排索引失败 ->", docId, err)
diff --git a/models/DocumentSearchResult.go b/models/DocumentSearchResult.go
index c93477b3..8a437d2a 100644
--- a/models/DocumentSearchResult.go
+++ b/models/DocumentSearchResult.go
@@ -71,7 +71,17 @@ func (m *DocumentSearchResult) FindToPager(keyword string, pageIndex, pageSize,
LEFT JOIN md_books as book ON doc.book_id = book.book_id
WHERE book.privately_owned = 0 AND (doc.document_name LIKE ? OR doc.release LIKE ?) `
- sql2 := `SELECT *
+ sql2 := `SELECT
+ document_id,
+ modify_time,
+ create_time,
+ document_name,
+ identify,
+ description,
+ book_identify,
+ book_name,
+ author,
+ search_type
FROM (
SELECT
doc.document_id,
@@ -84,7 +94,8 @@ FROM (
book.book_name,
rel.member_id,
mdmb.account AS author,
- 'document' AS search_type
+ 'document' AS search_type,
+ CASE WHEN doc.document_name LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_documents AS doc
LEFT JOIN md_books AS book ON doc.book_id = book.book_id
LEFT JOIN md_relationship AS rel ON book.book_id = rel.book_id AND rel.role_id = 0
@@ -102,7 +113,8 @@ SELECT
book.book_name,
rel.member_id,
mdmb.account AS author,
- 'book' AS search_type
+ 'book' AS search_type,
+ CASE WHEN book.book_name LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_books AS book
LEFT JOIN md_relationship AS rel ON book.book_id = rel.book_id AND rel.role_id = 0
LEFT JOIN md_members AS mdmb ON rel.member_id = mdmb.member_id
@@ -120,12 +132,13 @@ WHERE book.privately_owned = 0 AND (book.book_name LIKE ? OR book.description LI
blog.blog_title as book_name,
blog.member_id,
mdmb.account,
- 'blog' AS search_type
+ 'blog' AS search_type,
+ CASE WHEN blog.blog_title LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_blogs AS blog
LEFT JOIN md_members AS mdmb ON blog.member_id = mdmb.member_id
WHERE blog.blog_status = 'public' AND (blog.blog_release LIKE ? OR blog.blog_title LIKE ?)
) AS union_table
-ORDER BY create_time DESC
+ORDER BY relevance DESC, create_time DESC, document_id DESC
LIMIT ? OFFSET ?;`
err = o.Raw(escape_sql(sql1), keyword, keyword).QueryRow(&totalCount)
@@ -158,7 +171,7 @@ WHERE book.privately_owned = 0 AND (book.book_name LIKE ? OR book.description LI
totalCount += c
- _, err = o.Raw(escape_sql(sql2), keyword, keyword, keyword, keyword, keyword, keyword, pageSize, offset).QueryRows(&searchResult)
+ _, err = o.Raw(escape_sql(sql2), keyword, keyword, keyword, keyword, keyword, keyword, keyword, keyword, keyword, pageSize, offset).QueryRows(&searchResult)
if err != nil {
logs.Error("查询搜索结果失败 -> ", err)
return
@@ -174,7 +187,17 @@ WHERE book.privately_owned = 0 AND (book.book_name LIKE ? OR book.description LI
on team.book_id = book.book_id
WHERE (book.privately_owned = 0 OR rel1.relationship_id > 0 or team.team_member_id > 0) AND (doc.document_name LIKE ? OR doc.release LIKE ?);`
- sql2 := `SELECT *
+ sql2 := `SELECT
+ document_id,
+ modify_time,
+ create_time,
+ document_name,
+ identify,
+ description,
+ book_identify,
+ book_name,
+ author,
+ search_type
FROM (
SELECT
doc.document_id,
@@ -187,7 +210,8 @@ FROM (
book.book_name,
rel.member_id,
mdmb.account AS author,
- 'document' AS search_type
+ 'document' AS search_type,
+ CASE WHEN doc.document_name LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_documents AS doc
LEFT JOIN md_books AS book ON doc.book_id = book.book_id
LEFT JOIN md_relationship AS rel ON book.book_id = rel.book_id AND rel.role_id = 0
@@ -218,7 +242,8 @@ FROM (
book.book_name,
rel.member_id,
mdmb.account AS author,
- 'book' AS search_type
+ 'book' AS search_type,
+ CASE WHEN book.book_name LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_books AS book
LEFT JOIN md_relationship AS rel ON book.book_id = rel.book_id AND rel.role_id = 0
LEFT JOIN md_members AS mdmb ON rel.member_id = mdmb.member_id
@@ -247,13 +272,14 @@ FROM (
blog.blog_title as book_name,
blog.member_id,
mdmb.account,
- 'blog' AS search_type
+ 'blog' AS search_type,
+ CASE WHEN blog.blog_title LIKE ? THEN 2 ELSE 1 END AS relevance
FROM md_blogs AS blog
LEFT JOIN md_members AS mdmb ON blog.member_id = mdmb.member_id
WHERE (blog.blog_status = 'public' OR blog.member_id = ?) AND blog.blog_type = 0 AND
(blog.blog_release LIKE ? OR blog.blog_title LIKE ?)
) AS union_table
-ORDER BY create_time DESC
+ORDER BY relevance DESC, create_time DESC, document_id DESC
LIMIT ? OFFSET ?;`
err = o.Raw(escape_sql(sql1), memberId, memberId, keyword, keyword).QueryRow(&totalCount)
@@ -292,7 +318,7 @@ WHERE (book.privately_owned = 0 OR rel1.relationship_id > 0 or team.team_member_
totalCount += c
- _, err = o.Raw(escape_sql(sql2), memberId, memberId, keyword, keyword, memberId, memberId, keyword, keyword, memberId, keyword, keyword, pageSize, offset).QueryRows(&searchResult)
+ _, err = o.Raw(escape_sql(sql2), keyword, memberId, memberId, keyword, keyword, keyword, memberId, memberId, keyword, keyword, keyword, memberId, keyword, keyword, pageSize, offset).QueryRows(&searchResult)
if err != nil {
return
}
diff --git a/static/js/editor.js b/static/js/editor.js
index 63ca446b..67bab1f6 100644
--- a/static/js/editor.js
+++ b/static/js/editor.js
@@ -5,29 +5,48 @@
/**
* 打开最后选中的节点
*/
- function openLastSelectedNode() {
+function selectCatalogNode(nodeId, persist) {
+ var selected = false;
+ var normalizedNodeId = parseInt(nodeId, 10);
+
+ if (!normalizedNodeId) {
+ return false;
+ }
+
+ try {
+ $.each(window.documentCategory, function (i, n) {
+ if (parseInt(n.id, 10) === normalizedNodeId && !selected) {
+ var node = {"id": n.id};
+ window.treeCatalog.deselect_all();
+ window.treeCatalog.select_node(node);
+ if (persist) {
+ setLastSelectNode(node);
+ }
+ selected = true;
+ }
+ });
+ } catch ($ex) {
+ console.log($ex)
+ }
+
+ return selected;
+}
+
+function openLastSelectedNode() {
//如果文档树或编辑器没有准备好则不加载文档
if (window.treeCatalog == null || window.editor == null) {
return false;
}
var $isSelected = false;
+
+ if (window.selectedDocId) {
+ $isSelected = selectCatalogNode(window.selectedDocId, true);
+ }
+
if (window.localStorage) {
var $selectedNodeId = window.sessionStorage.getItem("MinDoc::LastLoadDocument:" + window.book.identify);
- try {
- if ($selectedNodeId) {
- //遍历文档树判断是否存在节点
- $.each(window.documentCategory, function (i, n) {
- if (n.id == $selectedNodeId && !$isSelected) {
- var $node = {"id": n.id};
- window.treeCatalog.deselect_all();
- window.treeCatalog.select_node($node);
- $isSelected = true;
- }
- });
-
- }
- } catch ($ex) {
- console.log($ex)
+ if (!$isSelected && $selectedNodeId) {
+ $isSelected = selectCatalogNode($selectedNodeId, false);
}
}
@@ -36,9 +55,7 @@
var doc = window.documentCategory[0];
if (doc && doc.id > 0) {
- var node = {"id": doc.id};
- $("#sidebar").jstree(true).select_node(node);
- $isSelected = true;
+ $isSelected = selectCatalogNode(doc.id, true);
}
}
return $isSelected;
diff --git a/static/js/kancloud.js b/static/js/kancloud.js
index 810a2768..981ebaf8 100644
--- a/static/js/kancloud.js
+++ b/static/js/kancloud.js
@@ -153,6 +153,7 @@ function renderPage($data) {
$("#article-info").text($data.doc_info);
$("#view_count").text("阅读次数:" + $data.view_count);
$("#doc_id").val($data.doc_id);
+ updateEditLink($data.doc_id);
checkMarkdownTocElement();
if ($data.page) {
loadComment($data.page, $data.doc_id);
@@ -170,6 +171,19 @@ function renderPage($data) {
}
+function updateEditLink($docid) {
+ var $editLink = $("#editDocumentLink");
+ var normalizedDocId = parseInt($docid, 10);
+
+ if ($editLink.length === 0 || !window.editURL || !normalizedDocId) {
+ return;
+ }
+
+ window.currentDocumentId = normalizedDocId;
+ var baseURL = window.editURL.replace(/\/+$/, '');
+ $editLink.attr("href", baseURL + "/" + normalizedDocId);
+}
+
/***
* 加载文档到阅读区
* @param $url
@@ -242,31 +256,41 @@ function initHighlighting() {
}
function handleEvent(event) {
- // 如果焦点在输入框、textarea或可编辑元素中,不执行快捷键操作
var target = event.target;
var tagName = target.tagName.toLowerCase();
var isInputElement = tagName === 'input' || tagName === 'textarea' || tagName === 'select';
var isContentEditable = target.isContentEditable || target.contentEditable === 'true';
-
+
+ // ESC 关闭搜索面板,无论焦点在哪里都生效
+ if (event.keyCode === 27) {
+ $(".navg-item[data-mode='view']").click();
+ if (isInputElement) {
+ target.blur();
+ }
+ event.preventDefault();
+ return;
+ }
+
+ // 其他快捷键:焦点在输入框、textarea或可编辑元素中时不执行
if (isInputElement || isContentEditable) {
return;
}
switch (event.keyCode) {
- case 70: // ctrl + f 打开搜索面板 并获取焦点
+ case 70: // f 打开搜索面板 并获取焦点,ctrl+f 留给浏览器原生搜索
+ if (event.ctrlKey || event.metaKey) {
+ return;
+ }
$(".navg-item[data-mode='search']").click();
document.getElementById('searchForm').querySelector('input').focus();
event.preventDefault();
break;
- case 27: // esc 关闭搜索面板
- $(".navg-item[data-mode='view']").click();
- event.preventDefault();
- break;
}
}
$(function () {
window.addEventListener('keydown', handleEvent)
+ updateEditLink(window.currentDocumentId);
checkMarkdownTocElement();
$(".view-backtop").on("click", function () {
diff --git a/utils/segmenter/segmenter.go b/utils/segmenter/segmenter.go
index 86cac74a..d50fb1c5 100644
--- a/utils/segmenter/segmenter.go
+++ b/utils/segmenter/segmenter.go
@@ -1,10 +1,13 @@
package segmenter
import (
+ "bufio"
"os"
"path/filepath"
+ "regexp"
"strings"
"sync"
+ "unicode"
"github.com/beego/beego/v2/core/logs"
"github.com/mindoc-org/mindoc/conf"
@@ -15,8 +18,34 @@ var (
// jieba 分词器实例
segmenterOnce sync.Once
jiebaCut *gojieba.Jieba
+ // 停用词集合
+ stopWords map[string]bool
+ technicalTermPattern = regexp.MustCompile(`(?i)[a-z0-9][a-z0-9+#._/-]{1,63}`)
)
+// techTermWhitelist 技术术语白名单
+// 这些词虽然是常见英语词汇,但同时也是 Linux/Unix 命令、编程语言
+// 或重要技术术语,不应被停用词过滤,否则用户搜索相关命令时将无法找到文档
+var techTermWhitelist = map[string]bool{
+ // Linux/Unix 常用命令(同时也是英语常见词)
+ "find": true, "top": true, "last": true, "more": true, "less": true,
+ "who": true, "which": true, "done": true, "move": true, "give": true,
+ "make": true, "take": true, "fill": true, "split": true, "cut": true,
+ // 编程语言/框架名称
+ "go": true, "net": true, "next": true,
+ // HTTP 方法 / 数据库操作
+ "get": true, "put": true, "call": true, "show": true, "describe": true,
+ "like": true,
+ // 系统/运维/容器/网络相关
+ "system": true, "volume": true, "name": true, "save": true, "keep": true,
+ "re": true, "mine": true, "near": true, "fire": true, "front": true,
+ "full": true, "empty": true, "computer": true, "detail": true, "part": true,
+ "back": true, "down": true, "up": true, "bar": true, "round": true,
+ "side": true, "bottom": true,
+ // 工具/软件名称(同时也是英语单词)
+ "everything": true,
+}
+
// getDictDir 获取词典目录
func getDictDir() string {
// 使用项目根目录下的 lib/jieba 目录
@@ -49,10 +78,31 @@ func initJieba() {
}
// 创建分词器
jiebaCut = gojieba.NewJieba(jiebaDict, hmmDict, userDict, idfDict, stopWordsDict)
- logs.Info("jieba分词器初始化完成")
+ // 加载停用词表
+ stopWords = loadStopWords(stopWordsDict)
+ logs.Info("jieba分词器初始化完成, 停用词数:", len(stopWords))
})
}
+// loadStopWords 从文件加载停用词集合
+func loadStopWords(filePath string) map[string]bool {
+ sw := make(map[string]bool)
+ f, err := os.Open(filePath)
+ if err != nil {
+ logs.Error("加载停用词表失败 ->", err)
+ return sw
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ word := strings.TrimSpace(scanner.Text())
+ if word != "" {
+ sw[strings.ToLower(word)] = true
+ }
+ }
+ return sw
+}
+
// Segment 中文分词器
// 使用 jieba 分词库的搜索引擎模式进行分词
func Segment(text string) []string {
@@ -75,8 +125,56 @@ func Segment(text string) []string {
}
// 转小写(英文)
word = strings.ToLower(word)
+ // 过滤单字符标点符号/特殊字符,避免匹配大量无关文档
+ runes := []rune(word)
+ if len(runes) == 1 && !unicode.IsLetter(runes[0]) && !unicode.IsDigit(runes[0]) {
+ continue
+ }
+ // 过滤停用词(白名单中的技术术语不过滤)
+ if stopWords[word] && !techTermWhitelist[word] {
+ continue
+ }
+ result = append(result, word)
+ }
+
+ for _, word := range extractTechnicalTerms(text) {
+ if stopWords[word] && !techTermWhitelist[word] {
+ continue
+ }
result = append(result, word)
}
return result
}
+
+func extractTechnicalTerms(text string) []string {
+ matches := technicalTermPattern.FindAllString(text, -1)
+ if len(matches) == 0 {
+ return nil
+ }
+ result := make([]string, 0, len(matches))
+ for _, match := range matches {
+ word := strings.ToLower(strings.TrimSpace(match))
+ if word == "" {
+ continue
+ }
+ if len([]rune(word)) < 2 {
+ continue
+ }
+ if !strings.ContainsAny(word, ".+#/_-") {
+ continue
+ }
+ hasAlphaNumeric := false
+ for _, r := range word {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ hasAlphaNumeric = true
+ break
+ }
+ }
+ if !hasAlphaNumeric {
+ continue
+ }
+ result = append(result, word)
+ }
+ return result
+}
diff --git a/views/document/cherry_markdown_edit_template.tpl b/views/document/cherry_markdown_edit_template.tpl
index e8cf0dac..2689b89f 100755
--- a/views/document/cherry_markdown_edit_template.tpl
+++ b/views/document/cherry_markdown_edit_template.tpl
@@ -20,6 +20,7 @@
window.fileUploadURL = "{{urlfor "DocumentController.Upload" "identify" .Model.Identify}}";
window.documentCategory = {{.Result}};
window.book = {{.ModelResult}};
+ window.selectedDocId = {{.SelectedDocId}};
window.selectNode = null;
window.deleteURL = "{{urlfor "DocumentController.Delete" ":key" .Model.Identify}}";
window.editURL = "{{urlfor "DocumentController.Content" ":key" .Model.Identify ":id" ""}}";
diff --git a/views/document/cherry_read.tpl b/views/document/cherry_read.tpl
index b16cad04..c1c2d49a 100644
--- a/views/document/cherry_read.tpl
+++ b/views/document/cherry_read.tpl
@@ -32,6 +32,8 @@
window.BASE_URL = '{{urlfor "HomeController.Index" }}';
window.IS_DOCUMENT_INDEX = '{{if .IS_DOCUMENT_INDEX}}true{{end}}' === 'true';
window.IS_DISPLAY_COMMENT = '{{if .Model.IsDisplayComment}}true{{end}}' === 'true';
+ window.editURL = '{{urlfor "DocumentController.Edit" ":key" .Model.Identify ":id" ""}}';
+ window.currentDocumentId = {{.DocumentId}};