ContentReverseIndex.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. package models
  2. import (
  3. "crypto/md5"
  4. "encoding/hex"
  5. "errors"
  6. "fmt"
  7. "math"
  8. "regexp"
  9. "sort"
  10. "strings"
  11. "github.com/beego/beego/v2/client/orm"
  12. "github.com/beego/beego/v2/core/logs"
  13. "github.com/beego/beego/v2/server/web"
  14. "github.com/mindoc-org/mindoc/conf"
  15. "github.com/mindoc-org/mindoc/utils"
  16. "github.com/mindoc-org/mindoc/utils/segmenter"
  17. )
  18. func init() {
  19. //go InitializeMissingIndexes()
  20. }
  21. const emptyIndexWord = "__mindoc_empty_index__"
  22. // ContentReverseIndex 倒排索引结构
  23. type ContentReverseIndex struct {
  24. Id string `orm:"pk;column(id);size(64);description(唯一标识ID)" json:"id"`
  25. // Word 分词词汇,最长64个字
  26. Word string `orm:"column(word);size(64);index;description(分词词汇)" json:"word"`
  27. // ContentType 内容类型:1-Document 2-Blog
  28. ContentType int `orm:"column(content_type);type(int);index:idx_content_type_id,priority:1;description(内容类型:1-Document 2-Blog)" json:"content_type"`
  29. // ContentId 内容ID,对应DocumentId或BlogId
  30. ContentId int `orm:"column(content_id);type(int);index:idx_content_type_id,priority:2;description(内容ID)" json:"content_id"`
  31. // WordCount 词频数
  32. WordCount int `orm:"column(word_count);type(int);default(0);description(词频数)" json:"word_count"`
  33. }
  34. // TableName 获取对应数据库表名
  35. func (c *ContentReverseIndex) TableName() string {
  36. return "t_content_reverse_index"
  37. }
  38. // TableEngine 获取数据使用的引擎
  39. func (c *ContentReverseIndex) TableEngine() string {
  40. return "INNODB"
  41. }
  42. func (c *ContentReverseIndex) TableNameWithPrefix() string {
  43. return conf.GetDatabasePrefix() + c.TableName()
  44. }
  45. func NewContentReverseIndex() *ContentReverseIndex {
  46. return &ContentReverseIndex{}
  47. }
  48. // Insert 插入倒排索引记录
  49. func (c *ContentReverseIndex) Insert() error {
  50. if c.Id == "" {
  51. return errors.New("id不能为空")
  52. }
  53. if c.Word == "" {
  54. return errors.New("分词词汇不能为空")
  55. }
  56. if c.ContentType != 1 && c.ContentType != 2 {
  57. return errors.New("内容类型必须是1(Document)或2(Blog)")
  58. }
  59. if c.ContentId <= 0 {
  60. return errors.New("内容ID必须大于0")
  61. }
  62. o := orm.NewOrm()
  63. _, err := o.Insert(c)
  64. return err
  65. }
  66. // DeleteByContentTypeAndContentId 根据内容类型和内容ID删除所有倒排索引记录
  67. func (c *ContentReverseIndex) DeleteByContentTypeAndContentId(contentType, contentId int) error {
  68. if contentType != 1 && contentType != 2 {
  69. return errors.New("内容类型必须是1(Document)或2(Blog)")
  70. }
  71. if contentId <= 0 {
  72. return errors.New("内容ID必须大于0")
  73. }
  74. o := orm.NewOrm()
  75. _, err := o.QueryTable(c.TableNameWithPrefix()).Filter("content_type", contentType).Filter("content_id", contentId).Delete()
  76. return err
  77. }
  78. // BatchInsert 批量插入倒排索引记录
  79. func (c *ContentReverseIndex) BatchInsert(indices []*ContentReverseIndex) error {
  80. if len(indices) == 0 {
  81. return nil
  82. }
  83. o := orm.NewOrm()
  84. _, err := o.InsertMulti(len(indices), indices)
  85. return err
  86. }
  87. // ContentReverseIndexResult 倒排索引查询结果结构
  88. type ContentReverseIndexResult struct {
  89. ContentId int `json:"content_id"`
  90. ContentType int `json:"content_type"`
  91. Score float64 `json:"score"` // TF-IDF分数
  92. WordCounts []int `json:"word_counts"` // 各个词的词频
  93. }
  94. // FindByWords 根据多个分词词汇查询结果,按TF-IDF值排序,返回全部匹配结果(不分页)
  95. // words: 分词词汇列表
  96. // 返回值: 结果列表, 匹配的总文档数(截断前), error
  97. func (c *ContentReverseIndex) FindByWords(words []string) ([]*ContentReverseIndexResult, int, error) {
  98. if len(words) == 0 {
  99. return nil, 0, errors.New("分词词汇列表不能为空")
  100. }
  101. words = normalizeIndexWords(words)
  102. if len(words) == 0 {
  103. return nil, 0, errors.New("分词词汇列表不能为空")
  104. }
  105. // 限制查询词数量,防止恶意超长关键词生成巨大IN子句
  106. const maxWords = 50
  107. if len(words) > maxWords {
  108. words = words[:maxWords]
  109. }
  110. o := orm.NewOrm()
  111. tableName := c.TableNameWithPrefix()
  112. if !validTableName.MatchString(tableName) {
  113. return nil, 0, errors.New("非法表名: " + tableName)
  114. }
  115. // 计算总文档数
  116. totalDocsSql := "SELECT COUNT(*) FROM (SELECT DISTINCT content_type, content_id FROM " + tableName + ") AS t"
  117. var totalDocs int
  118. err := o.Raw(totalDocsSql).QueryRow(&totalDocs)
  119. if err != nil {
  120. return nil, 0, err
  121. }
  122. // 构建IN条件
  123. wordPlaceholders := ""
  124. wordArgs := make([]any, 0)
  125. for i, word := range words {
  126. if i > 0 {
  127. wordPlaceholders += ","
  128. }
  129. wordPlaceholders += "?"
  130. wordArgs = append(wordArgs, word)
  131. }
  132. sql := "SELECT word, content_type, content_id, word_count FROM " + tableName +
  133. " WHERE word IN (" + wordPlaceholders + ") ORDER BY content_type, content_id"
  134. type indexRecord struct {
  135. Word string
  136. ContentType int
  137. ContentId int
  138. WordCount int
  139. }
  140. var records []indexRecord
  141. _, err = o.Raw(sql, wordArgs...).QueryRows(&records)
  142. if err != nil {
  143. return nil, 0, err
  144. }
  145. // 计算每个词的文档频率(DF):每个词出现在多少个文档中
  146. wordDocFreq := make(map[string]map[string]bool)
  147. for _, record := range records {
  148. key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId)
  149. if wordDocFreq[record.Word] == nil {
  150. wordDocFreq[record.Word] = make(map[string]bool)
  151. }
  152. wordDocFreq[record.Word][key] = true
  153. }
  154. // 聚合每个文档的匹配词信息
  155. type docWordInfo struct {
  156. Word string
  157. WordCount int
  158. }
  159. docWords := make(map[string][]docWordInfo)
  160. for _, record := range records {
  161. key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId)
  162. docWords[key] = append(docWords[key], docWordInfo{
  163. Word: record.Word,
  164. WordCount: record.WordCount,
  165. })
  166. }
  167. // 计算每个文档的TF-IDF分数(使用正确的per-word IDF)
  168. results := make([]*ContentReverseIndexResult, 0, len(docWords))
  169. for key, wordInfos := range docWords {
  170. var contentType, contentId int
  171. if _, err := fmt.Sscanf(key, "%d-%d", &contentType, &contentId); err != nil {
  172. logs.Error("解析文档key失败 ->", key, err)
  173. continue
  174. }
  175. score := 0.0
  176. wordCounts := make([]int, 0, len(wordInfos))
  177. for _, wi := range wordInfos {
  178. wordCounts = append(wordCounts, wi.WordCount)
  179. // TF: 使用对数TF(sublinear TF),避免长文档被不合理惩罚
  180. tf := 1.0 + math.Log(float64(wi.WordCount)+1)
  181. // IDF: 每个词独立计算,稀有词权重更高
  182. df := len(wordDocFreq[wi.Word])
  183. idf := 0.0
  184. if df > 0 && totalDocs > 0 {
  185. idf = math.Log(float64(totalDocs+1) / float64(df+1))
  186. }
  187. // 词长权重:长词(更具体的词)贡献更大
  188. wordLen := float64(len([]rune(wi.Word)))
  189. lengthWeight := math.Log2(1.0 + wordLen)
  190. score += tf * idf * lengthWeight
  191. }
  192. // 查询词覆盖率加成:匹配的查询词越多,分数越高
  193. coverage := float64(len(wordInfos)) / float64(len(words))
  194. score *= (1.0 + coverage)
  195. results = append(results, &ContentReverseIndexResult{
  196. ContentId: contentId,
  197. ContentType: contentType,
  198. Score: score,
  199. WordCounts: wordCounts,
  200. })
  201. }
  202. // 按Score降序排序
  203. sortResultsByScore(results)
  204. return results, len(results), nil
  205. }
  206. func normalizeIndexWords(words []string) []string {
  207. result := make([]string, 0, len(words))
  208. seen := make(map[string]struct{}, len(words))
  209. for _, word := range words {
  210. word = strings.TrimSpace(word)
  211. if word == "" || word == emptyIndexWord {
  212. continue
  213. }
  214. if _, ok := seen[word]; ok {
  215. continue
  216. }
  217. seen[word] = struct{}{}
  218. result = append(result, word)
  219. }
  220. return result
  221. }
  222. func sortResultsByScore(results []*ContentReverseIndexResult) {
  223. sort.Slice(results, func(i, j int) bool {
  224. if results[i].Score != results[j].Score {
  225. return results[i].Score > results[j].Score
  226. }
  227. if results[i].ContentType != results[j].ContentType {
  228. return results[i].ContentType < results[j].ContentType
  229. }
  230. return results[i].ContentId < results[j].ContentId
  231. })
  232. }
  233. func generateIndexId(contentType, contentId int, word string) string {
  234. source := fmt.Sprintf("%d-%d-%s", contentType, contentId, word)
  235. hasher := md5.New()
  236. hasher.Write([]byte(source))
  237. hash := hasher.Sum(nil)
  238. return hex.EncodeToString(hash)[:32]
  239. }
  240. func buildEmptyIndexRecord(contentType, contentId int) *ContentReverseIndex {
  241. return &ContentReverseIndex{
  242. Id: generateIndexId(contentType, contentId, emptyIndexWord),
  243. Word: emptyIndexWord,
  244. ContentType: contentType,
  245. ContentId: contentId,
  246. WordCount: 1,
  247. }
  248. }
  249. func BuildIndexForDocument(documentId int, content string) error {
  250. if documentId <= 0 {
  251. return errors.New("文档ID必须大于0")
  252. }
  253. index := NewContentReverseIndex()
  254. err := index.DeleteByContentTypeAndContentId(1, documentId)
  255. if err != nil {
  256. logs.Error("删除文档倒排索引失败 ->", documentId, err)
  257. return err
  258. }
  259. words := segmenter.Segment(content)
  260. wordCountMap := make(map[string]int)
  261. for _, word := range words {
  262. if len(word) > 64 {
  263. word = word[:64]
  264. }
  265. wordCountMap[word]++
  266. }
  267. indices := make([]*ContentReverseIndex, 0, len(wordCountMap))
  268. if len(wordCountMap) == 0 {
  269. indices = append(indices, buildEmptyIndexRecord(1, documentId))
  270. }
  271. for word, count := range wordCountMap {
  272. id := generateIndexId(1, documentId, word)
  273. indexItem := &ContentReverseIndex{
  274. Id: id,
  275. Word: word,
  276. ContentType: 1,
  277. ContentId: documentId,
  278. WordCount: count,
  279. }
  280. indices = append(indices, indexItem)
  281. }
  282. if len(indices) > 0 {
  283. err = index.BatchInsert(indices)
  284. if err != nil {
  285. return fmt.Errorf("批量插入文档倒排索引失败 -> %d %v", documentId, err)
  286. }
  287. }
  288. return nil
  289. }
  290. func BuildIndexForBlog(blogId int, content string) error {
  291. if blogId <= 0 {
  292. return errors.New("BlogID必须大于0")
  293. }
  294. index := NewContentReverseIndex()
  295. err := index.DeleteByContentTypeAndContentId(2, blogId)
  296. if err != nil {
  297. logs.Error("删除Blog倒排索引失败 ->", blogId, err)
  298. return err
  299. }
  300. words := segmenter.Segment(content)
  301. wordCountMap := make(map[string]int)
  302. for _, word := range words {
  303. if len(word) > 64 {
  304. word = word[:64]
  305. }
  306. wordCountMap[word]++
  307. }
  308. indices := make([]*ContentReverseIndex, 0, len(wordCountMap))
  309. if len(wordCountMap) == 0 {
  310. indices = append(indices, buildEmptyIndexRecord(2, blogId))
  311. }
  312. for word, count := range wordCountMap {
  313. id := generateIndexId(2, blogId, word)
  314. indexItem := &ContentReverseIndex{
  315. Id: id,
  316. Word: word,
  317. ContentType: 2,
  318. ContentId: blogId,
  319. WordCount: count,
  320. }
  321. indices = append(indices, indexItem)
  322. }
  323. if len(indices) > 0 {
  324. err = index.BatchInsert(indices)
  325. if err != nil {
  326. logs.Error("批量插入Blog倒排索引失败 ->", blogId, err)
  327. return err
  328. }
  329. }
  330. return nil
  331. }
  332. func CheckDocumentIndexed(documentId int) bool {
  333. if documentId <= 0 {
  334. return false
  335. }
  336. o := orm.NewOrm()
  337. index := NewContentReverseIndex()
  338. return o.QueryTable(index.TableNameWithPrefix()).Filter("content_type", 1).Filter("content_id", documentId).Exist()
  339. }
  340. func CheckBlogIndexed(blogId int) bool {
  341. if blogId <= 0 {
  342. return false
  343. }
  344. o := orm.NewOrm()
  345. index := NewContentReverseIndex()
  346. return o.QueryTable(index.TableNameWithPrefix()).Filter("content_type", 2).Filter("content_id", blogId).Exist()
  347. }
  348. func GetUnindexedDocuments(limit int) ([]*Document, error) {
  349. o := orm.NewOrm()
  350. var documents []*Document
  351. docTable := NewDocument().TableNameWithPrefix()
  352. indexTable := NewContentReverseIndex().TableNameWithPrefix()
  353. if !validTableName.MatchString(docTable) || !validTableName.MatchString(indexTable) {
  354. return nil, errors.New("非法表名")
  355. }
  356. sql := "SELECT d.* FROM " + docTable + " d " +
  357. "LEFT JOIN " + indexTable + " i ON i.content_type = 1 AND i.content_id = d.document_id " +
  358. "WHERE i.id IS NULL " +
  359. "ORDER BY d.document_id DESC"
  360. if limit > 0 {
  361. sql += " LIMIT ?"
  362. _, err := o.Raw(sql, limit).QueryRows(&documents)
  363. return documents, err
  364. }
  365. _, err := o.Raw(sql).QueryRows(&documents)
  366. return documents, err
  367. }
  368. func GetUnindexedBlogs(limit int) ([]*Blog, error) {
  369. o := orm.NewOrm()
  370. var blogs []*Blog
  371. blogTable := NewBlog().TableNameWithPrefix()
  372. indexTable := NewContentReverseIndex().TableNameWithPrefix()
  373. if !validTableName.MatchString(blogTable) || !validTableName.MatchString(indexTable) {
  374. return nil, errors.New("非法表名")
  375. }
  376. sql := "SELECT b.* FROM " + blogTable + " b " +
  377. "LEFT JOIN " + indexTable + " i ON i.content_type = 2 AND i.content_id = b.blog_id " +
  378. "WHERE i.id IS NULL " +
  379. "ORDER BY b.blog_id DESC"
  380. if limit > 0 {
  381. sql += " LIMIT ?"
  382. _, err := o.Raw(sql, limit).QueryRows(&blogs)
  383. return blogs, err
  384. }
  385. _, err := o.Raw(sql).QueryRows(&blogs)
  386. return blogs, err
  387. }
  388. // InitializeMissingIndexes 初始化缺失的倒排索引
  389. func InitializeMissingIndexes() {
  390. go func() {
  391. logs.Info("开始检查并初始化缺失的倒排索引...")
  392. InitializeMissingDocumentIndexes()
  393. InitializeMissingBlogIndexes()
  394. logs.Info("倒排索引初始化检查完成")
  395. }()
  396. }
  397. func InitializeMissingDocumentIndexes() {
  398. batchSize := 100
  399. for {
  400. documents, err := GetUnindexedDocuments(batchSize)
  401. if err != nil {
  402. logs.Error("获取未索引文档失败 ->", err)
  403. break
  404. }
  405. if len(documents) == 0 {
  406. break
  407. }
  408. for _, doc := range documents {
  409. indexed := CheckDocumentIndexed(doc.DocumentId)
  410. if !indexed {
  411. content := doc.Release
  412. if content == "" {
  413. content = doc.Markdown
  414. }
  415. content = doc.DocumentName + "\n" + content
  416. content = utils.StripTags(content)
  417. err := BuildIndexForDocument(doc.DocumentId, content)
  418. if err != nil {
  419. logs.Error("构建文档倒排索引失败 ->", doc.DocumentId, err)
  420. } else {
  421. logs.Info("文档倒排索引构建成功 ->", doc.DocumentId)
  422. }
  423. }
  424. }
  425. }
  426. }
  427. func InitializeMissingBlogIndexes() {
  428. batchSize := 100
  429. for {
  430. blogs, err := GetUnindexedBlogs(batchSize)
  431. if err != nil {
  432. logs.Error("获取未索引Blog失败 ->", err)
  433. break
  434. }
  435. if len(blogs) == 0 {
  436. break
  437. }
  438. for _, blog := range blogs {
  439. indexed := CheckBlogIndexed(blog.BlogId)
  440. if !indexed {
  441. content := blog.BlogRelease
  442. if content == "" {
  443. content = blog.BlogContent
  444. }
  445. content = blog.BlogTitle + "\n" + content
  446. content = utils.StripTags(content)
  447. err := BuildIndexForBlog(blog.BlogId, content)
  448. if err != nil {
  449. logs.Error("构建Blog倒排索引失败 ->", blog.BlogId, err)
  450. } else {
  451. logs.Info("Blog倒排索引构建成功 ->", blog.BlogId)
  452. }
  453. }
  454. }
  455. }
  456. }
  457. // validTableName 校验表名仅包含安全字符,防止 SQL 注入
  458. var validTableName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
  459. // RebuildAllIndexes 全量重建倒排索引(先清空再重建)。
  460. // 注意:MySQL/Postgres 使用 TRUNCATE,SQLite 使用 DELETE,若后续重建阶段发生错误,
  461. // 索引表将处于部分重建状态,此时搜索结果可能不完整。
  462. // 建议在业务低峰期执行,并在返回 error 时手动重新执行本命令。
  463. func RebuildAllIndexes() error {
  464. logs.Info("开始全量重建倒排索引...")
  465. // 清空倒排索引表
  466. o := orm.NewOrm()
  467. tableName := NewContentReverseIndex().TableNameWithPrefix()
  468. if !validTableName.MatchString(tableName) {
  469. err := errors.New("非法表名,拒绝执行: " + tableName)
  470. logs.Error(err)
  471. return err
  472. }
  473. err := clearReverseIndexTable(o, tableName)
  474. if err != nil {
  475. logs.Error("清空倒排索引表失败 ->", err)
  476. return err
  477. }
  478. logs.Info("倒排索引表已清空")
  479. // 重建文档索引
  480. if err := rebuildDocumentIndexes(); err != nil {
  481. logs.Error("文档索引重建失败,索引表处于部分重建状态,请重新执行 reindex ->", err)
  482. return err
  483. }
  484. // 重建博客索引
  485. if err := rebuildBlogIndexes(); err != nil {
  486. logs.Error("博客索引重建失败,索引表处于部分重建状态,请重新执行 reindex ->", err)
  487. return err
  488. }
  489. logs.Info("全量重建倒排索引完成")
  490. return nil
  491. }
  492. func clearReverseIndexTable(o orm.Ormer, tableName string) error {
  493. dbadapter, _ := web.AppConfig.String("db_adapter")
  494. if strings.EqualFold(dbadapter, "sqlite3") {
  495. _, err := o.Raw("DELETE FROM " + tableName).Exec()
  496. return err
  497. }
  498. _, err := o.Raw("TRUNCATE TABLE " + tableName).Exec()
  499. return err
  500. }
  501. func rebuildDocumentIndexes() error {
  502. o := orm.NewOrm()
  503. batchSize := 100
  504. offset := 0
  505. total := 0
  506. failed := 0
  507. var firstErr error
  508. for {
  509. var documents []*Document
  510. _, err := o.QueryTable(NewDocument().TableNameWithPrefix()).
  511. OrderBy("document_id").
  512. Limit(batchSize, offset).
  513. All(&documents)
  514. if err != nil {
  515. logs.Error("查询文档失败 ->", err)
  516. return err
  517. }
  518. if len(documents) == 0 {
  519. break
  520. }
  521. for _, doc := range documents {
  522. content := doc.Release
  523. if content == "" {
  524. content = doc.Markdown
  525. }
  526. content = doc.DocumentName + "\n" + content
  527. content = utils.StripTags(content)
  528. if err := BuildIndexForDocument(doc.DocumentId, content); err != nil {
  529. logs.Error("重建文档倒排索引失败 ->", doc.DocumentId, err)
  530. failed++
  531. if firstErr == nil {
  532. firstErr = fmt.Errorf("document_id=%d: %w", doc.DocumentId, err)
  533. }
  534. } else {
  535. total++
  536. }
  537. }
  538. offset += batchSize
  539. logs.Info("已重建文档索引:", total, "失败:", failed)
  540. }
  541. logs.Info("文档索引重建完成, 成功:", total, "失败:", failed)
  542. if failed > 0 {
  543. return fmt.Errorf("文档索引重建存在 %d 条失败,首个错误: %w", failed, firstErr)
  544. }
  545. return nil
  546. }
  547. func rebuildBlogIndexes() error {
  548. o := orm.NewOrm()
  549. batchSize := 100
  550. offset := 0
  551. total := 0
  552. failed := 0
  553. var firstErr error
  554. for {
  555. var blogs []*Blog
  556. _, err := o.QueryTable(NewBlog().TableNameWithPrefix()).
  557. OrderBy("blog_id").
  558. Limit(batchSize, offset).
  559. All(&blogs)
  560. if err != nil {
  561. logs.Error("查询博客失败 ->", err)
  562. return err
  563. }
  564. if len(blogs) == 0 {
  565. break
  566. }
  567. for _, blog := range blogs {
  568. content := blog.BlogRelease
  569. if content == "" {
  570. content = blog.BlogContent
  571. }
  572. content = blog.BlogTitle + "\n" + content
  573. content = utils.StripTags(content)
  574. if err := BuildIndexForBlog(blog.BlogId, content); err != nil {
  575. logs.Error("重建Blog倒排索引失败 ->", blog.BlogId, err)
  576. failed++
  577. if firstErr == nil {
  578. firstErr = fmt.Errorf("blog_id=%d: %w", blog.BlogId, err)
  579. }
  580. } else {
  581. total++
  582. }
  583. }
  584. offset += batchSize
  585. logs.Info("已重建Blog索引:", total, "失败:", failed)
  586. }
  587. logs.Info("Blog索引重建完成, 成功:", total, "失败:", failed)
  588. if failed > 0 {
  589. return fmt.Errorf("博客索引重建存在 %d 条失败,首个错误: %w", failed, firstErr)
  590. }
  591. return nil
  592. }