package echomeet /* 缓存组件:把高频访问的 public 公共模板加载到 Redis,并对外提供读取接口。 public 模板存于 admin MySQL(海外),所有用户共享、每次 GetTemplates 都要读取,是慢查询热点。 本组件采用"全量预热 + 定时刷新 + 事件驱动刷新"策略,使读请求恒命中 Redis: - 预热:Start() 时全量写入 Redis - 定时刷新:内部 cron 每 10 分钟全量刷新 - 事件驱动:后台增删改 public 模板触发 Rpc_ModifyEchomeetTemplate,由本组件监听并刷新 用户私有模板(ServiceDB,高基数、专属)不预热,相关读取在缓存未命中时回退查 DB。 */ import ( "context" "yunyan/comm" "yunyan/lego/base" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/cron" "yunyan/lego/sys/log" "yunyan/pb" "yunyan/sys/cache" natssys "yunyan/sys/nats" "encoding/json" "fmt" "time" gonats "github.com/nats-io/nats.go" ) // 缓存兜底 TTL:正常由定时/事件全量刷新覆盖,TTL 仅作异常情况下的过期保护 const cacheTTL = time.Hour * 24 type modelCacheComp struct { cbase.ModuleCompBase module *Echomeet service base.IRPCXService } func (this *modelCacheComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { this.ModuleCompBase.Init(service, module, comp, opt) this.module = module.(*Echomeet) this.service = service.(base.IRPCXService) return } func (this *modelCacheComp) Start() (err error) { if err = this.ModuleCompBase.Start(); err != nil { return } this.service.Register(string(comm.Rpc_ModifyEchomeetTemplate), this.Rpc_ModifyEchomeetTemplate) //事件驱动刷新 go this.Refresh() //启动预热(异步,避免海外 DB 慢查询阻塞模块启动) cron.AddFunc("0 */10 * * * ?", this.Refresh) //每 10 分钟全量刷新 this.subscribeConfigChanged() //订阅 console 配置变更广播 return } // subscribeConfigChanged 订阅 console 的「配置已变更」广播(core NATS pub/sub,fanout)。 // 收到 template 类即全量刷新 public 模板缓存(与 Rpc_ModifyEchomeetTemplate 同效); // 其余类型本模块不关心。NATS 未就绪则跳过——仍有 10 分钟定时刷新兜底。 func (this *modelCacheComp) subscribeConfigChanged() { conn := natssys.Conn() if conn == nil { this.module.Warn("echomeet: NATS 未就绪,配置变更广播订阅未启用(定时刷新兜底)") return } if _, err := conn.Subscribe(comm.Nats_ConfigChangedSubject, func(msg *gonats.Msg) { var ev comm.ConfigChangedEvent if e := json.Unmarshal(msg.Data, &ev); e != nil { return } if ev.Kind == comm.ConfigKindTemplate { this.Refresh() this.module.Infof("echomeet: 按配置变更广播刷新模板缓存 action=%s", ev.Action) } }); err != nil { this.module.Error("echomeet: 订阅配置变更广播失败", log.Field{Key: "err", Value: err.Error()}) } } // 事件驱动:后台增删改 public 模板后刷新 Redis 缓存 func (this *modelCacheComp) Rpc_ModifyEchomeetTemplate(ctx context.Context, args *pb.Rpc_EmptyReq, reply *pb.Rpc_EmptyResp) (err error) { this.Refresh() return } // Refresh 全量刷新缓存数据集到 Redis。best-effort:失败仅记日志 func (this *modelCacheComp) Refresh() { models, err := this.module.model.getcommtemplates("") if err != nil { this.module.Error("refresh echomeet template load db", log.Field{Key: "err", Value: err.Error()}) return } items := make(map[string]any, len(models)) for _, m := range models { items[fmt.Sprintf("%d", m.Id)] = m } if err = cache.ReplaceAll(context.Background(), comm.Cache_EchomeetTemplate, items, cacheTTL); err != nil { this.module.Error("refresh echomeet template write redis", log.Field{Key: "err", Value: err.Error()}) } } // ---- 对外读取接口(缓存优先 + DB 回退)----------------------------------------- // GetCommTemplates 读取 public 公共模板:缓存优先,language 为空返回全部,否则按语言过滤 func (this *modelCacheComp) GetCommTemplates(language string) (models []*pb.DBEchoMeetTemplate, err error) { list, cerr := cache.GetAll[pb.DBEchoMeetTemplate](context.Background(), comm.Cache_EchomeetTemplate) if cerr != nil || len(list) == 0 { return this.module.model.getcommtemplates(language) //回退查 DB } models = make([]*pb.DBEchoMeetTemplate, 0, len(list)) for _, m := range list { if language == "" || m.Language == language { models = append(models, m) } } if len(models) > 0 || language == "" { return // 精确命中(或不限语言) } // 回退:该区域 locale 无专属模板时,取同基础语言变体(如 es-MX→es/es-ES),按 tid 去重保留 id 最小一条 if base := baseLang(language); base != "" && base != language { models = pickBaseLangVariants(list, base) } return } // GetTemplateForId 按 id 读取模板:公共模板(id<起点)先查 public 缓存,未命中回退 DB; // 私有模板(id>=起点)不在公共缓存内,直接查 DB(避免与公共模板 id 误命中) func (this *modelCacheComp) GetTemplateForId(id uint64) (template *pb.DBEchoMeetTemplate, err error) { if id < templatePrivateIdStart { if v, found, cerr := cache.GetOne[pb.DBEchoMeetTemplate](context.Background(), comm.Cache_EchomeetTemplate, fmt.Sprintf("%d", id)); cerr == nil && found { return v, nil } } return this.module.model.gettemplateforid(id) } // GetTemplateForTid 按 tid+language 读取模板:先扫 public 缓存命中即返回,否则回退 DB。 // 精确匹配不到时,回退到同一基础语言的任一变体(如 es-MX→es-ES、zh-TW→zh-CN), // 避免区域 locale 没有专属模板时解析失败;候选取 id 最小的一条保证结果稳定。 func (this *modelCacheComp) GetTemplateForTid(tid string, language string) (template *pb.DBEchoMeetTemplate, err error) { if list, cerr := cache.GetAll[pb.DBEchoMeetTemplate](context.Background(), comm.Cache_EchomeetTemplate); cerr == nil && len(list) > 0 { base := baseLang(language) var fallback *pb.DBEchoMeetTemplate for _, m := range list { if m.Tid != tid { continue } if m.Language == language { return m, nil // 精确命中 } if base != "" && base != language && baseLang(m.Language) == base { if fallback == nil || m.Id < fallback.Id { fallback = m } } } if fallback != nil { return fallback, nil } } return this.module.model.gettemplatefortid(tid, language) } // ResolveTemplate 解析模板(与 model.resolvetemplate 行为一致): // - 私有模板(templateid>=起点):单条、无多语言版本,按主键直取,忽略 tid/language; // - 公共模板:tid 非空按 tid+language 取对应语言版本,否则按主键。 func (this *modelCacheComp) ResolveTemplate(tid string, templateid uint64, language string) (template *pb.DBEchoMeetTemplate, err error) { if templateid >= templatePrivateIdStart { return this.GetTemplateForId(templateid) } if tid != "" { return this.GetTemplateForTid(tid, language) } return this.GetTemplateForId(templateid) }