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}};