package memory import ( "fmt" "strings" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/mysql" "yunyan/pb" "google.golang.org/protobuf/proto" "gorm.io/gorm" ) type modelComp struct { cbase.ModuleCompBase module *Memory } // Init 建表。 // // ⚠️ 这里**绝不能把 err 返回出去**。lego.Run 里 service.Init 一旦拿到非 nil 就 // log.Panicf 让 home 退出,而 gateway/home/api/mcp/timer 五个服务打在同一个镜像里, // entrypoint 见任一 pid 退出就 kill 掉其余全部 —— 一次建表失败 = 全站 502。 // (allhelp/model.go 那段用了命名返回值 err,第二张表失败时会漏出去,是反例,别照抄。) func (this *modelComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) error { this.ModuleCompBase.Init(service, module, comp, opt) this.module = module.(*Memory) if e := mysql.CreateTable(comm.TableMemoryItem, &pb.DBMemoryItem{}); e != nil { this.module.Errorf("建表 %s 失败(本模块功能不可用,但不阻断服务启动): %v", comm.TableMemoryItem, e) } if e := mysql.CreateTable(comm.TableMemoryReport, &pb.DBMemoryReport{}); e != nil { this.module.Errorf("建表 %s 失败(本模块功能不可用,但不阻断服务启动): %v", comm.TableMemoryReport, e) } return nil } /* ⚠️ 从库里读出来的日期要先归一再用,也再回给客户端。 `happen_date` / `period_start` / `period_end` 都是 MySQL 的 `type:date` 列, 而 DSN 带 `parseTime=True` —— gorm 把它们扫进 Go 的 **string** 字段时给的是 `2026-09-07T00:00:00+08:00`,不是协议约定的 `2026-09-07`。 两头都会出事: - **回给客户端的响应**里也是这个形状。客户端拿它做字符串比较 (`_localToday()` 的 `e.happenDate == today`、`_mergeRange` 的 compareTo), 「今天」那一栏的离线兜底恒为空,区间边界上的项还会重复; - **服务端自己解析**它的地方会静默失效(见 comm.ParseMemoryDate 的注释)。 第二头已经由 ParseMemoryDate 容忍带时间的形式兜住了;这里管的是第一头: 凡是把记忆项/报告交出去的读函数,出口处统一归一。 */ // normalizeItemDates 把读出来的日期列收敛成 YYYY-MM-DD func normalizeItemDates(items ...*pb.DBMemoryItem) { for _, it := range items { if it == nil { continue } it.HappenDate = comm.NormalizeMemoryDate(it.HappenDate) } } // normalizeReportDates 同上,管周期报告的起止日 func normalizeReportDates(recs ...*pb.DBMemoryReport) { for _, r := range recs { if r == nil { continue } r.PeriodStart = comm.NormalizeMemoryDate(r.PeriodStart) r.PeriodEnd = comm.NormalizeMemoryDate(r.PeriodEnd) } } // ===== 记忆项 ===== // itemQuery listItems 的查询条件 type itemQuery struct { uid string startDate string endDate string categories []string states []int32 page int32 size int32 } // gormZeroDefaultColumns DBMemoryItem 上「默认值不是零值」的那几列。 // // gorm 在 Create 时会把这些列的**零值替换成标签里的默认值**再写库 // (替换在 callbacks.ConvertToCreateValues 的 reflect.Struct 分支里无条件做, // 与 Select / Omit 都无关——加 Select("*") 生成的 VALUES 一模一样,DryRun 实测过)。 // 默认值本身就是零值的列(default:0 / default:false)替换了也还是零值,不在此列。 // // 清单由 TestGormNonZeroDefaultsAreHandled 按 pb 结构体的 tag 反查守着: // proto 里再加一个非零 default: 而这里没跟上,那条测试会红。 var gormZeroDefaultColumns = []string{"date_certain", "remind_ahead"} // addItem 写入一条记忆项。 // // ⚠️ 插完必须把 gormZeroDefaultColumns 那几列按结构体的现值补写回去。 // mysql.Insert 就是 db.Table().Create(model),于是 ExtractMeetingTodos 明明写了 // DateCertain:false / RemindAhead:0,2026-09-18 测试库里 25 条记录**全部**落成 1 / 5: // - 客户端「待定日期」那一组永远是空的,没截止日的会议待办全堆在会议当天; // - memory_upcoming 按 remind_ahead=5 展开,会议待办会在 08:55 响系统通知, // 与「会议待办默认不提醒」正好相反; // - 用户手动建闹钟选「不提醒」同样被改成提前 5 分钟。 // // 不去掉 proto 上的 `default:` 标签:那要重生成 memory_db.pb.go(生成器版本那个坑), // 而且 MySQL 列上已经建出来的 DEFAULT 不会跟着变,收益为零。 // api_update 走 mysql.Save(UPDATE 写全字段),不受这条影响。 func (this *modelComp) addItem(item *pb.DBMemoryItem) error { now := time.Now().Unix() item.CreateTime = now item.UpdateTime = now // gorm 替换零值时连结构体上的字段一起改(field.Set),所以先把真值留一份: // 不还原的话 memory_add 回给客户端的 item 也是被改过的。 dateCertain, remindAhead := item.DateCertain, item.RemindAhead if err := mysql.Insert(comm.TableMemoryItem, item); err != nil { return err } fix := make(map[string]interface{}, len(gormZeroDefaultColumns)) if !dateCertain { fix["date_certain"] = false item.DateCertain = false } if remindAhead == 0 { fix["remind_ahead"] = int32(0) item.RemindAhead = 0 } if len(fix) == 0 { return nil } // ⚠️ 补写失败只告警不回错:行已经插进去了,回错会让调用方以为没落库 // (memory_add 会对客户端报「新增失败」,而那条记忆项其实已经在库里)。 if err := mysql.Table(comm.TableMemoryItem).Where("id = ?", item.Id).Updates(fix).Error; err != nil { this.module.Warnf("记忆项 id:%d 补写 %v 失败(该项的提醒/日期标记会退回默认值): %v", item.Id, fix, err) } return nil } func (this *modelComp) findItemByClientKey(uid, clientKey string) (*pb.DBMemoryItem, error) { if clientKey == "" { return nil, nil } item := &pb.DBMemoryItem{} err := mysql.FindOne(comm.TableMemoryItem, item, "uid=? and client_key=?", uid, clientKey) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, err } normalizeItemDates(item) return item, nil } // getItem 按 (uid, id) 取。**必须带 uid**——只按 id 查等于任何登录用户都能读别人的记忆项。 func (this *modelComp) getItem(uid string, id uint64) (*pb.DBMemoryItem, error) { item := &pb.DBMemoryItem{} err := mysql.FindOne(comm.TableMemoryItem, item, "uid=? and id=?", uid, id) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, err } normalizeItemDates(item) return item, nil } func (this *modelComp) saveItem(item *pb.DBMemoryItem) error { item.UpdateTime = time.Now().Unix() return mysql.Save(comm.TableMemoryItem, item) } func (this *modelComp) listItems(q *itemQuery) ([]*pb.DBMemoryItem, int64, error) { items := make([]*pb.DBMemoryItem, 0) // 过滤与排序走 comm 里的共享实现:mcp 是独立进程,它的工具查同一张表, // 两边各写一份的话改了一边不报错,只会让 EMAI 答的和 App 里显示的对不上。 tx := comm.ApplyMemoryQuery(mysql.Table(comm.TableMemoryItem), comm.MemoryQuery{ Uid: q.uid, StartDate: q.startDate, EndDate: q.endDate, Categories: q.categories, States: q.states, }) var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } tx = comm.OrderMemoryItems(tx) if q.size > 0 { offset := 0 if q.page > 1 { offset = int(q.page-1) * int(q.size) } tx = tx.Offset(offset).Limit(int(q.size)) } if err := tx.Find(&items).Error; err != nil { return nil, 0, err } normalizeItemDates(items...) return items, total, nil } func (this *modelComp) delItems(uid string, ids []uint64) (int64, error) { if len(ids) == 0 { return 0, nil } tx := mysql.Table(comm.TableMemoryItem).Where("uid = ? and id in ?", uid, ids).Delete(&pb.DBMemoryItem{}) return tx.RowsAffected, tx.Error } func (this *modelComp) completeItems(uid string, ids []uint64, done bool) (int64, error) { if len(ids) == 0 { return 0, nil } now := time.Now().Unix() values := map[string]interface{}{"update_time": now} if done { values["state"] = int32(pb.MemoryState_MemoryState_Done) values["finish_time"] = now } else { values["state"] = int32(pb.MemoryState_MemoryState_Pending) values["finish_time"] = 0 } tx := mysql.Table(comm.TableMemoryItem).Where("uid = ? and id in ?", uid, ids).Updates(values) return tx.RowsAffected, tx.Error } // nextGenRound 这条会议下一轮的轮次号。没有历史项时返回 1。 // // 查不到或出错一律返回 1:宁可这一轮清不干净(留下几条重复待办,用户能自己删), // 也不要返回一个大数把用户改过的项之外的东西全删了。 func (this *modelComp) nextGenRound(sourceID string) int32 { var maxRound int32 err := mysql.Table(comm.TableMemoryItem). Where("source = ? and source_id = ?", comm.MemorySrcMeeting, sourceID). Select("coalesce(max(gen_round), 0)").Scan(&maxRound).Error if err != nil { this.module.Warnf("取会议 %s 的 gen_round 失败,按第 1 轮处理: %v", sourceID, err) return 1 } return maxRound + 1 } // delMeetingItems 会议重新生成时清掉上一轮自动抽取的项。 // user_edited=1 的一律保留——不做这个,用户点一次「重新生成」就丢掉自己的修改。 func (this *modelComp) delMeetingItems(sourceID string, genRound int32) (int64, error) { tx := mysql.Table(comm.TableMemoryItem). Where("source = ? and source_id = ? and user_edited = ? and gen_round < ?", comm.MemorySrcMeeting, sourceID, false, genRound). Delete(&pb.DBMemoryItem{}) return tx.RowsAffected, tx.Error } // delMeetingItemsBySource 删掉若干条会议自动抽取的待办(纪要被删时调)。 // 带 uid 是归属校验:这条路径的入参来自 echomeet 的删除请求,不能只信 source_id。 func (this *modelComp) delMeetingItemsBySource(uid string, sourceIDs []string) (int64, error) { if uid == "" || len(sourceIDs) == 0 { return 0, nil } tx := mysql.Table(comm.TableMemoryItem). Where("uid = ? and source = ? and source_id in ? and user_edited = ?", uid, comm.MemorySrcMeeting, sourceIDs, false). Delete(&pb.DBMemoryItem{}) return tx.RowsAffected, tx.Error } // ===== 周期报告 ===== func (this *modelComp) upsertReport(rec *pb.DBMemoryReport) error { old, err := this.getReportByPeriod(rec.Uid, rec.PeriodType, rec.PeriodKey) if err != nil { return err } now := time.Now().Unix() if old != nil { // 已确认的报告不重算——用户看过的东西不要在他背后变 if old.Confirmed { proto.Merge(rec, old) // 不能 *rec = *old:pb 结构体含 sync.Mutex,值拷贝会被 vet 拦 rec.Id = old.Id return nil } rec.Id = old.Id rec.CreateTime = old.CreateTime rec.UpdateTime = now return mysql.Save(comm.TableMemoryReport, rec) } rec.CreateTime = now rec.UpdateTime = now return mysql.Insert(comm.TableMemoryReport, rec) } func (this *modelComp) saveReport(rec *pb.DBMemoryReport) error { rec.UpdateTime = time.Now().Unix() return mysql.Save(comm.TableMemoryReport, rec) } func (this *modelComp) getReport(id uint64) (*pb.DBMemoryReport, error) { rec := &pb.DBMemoryReport{} err := mysql.FindOne(comm.TableMemoryReport, rec, "id=?", id) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, err } normalizeReportDates(rec) return rec, nil } func (this *modelComp) getReportByPeriod(uid, ptype, pkey string) (*pb.DBMemoryReport, error) { rec := &pb.DBMemoryReport{} err := mysql.FindOne(comm.TableMemoryReport, rec, "uid=? and period_type=? and period_key=?", uid, ptype, pkey) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, err } normalizeReportDates(rec) return rec, nil } // latestUnconfirmedReport 最近一份「已生成完、用户还没确认」的报告。 // 用户一个月不开 App 会攒下 4 份周报 + 1 份月报,一次弹 5 次没人受得了, // 所以弹窗只认这一份,其余去历史列表里翻。 func (this *modelComp) latestUnconfirmedReport(uid string) (*pb.DBMemoryReport, error) { recs := make([]*pb.DBMemoryReport, 0) err := mysql.Table(comm.TableMemoryReport). Where("uid = ? and state = ? and confirmed = ?", uid, int32(pb.MemoryReportState_MemoryReportState_Done), false). Order("period_end desc").Limit(1).Find(&recs).Error if err != nil { return nil, err } if len(recs) == 0 { return nil, nil } normalizeReportDates(recs[0]) return recs[0], nil } func (this *modelComp) listReports(uid, ptype string, page, size int32) ([]*pb.DBMemoryReport, int64, error) { recs := make([]*pb.DBMemoryReport, 0) tx := mysql.Table(comm.TableMemoryReport).Where("uid = ?", uid) if ptype != "" { tx = tx.Where("period_type = ?", ptype) } var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } if size <= 0 { size = 20 } offset := 0 if page > 1 { offset = int(page-1) * int(size) } err := tx.Order("period_end desc").Offset(offset).Limit(int(size)).Find(&recs).Error normalizeReportDates(recs...) return recs, total, err } // uidsWithItemsBetween 上周期里记录数达到阈值的用户。 // // 只给这些人生成报告:空报告没意义还白烧 LLM 调用。阈值也是当前唯一的成本闸门 // ——既有的额度桶(Meetintegral/Aichatintegral)全是「用户主动发起才扣」的语义, // cron 主动生成的东西扣用户额度说不通,见设计文档 §12 待确认 1。 func (this *modelComp) uidsWithItemsBetween(start, end string, minItems int) ([]string, error) { uids := make([]string, 0) err := mysql.Table(comm.TableMemoryItem). Select("uid"). Where("happen_date >= ? and happen_date <= ?", start, end). Group("uid"). Having(fmt.Sprintf("count(*) >= %d", minItems)). Pluck("uid", &uids).Error return uids, err } // countItemsBetween 单个用户在周期内的记录条数。 // // 与 uidsWithItemsBetween 同一个条件(按 happen_date 落在范围内),只是收敛到一个人: // ensureRecent 补建报告时要过同一道闸门,两边条件不一致会出现 // 「cron 判定这人不够条数不建,用户一回来又给他建一份」的来回摆动。 func (this *modelComp) countItemsBetween(uid, start, end string) (int64, error) { var n int64 err := mysql.Table(comm.TableMemoryItem). Where("uid = ? and happen_date >= ? and happen_date <= ?", uid, start, end). Count(&n).Error return n, err } // itemFieldWhitelist memory_update 允许客户端点名更新的字段。 // 白名单而不是黑名单:漏挡一个 uid/id/create_time 就是越权改别人的数据。 var itemFieldWhitelist = map[string]bool{ "category": true, "title": true, "detail": true, "happen_date": true, "happen_time": true, "date_certain": true, "due_raw": true, "tz": true, "repeat_rule": true, "repeat_raw": true, "weekday": true, "amount_cents": true, "currency": true, "owner": true, "state": true, "remind_ahead": true, "extra": true, } func normalizeFields(fields []string) []string { out := make([]string, 0, len(fields)) seen := map[string]bool{} for _, f := range fields { f = strings.ToLower(strings.TrimSpace(f)) if itemFieldWhitelist[f] && !seen[f] { seen[f] = true out = append(out, f) } } return out }