package model import ( "bytes" "compress/gzip" "errors" "fmt" "io" "moredoc/util" "moredoc/util/converter" "moredoc/util/sitemap" "os" "path/filepath" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "go.uber.org/zap" "gorm.io/gorm" ) type reconvertDocument struct { Id int64 `json:"id"` } var ( isCreatingSitemap bool cacheReconvert = "cache/reconvert" ) // UpdateSitemap 更新站点地图 func (m *DBModel) UpdateSitemap() (err error) { if isCreatingSitemap { return } isCreatingSitemap = true defer func() { isCreatingSitemap = false }() os.MkdirAll("sitemap", os.ModePerm) var ( limit = 10000 page = 1 documents []Document articles []Article modelDocument = &Document{} modelArticle = &Article{} sitemapIndexes []sitemap.SitemapIndex sm = sitemap.NewSitemap() domain = strings.TrimRight(m.GetConfigOfSystem(ConfigSystemDomain).Domain, "/") ) for { if err = m.db.Model(modelDocument).Select("id", "updated_at").Limit(limit).Offset((page - 1) * limit).Order("id asc").Find(&documents).Error; err != nil && err != gorm.ErrRecordNotFound { m.logger.Error("execUpdateSitemap", zap.Error(err)) return } if len(documents) == 0 { break } file := fmt.Sprintf("sitemap/documents-%d.xml", page) var su []sitemap.SitemapUrl for _, doc := range documents { su = append(su, sitemap.SitemapUrl{ Loc: fmt.Sprintf("%s/document/%d", domain, doc.Id), Lastmod: doc.UpdatedAt.Format(time.RFC3339), ChangeFreq: sitemap.DAILY, Priority: 1.0, }) } if err = sm.CreateSitemapContent(su, file); err != nil { m.logger.Error("execUpdateSitemap", zap.Error(err)) return } sitemapIndexes = append(sitemapIndexes, sitemap.SitemapIndex{ Loc: domain + "/" + file, Lastmod: time.Now().Format(time.RFC3339), }) page++ } page = 1 for { if err = m.db.Model(modelArticle).Select("id", "updated_at", "identifier").Limit(limit).Offset((page - 1) * limit).Order("id asc").Find(&articles).Error; err != nil && err != gorm.ErrRecordNotFound { m.logger.Error("execUpdateSitemap", zap.Error(err)) return } if len(articles) == 0 { break } file := fmt.Sprintf("sitemap/articles-%d.xml", page) var su []sitemap.SitemapUrl for _, article := range articles { su = append(su, sitemap.SitemapUrl{ Loc: fmt.Sprintf("%s/article/%s", domain, article.Identifier), Lastmod: article.UpdatedAt.Format(time.RFC3339), ChangeFreq: sitemap.DAILY, Priority: 1.0, }) } if err = sm.CreateSitemapContent(su, file); err != nil { m.logger.Error("execUpdateSitemap", zap.Error(err)) return } sitemapIndexes = append(sitemapIndexes, sitemap.SitemapIndex{ Loc: domain + "/" + file, Lastmod: time.Now().Format(time.RFC3339), }) page++ } if len(sitemapIndexes) > 0 { if err = sm.CreateSitemapIndex(sitemapIndexes, "sitemap/sitemap.xml"); err != nil { m.logger.Error("execUpdateSitemap", zap.Error(err)) return } } return } // SEO func (m *DBModel) InitSEO() { // 扫描dist目录下的所有HTML文件,将文件名作为SEO的关键字 cfg := m.GetConfigOfSystem() dist := "dist" pages := map[string]string{ "200.html": "", "404.html": "404 - 页面未找到 - ", "findpassword/index.html": "找回密码 - ", "index.html": "", "login/index.html": "用户登录 - ", "register/index.html": "用户注册 - ", "search/index.html": "文档搜索 - ", "upload/index.html": "文档上传 - ", } filepath.Walk(dist, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } path = filepath.ToSlash(path) if filepath.Ext(path) == ".html" { name := strings.TrimPrefix(path, dist+"/") defaultTitle, ok := pages[name] if !ok && strings.HasPrefix(path, dist+"/admin") { defaultTitle = "管理后台 - " } bs, _ := os.ReadFile(path) if doc, errDoc := goquery.NewDocumentFromReader(bytes.NewReader(bs)); errDoc != nil { m.logger.Error("initSEO", zap.Error(errDoc), zap.String("file", path)) } else { m.logger.Debug("initSEO", zap.String("file", path), zap.String("title", defaultTitle+cfg.Sitename)) doc.Find("title").SetText(defaultTitle + cfg.Sitename) doc.Find("meta[name='keywords']").SetAttr("content", cfg.Keywords) doc.Find("meta[name='description']").SetAttr("content", cfg.Description) doc.Find("meta[content='moredoc']").Remove() doc.Find("meta[name='og:type']").Remove() if htmlStr, errHtml := doc.Html(); errHtml == nil { os.WriteFile(path, []byte(htmlStr), os.ModePerm) } } } return nil }) } func (m *DBModel) cronUpdateSitemap() { layout := "2006-01-02" lastUpdated := time.Now().Format(layout) for { hour, _ := strconv.Atoi(os.Getenv("MOREDOC_UPDATE_SITEMAP_HOUR")) // 默认为每天凌晨0点更新站点地图 hour = hour % 24 m.logger.Debug("cronUpdateSitemap", zap.Int("hour", hour), zap.String("lastUpdated", lastUpdated)) now := time.Now() if now.Hour() == hour && now.Format(layout) != lastUpdated { m.logger.Debug("cronUpdateSitemap,start...") err := m.UpdateSitemap() if err != nil { m.logger.Debug("cronUpdateSitemap,end...", zap.Error(err)) } m.logger.Debug("cronUpdateSitemap,end...") lastUpdated = now.Format(layout) } time.Sleep(1 * time.Minute) } } // 清理无效附件 // 1. 找出已被标记删除的附件 // 2. 查询是否存在相同hash的未被标记删除的附件,对于此类附件,则只删除附件记录而不删除附件文件。 // 3. 删除已被标记删除的附件 // 4. 对于文档类附件,要注意衍生的附件,如缩略图、PDF等,也要一并删除。 func (m *DBModel) cronCleanInvalidAttachment() { sleepDuration := 1 * time.Minute for { time.Sleep(1 * time.Second) m.logger.Debug("cronCleanInvalidAttachment,start...") var ( deletedAttachemnts, attachemnts []Attachment hashes []string hashMap = make(map[string]struct{}) ids []int64 retentionMinute = m.GetConfigOfSecurity(ConfigSecurityAttachmentRetentionMinute).AttachmentRetentionMinute ) if retentionMinute < 0 { retentionMinute = 0 } // 1. 找出已被标记删除的附件 m.db.Unscoped().Where("deleted_at IS NOT NULL").Where("deleted_at < ?", time.Now().Add(-time.Duration(retentionMinute)*time.Minute)).Limit(100).Find(&deletedAttachemnts) if len(deletedAttachemnts) == 0 { m.logger.Debug("cronCleanInvalidAttachment,end...") time.Sleep(sleepDuration) continue } for _, attachemnt := range deletedAttachemnts { hashes = append(hashes, attachemnt.Hash) ids = append(ids, attachemnt.Id) } // 2. 查询是否存在相同hash的未被标记删除的附件 m.db.Select("hash").Where("hash IN (?)", hashes).Group("hash").Limit(len(hashes)).Find(&attachemnts) for _, attachemnt := range attachemnts { hashMap[attachemnt.Hash] = struct{}{} } // 3. 删除已被标记删除的附件 err := m.db.Unscoped().Where("id IN (?)", ids).Delete(&Attachment{}).Error if err != nil { m.logger.Error("cronCleanInvalidAttachment", zap.Error(err)) m.logger.Debug("cronCleanInvalidAttachment,end...") continue } m.logger.Debug("cronCleanInvalidAttachment", zap.Any("ids", ids), zap.Any("Attachemnts", deletedAttachemnts)) for _, attachemnt := range deletedAttachemnts { if _, ok := hashMap[attachemnt.Hash]; !ok { // 删除附件文件 m.logger.Debug("cronCleanInvalidAttachment", zap.String("path", attachemnt.Path), zap.Any("attachemnt", attachemnt)) file := strings.TrimLeft(attachemnt.Path, "./") m.logger.Debug("cronCleanInvalidAttachment", zap.String("file", file)) if err := os.Remove(file); err != nil { m.logger.Error("cronCleanInvalidAttachment", zap.Error(err), zap.String("file", file)) } if attachemnt.Type == AttachmentTypeDocument { // 删除文档的衍生文件 folder := strings.TrimSuffix(file, filepath.Ext(file)) m.logger.Debug("cronCleanInvalidAttachment", zap.String("folder", folder)) if err := os.RemoveAll(folder); err != nil { m.logger.Error("cronCleanInvalidAttachment", zap.Error(err), zap.String("folder", folder)) } } } } m.logger.Debug("cronCleanInvalidAttachment,end...") } } func (m *DBModel) cronMarkAttachmentDeleted() { // 定时标记删除24小时前上传的但是未被使用的附件 for { time.Sleep(1 * time.Hour) var ( configs []Config banners []Banner hashes []string ) // 1. 查找图片类配置 m.db.Select("value").Where("input_type = ?", "image").Find(&configs) if len(configs) > 0 { for _, config := range configs { // 文件hash hash := strings.TrimSpace(strings.TrimSuffix(filepath.Base(config.Value), filepath.Ext(config.Value))) if hash != "" { hashes = append(hashes, hash) } } } // 2. 查找横幅类配置 m.db.Select("path").Find(&banners) if len(banners) > 0 { for _, banner := range banners { // 文件hash hash := strings.TrimSpace(strings.TrimSuffix(filepath.Base(banner.Path), filepath.Ext(banner.Path))) if hash != "" { hashes = append(hashes, hash) } } } if len(hashes) > 0 { err := m.db.Where("`hash` NOT IN (?) and `type` in (?)", hashes, []int{AttachmentTypeConfig, AttachmentTypeBanner}).Delete(&Attachment{}).Error if err != nil { m.logger.Error("cronMarkAttachmentDeleted", zap.Error(err)) } } // 非配置类和横幅类附件,如果type_id为0,则表示未被使用,超过24小时则标记删除 m.logger.Debug("cronMarkAttachmentDeleted start...") err := m.db.Where("`type` not in (?) and type_id = ?", []int{AttachmentTypeConfig, AttachmentTypeBanner}, 0).Where("created_at < ?", time.Now().Add(-time.Duration(24)*time.Hour)).Delete(&Attachment{}).Error if err != nil { m.logger.Error("cronMarkAttachmentDeleted", zap.Error(err)) } m.logger.Debug("cronMarkAttachmentDeleted end...") } } func (m *DBModel) loopCovertDocument() { if convertDocumentRunning { return } // 清空缓存目录 os.RemoveAll("cache/convert") convertDocumentRunning = true sleep := 10 * time.Second m.db.Model(&Document{}).Where("status = ?", DocumentStatusConverting).Update("status", DocumentStatusPending) for { now := time.Now() m.logger.Debug("loopCovertDocument,start...") err := m.ConvertDocument() if err != nil && err != gorm.ErrRecordNotFound { m.logger.Error("loopCovertDocument", zap.Error(err)) } m.logger.Debug("loopCovertDocument,end...", zap.String("cost", time.Since(now).String())) if err == gorm.ErrRecordNotFound { time.Sleep(sleep) } } } func (m *DBModel) ReconvertDocoument(documentId int64, ext string) { ext = "." + strings.TrimLeft(ext, ".") os.RemoveAll(cacheReconvert) os.MkdirAll(cacheReconvert, os.ModePerm) if documentId <= 0 { m.reconvertAllDocument(ext) return } doc, err := m.GetDocument(documentId) if err != nil { m.logger.Error("ReconvertDocoument", zap.Error(err)) return } if doc.Status != DocumentStatusConverted { m.logger.Error("ReconvertDocoument", zap.Error(errors.New("文档不是已转换的文档,不能重转"))) return } m.reconvertDocument(&doc, ext) } func (m *DBModel) reconvertDocument(doc *Document, ext string) { m.logger.Debug("reconvertDocument", zap.Any("doc", doc), zap.String("ext", ext)) if doc.PreviewExt == ext { m.logger.Info("reconvertDocument", zap.String("msg", "文档预览文件格式与指定格式一致,无需重转"), zap.String("document", doc.Title+doc.Ext)) return } // 1. 下载文档预览文件 attachment := m.GetAttachmentByTypeAndTypeId(AttachmentTypeDocument, doc.Id, "id", "hash") if attachment.Id == 0 { m.logger.Error("reconvertDocument", zap.String("msg", "文档预览文件不存在"), zap.String("document", doc.Title+doc.Ext)) return } cacheDir := filepath.Join(cacheReconvert, strconv.FormatInt(doc.Id, 10)) os.MkdirAll(cacheDir, os.ModePerm) defer os.RemoveAll(cacheDir) totalPreview := doc.Preview if totalPreview == 0 { totalPreview = doc.Pages } var ( convertedTargets []string oldSrcFiles []string ) for i := 1; i <= totalPreview; i++ { // 已存在的预览文件 isGZIP := false oldExt := doc.PreviewExt if doc.EnableGZIP && strings.HasSuffix(oldExt, ".svg") { oldExt = ".gzip.svg" isGZIP = true } // 目标文件 dstFile := filepath.Join(cacheDir, fmt.Sprintf("%d%s", i, oldExt)) // 源文件 srcFile := fmt.Sprintf("documents/%s/%s/%d%s", strings.Join(strings.Split(attachment.Hash, "")[:5], "/"), attachment.Hash, i, oldExt) oldSrcFiles = append(oldSrcFiles, srcFile) err := util.CopyFile(srcFile, dstFile) if err != nil { m.logger.Error("reconvertDocument", zap.String("msg", "下载文档预览文件失败"), zap.String("document", doc.Title+doc.Ext), zap.Error(err)) return } m.logger.Debug("reconvertDocument", zap.Bool("isGZIP", isGZIP), zap.String("msg", "下载文档预览文件成功"), zap.String("document", doc.Title+doc.Ext), zap.String("srcFile", srcFile), zap.String("dstFile", dstFile)) if isGZIP { // 解压缩 m.ungzipSVG(dstFile) } // 2. 转换文档预览文件 convertedTargetFile := filepath.Join(cacheDir, fmt.Sprintf("%d%s", i, ext)) if strings.HasSuffix(oldExt, ".svg") { // 如果是svg文件,则需要使用inkscape预先转为png tmpFile := filepath.Join(cacheDir, fmt.Sprintf("tmp-%d.png", i)) err = converter.ConvertByInkscape(dstFile, tmpFile) if err == nil { if strings.HasSuffix(convertedTargetFile, ".png") { // 如果目标文件是png,则直接使用inkscape转换后的文件 convertedTargetFile = tmpFile } else { // 如果目标文件不是png,则需要使用ImageMagick转换 err = converter.ConvertByImageMagick(tmpFile, convertedTargetFile) os.RemoveAll(tmpFile) } } } else { err = converter.ConvertByImageMagick(dstFile, convertedTargetFile) } if err != nil { m.logger.Error("reconvertDocument", zap.String("msg", "转换文档预览文件失败"), zap.String("document", doc.Title+doc.Ext), zap.Error(err)) return } convertedTargets = append(convertedTargets, convertedTargetFile) } // 3. 上传文档预览文件 for i, srcFile := range convertedTargets { dstFile := fmt.Sprintf("documents/%s/%s/%d%s", strings.Join(strings.Split(attachment.Hash, "")[:5], "/"), attachment.Hash, i+1, ext) err := util.CopyFile(srcFile, dstFile) if err != nil { m.logger.Error("reconvertDocument", zap.String("msg", "上传文档预览文件失败"), zap.String("document", doc.Title+doc.Ext), zap.Error(err)) return } } // 4. 更新数据库表的预览后缀 // 查询同一hash的文档 var ( attachemnts []Attachment err error data = map[string]interface{}{ "preview_ext": ext, "enable_gzip": false, } ) m.db.Select("id", "type_id").Where("hash = ? and `type` = ?", attachment.Hash, AttachmentTypeDocument).Find(&attachemnts) if len(attachemnts) > 0 { var ids []int64 for _, attachemnt := range attachemnts { ids = append(ids, attachemnt.TypeId) } err = m.db.Model(&Document{}).Where("id IN (?)", ids).Updates(data).Error } else { err = m.db.Model(doc).Updates(data).Error } if err != nil { m.logger.Error("reconvertDocument", zap.String("msg", "更新文档预览文件后缀失败"), zap.String("document", doc.Title+doc.Ext), zap.Error(err)) return } // 5. 删除缓存文件,删除原预览文件 for _, file := range oldSrcFiles { os.Remove(file) } } func (m *DBModel) reconvertAllDocument(ext string) { var cfg reconvertDocument bytes, _ := os.ReadFile("cache/reconvert.json") json.Unmarshal(bytes, &cfg) for { var doc Document m.db.Where("id > ?", cfg.Id).Where("status = ?", DocumentStatusConverted).Order("id asc").Find(&doc) if doc.Id == 0 { break } m.reconvertDocument(&doc, ext) cfg.Id = doc.Id bytes, _ = json.Marshal(cfg) os.WriteFile("cache/reconvert.json", bytes, os.ModePerm) } } func (m *DBModel) ungzipSVG(svg string) { m.logger.Info("ungzipSVG", zap.String("svg", svg)) bs, err := os.ReadFile(svg) if err != nil { m.logger.Error("ungzipSVG", zap.Error(err)) return } gz, err := gzip.NewReader(bytes.NewReader(bs)) if err != nil { m.logger.Error("ungzipSVG", zap.Error(err)) return } defer gz.Close() fp, err := os.Create(svg) if err != nil { m.logger.Error("ungzipSVG", zap.Error(err)) return } defer fp.Close() io.Copy(fp, gz) }