You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

424 lines
15 KiB

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
}