package rss import ( "context" "encoding/xml" "fmt" "os" "path/filepath" "strings" "sync" "time" "videoweb/app/service/configser" "videoweb/pub/common/log" "videoweb/pub/common/netutil" "videoweb/pub/common/utils" "videoweb/pub/models/cfg" "videoweb/pub/models/videoinformationmod" "videoweb/pub/pubdef" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const ( maxRSSItems = 200 // RSS 条目上限 ) var generateMu sync.Mutex // RSS 2.0 结构定义 type rssFeed struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` Atom string `xml:"xmlns:atom,attr"` Channel rssChannel `xml:"channel"` } type rssChannel struct { Title string `xml:"title"` Link string `xml:"link"` Description string `xml:"description"` Language string `xml:"language"` LastBuildDate string `xml:"lastBuildDate"` AtomLink atomLink `xml:"atom:link"` Items []rssItem `xml:"item"` } type atomLink struct { Href string `xml:"href,attr"` Rel string `xml:"rel,attr"` Type string `xml:"type,attr"` } type rssItem struct { Title string `xml:"title"` Link string `xml:"link"` Description string `xml:"description"` PubDate string `xml:"pubDate"` GUID string `xml:"guid"` Enclosure *rssEnclosure `xml:"enclosure,omitempty"` } type rssEnclosure struct { URL string `xml:"url,attr"` Type string `xml:"type,attr"` Length string `xml:"length,attr"` } // GenerateRSS 生成 rss.xml,输出到 outDir/rss.xml(Web 侧通过 /rss.xml 静态暴露) func GenerateRSS(ctx context.Context, outDir string) { generateMu.Lock() defer generateMu.Unlock() scfg, srvErr := configser.GetSystemConfig(ctx) if srvErr != nil || scfg == nil { log.Error("GenerateRSS GetSystemConfig", log.Any("err", srvErr)) return } // 查询最新的已上架视频 filter := bson.M{ videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, } opt := options.Find(). SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}). SetLimit(int64(maxRSSItems)) videoList, vsErr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt) if vsErr != nil { log.Error("GenerateRSS FindManyVideoInformations", log.Any("err", vsErr)) return } items := buildRSSItems(scfg, videoList) feedURL := utils.JoinUrlPath(scfg.WebAddr, "/rss.xml") feed := rssFeed{ Version: "2.0", Atom: "http://www.w3.org/2005/Atom", Channel: rssChannel{ Title: scfg.WebTitle, Link: scfg.WebAddr, Description: scfg.WebDescription, Language: "zh-cn", LastBuildDate: time.Now().Format(time.RFC1123Z), AtomLink: atomLink{ Href: feedURL, Rel: "self", Type: "application/rss+xml", }, Items: items, }, } const filename = "rss.xml" if err := writeRSSFile(outDir, filename, feed); err != nil { log.Error("GenerateRSS write", log.Any("err", err)) return } // 写入成功,rename .tmp → 正式文件 src := filepath.Join(outDir, filename+".tmp") dst := filepath.Join(outDir, filename) if err := os.Rename(src, dst); err != nil { log.Error("GenerateRSS rename", log.Any("err", err)) os.Remove(src) return } log.Info("GenerateRSS done", log.Any("items", len(items))) } // absModIndex 根据 id 确定性选取切片索引,保证结果非负 func absModIndex(id int64, length int) int { idx := id % int64(length) if idx < 0 { idx = -idx } return int(idx) } func buildRSSItems(scfg *cfg.SysCfg, videoList videoinformationmod.VideoInformationList) []rssItem { logoURL := utils.JoinUrlPath(scfg.WebAddr, "/static/images/logo.png") items := make([]rssItem, 0, len(videoList)) for _, v := range videoList { desc := v.Desc if desc == "" { desc = v.Title } pageURL := utils.JoinUrlPathKeepSlash(scfg.WebAddr, fmt.Sprintf("/video/detail?id=%d", v.Id)) pubDate := "" if !v.AddedTime.IsZero() { pubDate = v.AddedTime.Format(time.RFC1123Z) } item := rssItem{ Title: v.Title, Link: pageURL, Description: desc, PubDate: pubDate, GUID: pageURL, } // 封面图优先级:UnencryptedImage > SitemapDefaultImages 确定性选取 > logo encURL := "" encType := "image/jpeg" if v.UnencryptedImage != "" && scfg.UnencryptedCdn != "" { encURL = netutil.MergeURL(scfg.UnencryptedCdn, v.UnencryptedImage) } else if len(scfg.SitemapDefaultImages) > 0 && scfg.UnencryptedCdn != "" { img := scfg.SitemapDefaultImages[absModIndex(v.Id, len(scfg.SitemapDefaultImages))] encURL = netutil.MergeURL(scfg.UnencryptedCdn, img) } else { encURL = logoURL encType = "image/png" } if strings.HasSuffix(encURL, ".png") { encType = "image/png" } else if strings.HasSuffix(encURL, ".webp") { encType = "image/webp" } item.Enclosure = &rssEnclosure{ URL: encURL, Type: encType, Length: "0", } items = append(items, item) } return items } // writeRSSFile 将数据写入 rssDir/filename.tmp,由调用方负责 rename func writeRSSFile(rssDir, filename string, data interface{}) error { if err := os.MkdirAll(rssDir, 0755); err != nil { return fmt.Errorf("mkdir %s: %w", rssDir, err) } path := filepath.Join(rssDir, filename+".tmp") file, err := os.Create(path) if err != nil { return fmt.Errorf("create %s: %w", filename, err) } defer file.Close() if _, err := file.WriteString(xml.Header); err != nil { return fmt.Errorf("write header %s: %w", filename, err) } encoder := xml.NewEncoder(file) encoder.Indent("", " ") if err := encoder.Encode(data); err != nil { return fmt.Errorf("encode %s: %w", filename, err) } if err := encoder.Flush(); err != nil { return fmt.Errorf("flush %s: %w", filename, err) } return file.Sync() }