package configser import ( "context" "regexp" "strconv" "strings" "sync" "videoweb/app/api/proto" "videoweb/app/service/translationser" "videoweb/pub/common/log" "videoweb/pub/common/netutil" "videoweb/pub/common/srvdef" "videoweb/pub/models/announcementmod" "videoweb/pub/models/cfg" "videoweb/pub/models/ipblackmod" "videoweb/pub/models/richtextcontentmod" "videoweb/pub/models/videotagmod" "github.com/robfig/cron/v3" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) var ( CacheMgr CacheInfo // skdCron *cron.Cron ) // richTextImgSrcRe 匹配富文本HTML里 img 标签的 src 图片地址,兼容单引号、双引号和JSON转义后的双引号。 var richTextImgSrcRe = regexp.MustCompile(`(?i)(]*?\s*)src\s*=\s*(\\?"([^"\\]*)\\?"|'([^']*)')`) type CacheInfo struct { Lock sync.RWMutex // 读写锁 Config proto.ConfigInfo // 系统配置缓存 Advitise proto.AdvitiseData TagCategoryList []proto.VideoTagCategoryData TotalHistory int64 IpBlackMaps map[string]struct{} TagMaps map[int64]proto.TagsInfoData NewAddressInfo proto.NewAddressInfo AppRichTexts []proto.AppRichTextItemResp Announcements []*proto.AnnouncementItem } type appRichTextCacheItem struct { Id int64 `bson:"_id,omitempty"` Title string `bson:"title"` Content string `bson:"content"` Desc string `bson:"desc"` } func init() { skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务 CacheMgr.Config = proto.ConfigInfo{} CacheMgr.IpBlackMaps = make(map[string]struct{}) } func Start() { ctx := context.Background() UpdateConfigInfo(ctx) UpdateAdvitiseData(ctx) updateIpBlack() updateTagsCache() updateAppRichTexts() updateAnnouncements() _, _ = skdCron.AddFunc("0 */5 * * * ?", func() { UpdateConfigInfo(ctx) UpdateAdvitiseData(ctx) updateIpBlack() updateTagsCache() updateAppRichTexts() }) _, _ = skdCron.AddFunc("0 */10 * * * ?", func() { updateAnnouncements() }) skdCron.Start() } func updateTagsCache() { filter := bson.M{videotagmod.FN.VideoTagInfo.Status: true} opts := &options.FindOptions{} opts.SetLimit(5000) opts.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Sort: -1}) list, err := videotagmod.FindManyVideoTagInfos(context.Background(), filter, opts) if err != nil { log.Warn("updateTaggCache.FFindManyVideoTagInfos", log.Any("err", err)) return } tempMaps := make(map[int64]proto.TagsInfoData) for _, v := range list { tempMaps[v.Id] = proto.TagsInfoData{ Id: v.Id, Name: v.Title, Slug: v.Slug, SeoInfo: v.SeoInfo, } } CacheMgr.Lock.Lock() defer CacheMgr.Lock.Unlock() CacheMgr.TagMaps = tempMaps } func UpdateConfigInfo(ctx context.Context) { config := proto.ConfigInfo{} newAddressInfo := proto.NewAddressInfo{} filter := bson.M{cfg.FN.SysCfg.Id: 1} sysCfg, _ := cfg.FindOneSysCfg(context.Background(), filter) if sysCfg == nil { return } config.NewUrlAddr = sysCfg.NewUrlAddr config.NewUrlAddr1 = sysCfg.NewUrlAddr1 config.NewUrlAddr2 = sysCfg.NewUrlAddr2 config.ForeverUrlAddr = sysCfg.ForeverUrlAddr config.GmailAddr = sysCfg.GmailAddr config.AppAddr = sysCfg.AppAddr config.OfficialAppAddr = sysCfg.OfficialAppAddr config.WeiChart = sysCfg.WeiChart config.QQ = sysCfg.QQ config.TgGroup = sysCfg.TgGroup config.PotaoGroup = sysCfg.PotaoGroup config.GitAddr = sysCfg.GitAddr config.TwitterAddr = sysCfg.TwitterAddr config.Telegram = sysCfg.Telegram config.TelegramAdd = sysCfg.TelegramAdd config.TranslationAddr = sysCfg.TranslationAddr config.ImgCdn = sysCfg.ImgAppCdn config.TsCdn = sysCfg.MediaAppCdn config.ImgAppUsCdn = sysCfg.ImgAppUsCdn config.MediaAppUsCdn = sysCfg.MediaAppUsCdn config.PdfAddr = sysCfg.PdfAddr config.WebTitle = sysCfg.WebTitle config.WebKeyworld = sysCfg.WebKeyworld config.WebDescription = sysCfg.WebDescription config.WebSiteName = sysCfg.WebSiteName config.WebAddr = sysCfg.WebAddr config.DHNewUrlAddr1 = sysCfg.DHNewUrlAddr1 config.DHNewUrlAddr2 = sysCfg.DHNewUrlAddr2 config.DHNewUrlAddr3 = sysCfg.DHNewUrlAddr3 config.UseIngAddress = sysCfg.UseAddress config.FreeVPN = sysCfg.FreeVPN config.AdvitiesTopicNum = sysCfg.AdvitiesTopicNum config.DHKeyworld = sysCfg.DHKeyworld config.DHDescription = sysCfg.DHDescription config.DHSiteName = sysCfg.DHSiteName config.DHTitle = sysCfg.DHTitle config.ActressBackGround = netutil.MergeURL(sysCfg.ImgAppCdn, sysCfg.ActressBackGround) config.WatermarkImage = sysCfg.WatermarkImage config.CustomerServiceHost = sysCfg.CustomerServiceHost config.ChannelCode = sysCfg.ChannelCode config.EnableH265 = sysCfg.EnableH265 // NewAddressInfo 来源于系统配置 sys_cfg,用于只返回前端需要展示的最新地址信息。 newAddressInfo.NewUrlAddr = sysCfg.NewUrlAddr newAddressInfo.NewUrlAddr1 = sysCfg.NewUrlAddr1 newAddressInfo.NewUrlAddr2 = sysCfg.NewUrlAddr2 newAddressInfo.ForeverUrlAddr = sysCfg.ForeverUrlAddr newAddressInfo.GmailAddr = sysCfg.GmailAddr newAddressInfo.TgGroup = sysCfg.TgGroup newAddressInfo.Telegram = sysCfg.Telegram newAddressInfo.CustomerServiceHost = sysCfg.CustomerServiceHost newAddressInfo.TwitterAddr = sysCfg.TwitterAddr newAddressInfo.PdfAddr = sysCfg.PdfAddr newAddressInfo.AppAddr = sysCfg.AppAddr newAddressInfo.OfficialAppAddr = sysCfg.OfficialAppAddr CacheMgr.Lock.Lock() defer CacheMgr.Lock.Unlock() CacheMgr.Config = config CacheMgr.NewAddressInfo = newAddressInfo } func GetConfigInfo() proto.ConfigInfo { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() return CacheMgr.Config } func GetAdvitise() proto.AdvitiseData { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() return CacheMgr.Advitise } func GetNewAddress() proto.NewAddressInfo { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() return CacheMgr.NewAddressInfo } // GetFooterInfo 查询APP底部信息 func GetFooterInfo() proto.FooterInfo { // 底部富文本和系统配置都来自缓存,接口请求不直接访问Mongo。 config := GetConfigInfo() return proto.FooterInfo{ Brand: buildFooterBrand(config), NavList: buildFooterNavList(), ContactList: buildFooterContactList(config), } } func getFooterRichTextMap() map[string]proto.AppRichTextItemResp { data := getCachedAppRichTexts() if len(data) == 0 { updateAppRichTexts() data = getCachedAppRichTexts() } result := make(map[string]proto.AppRichTextItemResp, len(data)) for _, item := range data { title := strings.TrimSpace(item.Title) if title == "" { continue } result[title] = item } return result } func buildFooterBrand(config proto.ConfigInfo) proto.FooterBrand { return proto.FooterBrand{ Title: config.WebSiteName, Content: config.WebDescription, } } func buildFooterNavList() []proto.TranslationCategory { bottom := translationser.GetTranslationBottomList() return append([]proto.TranslationCategory{}, bottom.CategoryList...) } func buildFooterContactList(config proto.ConfigInfo) []proto.FooterContact { // 联系方式名称按页面截图固定返回,右侧只包含 E-mail、商务合作、官方群 三项。 return []proto.FooterContact{ {Name: "E-mail", Value: config.GmailAddr}, {Name: "商务合作:立即联系", Value: config.Telegram}, {Name: "官方群:立即加入", Value: firstNotEmpty(config.TgGroup, config.TelegramAdd)}, } } func firstNotEmpty(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) if value != "" { return value } } return "" } // GetAppRichTextList 查询APP富文本内容列表 func GetAppRichTextList(rawIds string, rawTitles string) proto.AppRichTextListResp { ids := parseInt64Set(rawIds) titles := parseStringSet(rawTitles) data := getCachedAppRichTexts() if len(data) == 0 { updateAppRichTexts() data = getCachedAppRichTexts() } return proto.AppRichTextListResp{List: filterAppRichTexts(data, ids, titles)} } func getCachedAppRichTexts() []proto.AppRichTextItemResp { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() return append([]proto.AppRichTextItemResp{}, CacheMgr.AppRichTexts...) } func filterAppRichTexts(data []proto.AppRichTextItemResp, ids map[int64]struct{}, titles map[string]struct{}) []proto.AppRichTextItemResp { list := make([]proto.AppRichTextItemResp, 0, len(data)) for _, item := range data { if len(ids) > 0 { if _, ok := ids[item.Id]; !ok { continue } } if len(titles) > 0 { if _, ok := titles[item.Title]; !ok { continue } } list = append(list, item) } return list } func parseInt64Set(raw string) map[int64]struct{} { raw = strings.TrimSpace(raw) if raw == "" { return nil } resp := make(map[int64]struct{}) for _, item := range strings.Split(raw, ",") { id, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64) if err != nil || id <= 0 { continue } resp[id] = struct{}{} } return resp } func parseStringSet(raw string) map[string]struct{} { raw = strings.TrimSpace(raw) if raw == "" { return nil } resp := make(map[string]struct{}) for _, item := range strings.Split(raw, ",") { item = strings.TrimSpace(item) if item == "" { continue } resp[item] = struct{}{} } return resp } // GetSiteSeoInfo 返回全站SEO配置,供统一渲染 title、meta keywords、description 和 canonical。 func GetSiteSeoInfo() proto.SeoInfo { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() webAddr := normalizeWebAddr(CacheMgr.Config.WebAddr) return proto.SeoInfo{ Title: CacheMgr.Config.WebTitle, Keywords: append([]string{}, CacheMgr.Config.WebKeyworld...), Description: CacheMgr.Config.WebDescription, WebSiteName: CacheMgr.Config.WebSiteName, CanonicalURL: webAddr, } } // GetNavSeoInfo 返回导航页SEO配置 func GetNavSeoInfo() proto.SeoNavInfo { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() return proto.SeoNavInfo{ DHTitle: CacheMgr.Config.DHTitle, DHKeyworld: append([]string{}, CacheMgr.Config.DHKeyworld...), DHDescription: CacheMgr.Config.DHDescription, DHSiteName: CacheMgr.Config.DHSiteName, } } // GetNavPageList 返回导航页线路集合。 func GetNavPageList() []proto.NavPageItem { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() list := make([]proto.NavPageItem, 0, 3) for _, item := range []proto.NavPageItem{ {Line: 1, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr1)}, {Line: 2, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr2)}, {Line: 3, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr3)}, } { if item.Url == "" { continue } list = append(list, item) } return list } func normalizeWebAddr(webAddr string) string { webAddr = strings.TrimSpace(webAddr) webAddr = strings.TrimRight(webAddr, "/") if webAddr == "" { return "" } if !strings.HasPrefix(webAddr, "http://") && !strings.HasPrefix(webAddr, "https://") { return "https://" + webAddr } return webAddr } func GetRandCDN() string { config := GetConfigInfo() return config.TsCdn } func updateIpBlack() { ctx := context.Background() list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{}) if err != nil { log.Error("updateIpBlack FindManyIPBlackModel error", log.E(err)) return } tempMaps := make(map[string]struct{}) for _, v := range list { tempMaps[v.Ip] = struct{}{} } CacheMgr.Lock.Lock() defer CacheMgr.Lock.Unlock() CacheMgr.IpBlackMaps = tempMaps } func updateAppRichTexts() { resp, err := queryAppRichTexts(context.Background()) if err != nil { log.Warn("查询APP富文本内容失败", log.E(err)) return } CacheMgr.Lock.Lock() defer CacheMgr.Lock.Unlock() CacheMgr.AppRichTexts = resp } // queryAppRichTexts 查询启用状态的APP富文本内容,统一供定时缓存和接口兜底加载使用。 func queryAppRichTexts(ctx context.Context) ([]proto.AppRichTextItemResp, *srvdef.SrvErr) { filter := bson.M{ richtextcontentmod.FN.RichTextContentModel.Status: true, } opts := options.Find(). SetProjection(bson.M{ richtextcontentmod.FN.RichTextContentModel.Id: 1, richtextcontentmod.FN.RichTextContentModel.Title: 1, richtextcontentmod.FN.RichTextContentModel.Content: 1, richtextcontentmod.FN.RichTextContentModel.Desc: 1, }). SetSort(bson.D{ {Key: richtextcontentmod.FN.RichTextContentModel.Sort, Value: -1}, {Key: richtextcontentmod.FN.RichTextContentModel.UpdatedAt, Value: -1}, }). SetLimit(100) list := make([]appRichTextCacheItem, 0) err := richtextcontentmod.FindManyRichTextContentModelModelWithBind(ctx, &list, filter, opts) if err != nil { log.Warn("查询APP富文本内容失败", log.Any("filter", filter), log.E(err)) return nil, err } imgCdn := GetConfigInfo().ImgCdn resp := make([]proto.AppRichTextItemResp, 0, len(list)) for _, item := range list { resp = append(resp, proto.AppRichTextItemResp{ Id: item.Id, Title: item.Title, Content: applyRichTextImgSrcCdn(imgCdn, item.Content), Desc: item.Desc, }) } return resp, nil } // applyRichTextImgSrcCdn 给富文本 content 中 img 标签的 src 相对图片地址拼接图片CDN func applyRichTextImgSrcCdn(imgCdn string, content string) string { if imgCdn == "" || content == "" { return content } return richTextImgSrcRe.ReplaceAllStringFunc(content, func(match string) string { parts := richTextImgSrcRe.FindStringSubmatch(match) if len(parts) < 5 { return match } prefix := parts[1] quote := `"` src := parts[3] if src == "" { quote = `'` src = parts[4] } else if strings.HasPrefix(parts[2], `\"`) { quote = `\"` } src = strings.TrimSpace(src) if src == "" || isAbsoluteRichTextSrc(src) { return match } return prefix + "src=" + quote + netutil.MergeURL(imgCdn, src) + quote }) } // isAbsoluteRichTextSrc 判断图片地址是否已经是完整地址或特殊协议,避免重复拼接CDN。 func isAbsoluteRichTextSrc(src string) bool { lowerSrc := strings.ToLower(src) return strings.HasPrefix(lowerSrc, "http://") || strings.HasPrefix(lowerSrc, "https://") || strings.HasPrefix(lowerSrc, "data:") || strings.HasPrefix(lowerSrc, "blob:") || strings.HasPrefix(src, "//") } func updateAnnouncements() { list, err := queryAnnouncements(context.Background()) if err != nil { log.Warn("查询公告失败", log.E(err)) return } CacheMgr.Lock.Lock() defer CacheMgr.Lock.Unlock() CacheMgr.Announcements = list } func queryAnnouncements(ctx context.Context) ([]*proto.AnnouncementItem, *srvdef.SrvErr) { filter := bson.M{announcementmod.FN.Announcement.Status: true} opts := options.Find(). SetProjection(bson.M{ announcementmod.FN.Announcement.Id: 1, announcementmod.FN.Announcement.Title: 1, announcementmod.FN.Announcement.Content: 1, }). SetSort(bson.D{ {Key: announcementmod.FN.Announcement.Sort, Value: -1}, {Key: announcementmod.FN.Announcement.UpdatedAt, Value: -1}, }). SetLimit(50) list, err := announcementmod.FindManyAnnouncement(ctx, filter, opts) if err != nil { log.Warn("查询公告失败", log.Any("filter", filter), log.E(err)) return nil, err } resp := make([]*proto.AnnouncementItem, 0, len(list)) for _, item := range list { if item == nil { continue } resp = append(resp, &proto.AnnouncementItem{ Id: item.Id, Title: item.Title, Content: item.Content, }) } return resp, nil } // GetAnnouncements 返回启用中的公告缓存,每10分钟刷新。 func GetAnnouncements() []*proto.AnnouncementItem { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() if len(CacheMgr.Announcements) == 0 { return make([]*proto.AnnouncementItem, 0) } resp := make([]*proto.AnnouncementItem, len(CacheMgr.Announcements)) copy(resp, CacheMgr.Announcements) return resp } func CheckIpBlack(ip string) bool { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() if _, ok := CacheMgr.IpBlackMaps[ip]; ok { return true } return false } func GetVideoTagCategoryDataList() []proto.VideoTagCategoryData { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() rsl := make([]proto.VideoTagCategoryData, 0) for _, v := range CacheMgr.TagCategoryList { rsl = append(rsl, v) } return rsl } func Stop() { skdCron.Stop() } func GetTag(tagId int64) *proto.TagsInfoData { CacheMgr.Lock.RLock() defer CacheMgr.Lock.RUnlock() tag, ok := CacheMgr.TagMaps[tagId] if !ok { return nil } return &tag }