package echomeet import ( "errors" "fmt" "path/filepath" "runtime" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" "gorm.io/gorm/clause" ) // templatePrivateIdStart 模板 id 分段阈值(唯一的归属判定依据): // - id < 此值 → 公共模板,存公共数据库(Postgres),可有多语言版本; // - id >= 此值 → 用户私有模板,存业务服务数据库(MySQL),单条、无多语言版本。 // // 由建表时对私有模板表设置自增起点保证两段 id 不重叠(见 modelComp.Init)。 const templatePrivateIdStart = 10000 // 代理模型 type modelComp struct { cbase.ModuleCompBase module *Echomeet } // 组件初始化接口 func (this *modelComp) 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) if err = mysql.CreateTable(comm.TableEchomeetTemplate, &pb.DBEchoMeetTemplate{}); err == nil { // 私有模板 id 从分段起点(10000)开始递增,确保与公共模板(公共库,id<10000)id 段不重叠, // 从而 gettemplateforid 能仅凭 id 把请求路由到正确的库(仅空表生效)。 mysql.AutoIncrementStart(comm.TableEchomeetTemplate, "id", templatePrivateIdStart) } if err = mysql.CreateTable(comm.TableEchomeetRecord, &pb.DBEchoMeetRecord{}); err != nil { return } this.ensureRecordFulltextIndex() return } // 会议纪要全文检索索引的名字与列。 // // ⚠️ 列的顺序和个数**必须与查询里的 MATCH(...) 完全一致**,MySQL 才会用上这个索引; // 少一列多一列都会报 "Can't find FULLTEXT index matching the column list"。 // 改这里就要同步改 modules/mcp/tool_meeting.go 的 fulltextColumns。 const ( recordFulltextIndex = "ft_echomeet_record_content" recordFulltextColumns = "title,summary,overview,remark" ) // ensureRecordFulltextIndex 给会议记录建 ngram 全文索引(幂等,失败不阻断启动)。 // // 为什么要它:MCP 的 search_meeting_notes 原来用 `LIKE %kw%` 逐个关键词 AND, // 中文场景下召回极差——用户问「关于降本增效的会议」,纪要里写的是「成本优化」就一条都匹配不到, // 而且每个关键词都是全表扫描。换成全文索引后按相关度排序,多关键词是 OR + 打分, // 召回和排序都合理得多。 // // ⚠️ 用 ngram 解析器,因为默认解析器按空格切词,中文整段会被当成一个巨型 token,等于没索引。 // 服务器上 `ngram_token_size=2`,所以**单字关键词检索不到**(工具那边对单字回退用 LIKE)。 // // ⚠️ 失败只记日志:索引建不出来(权限不足、旧版 MySQL 不支持 ngram)时, // 检索侧会自动退回 LIKE,功能降级但不能因此让 home 服务起不来。 func (this *modelComp) ensureRecordFulltextIndex() { var n int64 if err := mysql.Table("information_schema.STATISTICS"). Where("TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?", comm.TableEchomeetRecord, recordFulltextIndex). Count(&n).Error; err != nil { this.module.Warnf("检查会议全文索引失败,跳过建索引: %v", err) return } if n > 0 { return } sql := fmt.Sprintf("CREATE FULLTEXT INDEX %s ON `%s` (%s) WITH PARSER ngram", recordFulltextIndex, comm.TableEchomeetRecord, recordFulltextColumns) if err := mysql.Exec(sql).Error; err != nil { this.module.Warnf("创建会议全文索引失败(检索将退回 LIKE): %v", err) return } this.module.Infof("会议全文索引已创建: %s(%s)", recordFulltextIndex, recordFulltextColumns) } func (this *modelComp) getuser(uid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "uid=?", uid) return } func (this *modelComp) updateuser(user *pb.DBUser) (err error) { err = mysql.Save(comm.TableUser, user) return } // d读取模版 // 2026-09-14 起公共模板按应用隔离(后台「会议模板」页按应用管),作用域键 = 本部署的 comm.AppName()。 // 表里的 app_name 列由 console 用 ALTER 补上,pb struct 没这个字段——扫描时 gorm 忽略多出来的列, // 只在 WHERE 里用它就行。 func (this *modelComp) getcommtemplates(language string) (models []*pb.DBEchoMeetTemplate, err error) { models = make([]*pb.DBEchoMeetTemplate, 0) app := comm.AppName() if language == "" { err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ? AND app_name = ?", "public", app) return } if err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ? AND app_name = ? AND language = ?", "public", app, language); err != nil { return } if len(models) > 0 { return // 精确命中 } // 回退:该区域 locale 无专属模板时,取同基础语言变体(裸 base 或区域变体,如 es-MX→es/es-ES), // 按 tid 去重保留 id 最小一条,避免同一模板出现多条。 if base := baseLang(language); base != "" && base != language { var variants []*pb.DBEchoMeetTemplate if err = postgres.Find(comm.TableEchomeetTemplate, &variants, "source = ? AND app_name = ? AND (language = ? OR language LIKE ?)", "public", app, base, base+"-%"); err != nil { return } models = pickBaseLangVariants(variants, base) } return } // pickBaseLangVariants 从候选模板里挑出与 base 同基础语言的,按 tid 去重保留 id 最小一条。 // 用于区域 locale(如 es-MX)无专属模板时回退到同基础语言那套(es / es-ES)做列表,避免同 tid 重复。 func pickBaseLangVariants(candidates []*pb.DBEchoMeetTemplate, base string) []*pb.DBEchoMeetTemplate { byTid := make(map[string]*pb.DBEchoMeetTemplate) for _, m := range candidates { if baseLang(m.Language) != base { continue } if cur, ok := byTid[m.Tid]; !ok || m.Id < cur.Id { byTid[m.Tid] = m } } out := make([]*pb.DBEchoMeetTemplate, 0, len(byTid)) for _, m := range byTid { out = append(out, m) } return out } // 读取模版 func (this *modelComp) gettemplates(uid string, language string) (models []*pb.DBEchoMeetTemplate, err error) { models = make([]*pb.DBEchoMeetTemplate, 0) if language == "" { err = mysql.Find(comm.TableEchomeetTemplate, &models, "source = ?", uid) } else { err = mysql.Find(comm.TableEchomeetTemplate, &models, "source = ? AND language = ?", uid, language) } return } /* gettemplateforid 按主键读取模板,仅凭 id 段判定归属并路由到对应库: - id < templatePrivateIdStart:公共模板 → 公共数据库(Postgres) - id >= templatePrivateIdStart:用户私有模板 → 业务服务数据库(MySQL) 私有模板单条、无多语言版本,按主键直接取即可。 */ func (this *modelComp) gettemplateforid(id uint64) (template *pb.DBEchoMeetTemplate, err error) { template = &pb.DBEchoMeetTemplate{} if id >= templatePrivateIdStart { err = mysql.FindOne(comm.TableEchomeetTemplate, template, "id=?", id) return } err = postgres.FindOne(comm.TableEchomeetTemplate, template, "id=?", id) return } // resolvetemplate 私有模板(id>=起点)单条无多语言,按主键直取; // 公共模板 tid 非空按 tid+language 取对应语言版本,否则按主键。 func (this *modelComp) 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) } // gettemplatefortid 按 tid + language 查找模板(用于公共模板)。 // 精确匹配不到时,回退到同一基础语言的任一变体(如 es-MX→es-ES、zh-TW→zh-CN), // 避免区域 locale 没有专属模板时解析失败;FindOne→gorm First 默认按主键 id 升序,取最小一条保证结果稳定。 func (this *modelComp) gettemplatefortid(tid string, language string) (template *pb.DBEchoMeetTemplate, err error) { template = &pb.DBEchoMeetTemplate{} app := comm.AppName() if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "app_name=? AND tid=? AND language=?", app, tid, language); err == nil { return } if err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND language=?", tid, language); err == nil { return } // 回退:同基础语言(裸 base 或其任一区域变体,如 es / es-ES 都能兜底 es-MX) if base := baseLang(language); base != "" && base != language { if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "app_name=? AND tid=? AND (language=? OR language LIKE ?)", app, tid, base, base+"-%"); err == nil { return } err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND (language=? OR language LIKE ?)", tid, base, base+"-%") } return } func (this *modelComp) addtemplates(template *pb.DBEchoMeetTemplate) (err error) { err = mysql.Insert(comm.TableEchomeetTemplate, template) return } func (this *modelComp) updatetemplates(template *pb.DBEchoMeetTemplate) (err error) { err = mysql.Save(comm.TableEchomeetTemplate, template) return } func (this *modelComp) deltemplates(id []uint64) (err error) { err = mysql.Delete(comm.TableEchomeetTemplate, "id IN ?", id) return } // 查询用户记录的精简信息(不含 original、translate、summary 大字段),新到旧。 // 列表接口 GetAllRecords 用;Omit 掉的字段序列化后是空串不是缺键,客户端不会解析失败。 func (this *modelComp) getrecordbriefforuid(uid string) (models []*pb.DBEchoMeetRecord, err error) { models = make([]*pb.DBEchoMeetRecord, 0) err = mysql.Table(comm.TableEchomeetRecord). Omit("original", "translate", "summary"). Where("uid = ?", uid). Order("creationtime DESC, id DESC"). Find(&models).Error return } // ⚠️ 不带 uid 的 getrecord / getrecords / delrecords **只给回调与后台兜底任务用** // (第三方 ASR 回调没有 session;sweepStuck* 按 state 扫全表)。 // 凡是由客户端按 id 指定记录的接口,一律走下面三个带 uid 的版本 —— // 不然任何登录用户改一个 id 就能读改删别人的纪要。 func (this *modelComp) getrecord(id uint64) (model *pb.DBEchoMeetRecord, err error) { model = &pb.DBEchoMeetRecord{} err = mysql.FindOne(comm.TableEchomeetRecord, model, "id=?", id) return } // ErrRecordNotFound 记录不存在,**或者不属于当前用户**。 // // 两种情况刻意合并成同一个错误:区分开就等于对外提供了一个「这个 id 存在吗」的探测接口, // 攻击者拿它可以枚举出全站有多少条纪要、哪些 id 是活的。 var ErrRecordNotFound = errors.New("记录不存在") // getrecordforuid 按 (uid, id) 取单条。查不到统一回 ErrRecordNotFound。 func (this *modelComp) getrecordforuid(uid string, id uint64) (model *pb.DBEchoMeetRecord, err error) { model = &pb.DBEchoMeetRecord{} if err = mysql.FindOne(comm.TableEchomeetRecord, model, "uid=? AND id=?", uid, id); err != nil { if errors.Is(err, mysql.ErrNoDocuments) { err = ErrRecordNotFound } return nil, err } return } // getrecordsforuidids 批量取本人的记录。 // // ⚠️ ids 里混进别人的(或已删的)id 时**只返回自己那部分、不报错**。 // 客户端的轮询队列里本来就可能留着已被别的设备删掉的 id,这里一报错 // 就会把它整个轮询循环掀掉再也起不来(MeetingTaskService._executeTask 那个坑)。 func (this *modelComp) getrecordsforuidids(uid string, ids []uint64) (models []*pb.DBEchoMeetRecord, err error) { models = make([]*pb.DBEchoMeetRecord, 0) if len(ids) == 0 { return } err = mysql.Find(comm.TableEchomeetRecord, &models, "uid=? AND id IN ?", uid, ids) return } // ownedrecordids 从 ids 里挑出属于 uid 的那些。只取主键,不拉大字段。 func (this *modelComp) ownedrecordids(uid string, ids []uint64) (owned []uint64, err error) { owned = make([]uint64, 0, len(ids)) if len(ids) == 0 { return } err = mysql.Table(comm.TableEchomeetRecord). Where("uid = ? AND id IN ?", uid, ids). Pluck("id", &owned).Error return } // delrecordsforuid 删本人的记录,返回**实际删掉的** id。 // // 先查交集再删:客户端据响应里的 Ids 清本地缓存,而 DELETE 只给得出行数、给不出 id。 func (this *modelComp) delrecordsforuid(uid string, ids []uint64) (deleted []uint64, err error) { if deleted, err = this.ownedrecordids(uid, ids); err != nil || len(deleted) == 0 { return } if err = mysql.Delete(comm.TableEchomeetRecord, "uid=? AND id IN ?", uid, deleted); err != nil { return nil, err } return } func (this *modelComp) getrecordByTaskId(taskId string) (model *pb.DBEchoMeetRecord, err error) { model = &pb.DBEchoMeetRecord{} err = mysql.FindOne(comm.TableEchomeetRecord, model, "taskid=?", taskId) return } // getstucktranscribing 捞一批「卡在转写中」的记录:已经拿到第三方 taskid, // 但距上次主动查询超过阈值。 // // ⚠️ lastquerytime=0 也要算进来——那表示**从来没有人查过**(客户端一次都没拉过, // 比如它的轮询循环崩了)。只按 `lastquerytime'' AND (lastquerytime IS NULL OR lastquerytime=0 OR lastquerytime0 AND starttime