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 } // ===== 记忆项 ===== // itemQuery listItems 的查询条件 type itemQuery struct { uid string startDate string endDate string categories []string states []int32 page int32 size int32 } func (this *modelComp) addItem(item *pb.DBMemoryItem) error { now := time.Now().Unix() item.CreateTime = now item.UpdateTime = now return mysql.Insert(comm.TableMemoryItem, item) } 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 } 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 } 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) tx := mysql.Table(comm.TableMemoryItem).Where("uid = ?", q.uid) if q.startDate != "" { tx = tx.Where("happen_date >= ?", q.startDate) } if q.endDate != "" { tx = tx.Where("happen_date <= ?", q.endDate) } if len(q.categories) > 0 { tx = tx.Where("category in ?", q.categories) } if len(q.states) > 0 { tx = tx.Where("state in ?", q.states) } var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } // 排序口径:先按日期,再按时刻(无时刻的排在当天最后),最后按 id 保证稳定 tx = tx.Order("happen_date asc").Order("happen_time = '' asc").Order("happen_time asc").Order("id asc") 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 } 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 } // ===== 周期报告 ===== 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 } 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 } 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 } 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 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 } // 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 }