Browse Source

记忆中心后端:memory 模块 + 会议待办抽取 + 周报月报

按设计文档一~三期的后端部分落地。全量 go build / go vet 通过,新增 22 个测试。

memory 模块(modules/memory,由 home 装载,路径 /api/home/memory_*)
11 个接口:list / today / add / update / del / complete / upcoming / stats /
getreport / confirmreport / listreports。

表由 proto + gorm tag 生成(不是手写 DDL——本仓所有业务表都走
mysql.CreateTable + AutoMigrate)。protobuf 没有 DECIMAL/DATE/DATETIME/null,
逐个落成:金额 int64 存分(与 pay/结算同口径,绝不用 double)、happen_date
string + gorm type:date、next_remind_at int64 unix 秒(0=无)。

几处刻意的选择
- 建表失败只 Errorln 不外传。lego.Run 里 Init 返回非 nil 就 Panicf,而五个服务
  同镜像、entrypoint 见任一 pid 退出就全杀,一次建表失败等于全站 502。
  allhelp/model.go 那段用命名返回值 err,第二张表失败时会漏出去,是反例。
- client_key 服务端保证非空。它参与 (uid, client_key) 唯一索引,而 MySQL 的唯一
  索引不像放行 NULL 那样放行空串,留空会让同一用户的第二条记录直接插不进去。
  用 crypto/rand 生成:这个值是去重依据,可预测就可被抢占。
- 重复项的提醒改成「就地展开」。设计文档 v0.1 写的「remind_at 服务端算好客户端
  直接用」对重复项不成立——一个字段表达不了「每天」,且没人在它过去后往前推。
  现在 memory_upcoming 按项自己的 tz 展开成绝对时刻列表返回,不落库。
  单条项按自己的 tz 算而不跟着请求方走:「今天有哪些事」是视图问题可以跟着人走,
  「明早 8 点响」是承诺问题不能。
- 花销按币种分行统计,不做汇率换算。把 CNY 和 JPY 加成一个数是错的。
- 统计全部走 SQL,LLM 只把 stat_json 组织成一段适合朗读的话。明细截断到 20 条
  (重度用户一周两百条未完成,全塞进 prompt 会顶爆上下文,播报也没人听得完)。
- 查询/删除/更新一律带 uid 作条件,报告确认校验 uid 归属。
- 更新字段走白名单而不是黑名单,漏挡一个 uid/id 就是越权改别人的数据。

选路走编排,不再写死服务商
新增 comm.IEchomeet.ChatLLM,由 echomeet 用它的 PickLLM/GetSummarizer 实现。
memory 的报告生成与会议抽取都经它选模型。allhelp/summary.go 直接 import
sys/doubao 是历史债:绕过后台编排,海外应用照样调国内豆包、后台换模型也换不掉。
新模块不复制这个写法。

会议纪要 → 待办抽取(第三路)
挂在 AIProcess 写完 summary、状态置 Completed、出队之后,另起 goroutine。
prompt 内置代码不放 echomeet_template(1189 条模板改不齐,且输出要被程序 parse)。
输入用已生成的 summary 而非转写全文。注入 <meeting_date>,服务端二次校验
due_date:解析不出或早于会议当天的一律只留 due_raw、落会议当天并置
date_certain=false。抽取失败只记日志,绝不回头改 rec.State。

- gen_round 由 memory 按 source_id 现有最大值 +1 推出,不由调用方传。
  echomeet 没有地方存轮次计数器,硬编码成 1 会让第二次「重新生成」的清理条件
  gen_round < 1 一条都匹配不上,旧待办从此永远留在库里。
- 会议归属日期只能用 creationtime(上传时间)——DBEchoMeetRecord 上没有「会议实际
  发生日期」字段,也没有客户端时区。补录历史录音会算错,是已知偏差,注释已记。

周报月报
cron 建单 → Redis 队列 → N worker。锁与队列 key 都过 redissys.RKey 加应用前缀:
测试机多应用共用一个 Redis,锁 key 不带前缀会让 A 应用挡住 B 应用整周不生成报告,
而且安安静静什么都不报。
补偿比防重更要紧:当前 compose 是单副本,多副本重复是前瞻性风险;真会发生的是
00:30 跑到一半容器重启、cron 不补跑、报告永远停在 pending。所以 memory_getreport
里做超时重新入队(同 echomeet 用 PollTranscribe 兜「转写回调丢了」的思路)。
文案生成失败不算整体失败——统计是完整的,宁可少一句话不可让报告整个没有。

顺带修的既有缺陷
- user 模块 cancel()(账号注销)补上 memory_item / memory_report 两张表,
  以及既有遗漏的 useridverify(身份证掩码 + 加盐指纹)。这个清单是硬编码的,
  漏了不报错,只是个人数据静静留在库里。
- errorcode.proto 新开 5201-5206 段。复用 ReqParameterError 会让客户端无法分流,
  「报告还没生成好」该转圈重试而不是弹错。

测试
comm/memory_test.go 8 个:周日在 Go 里是 0、Dart 里是 7 的换算(错了不报错,
只会让每周日的闹钟在周一响)、ISO 跨年周、闰年二月、非法时区必须回退 time.Local
而不是 UTC(否则静默偏移 8 小时)、client_key 非空且不重复。
modules/memory 14 个:一次性/每天/每周展开、锚点在一年前仍能算出未来时刻、
过期项不排、灵感花销不提醒、全天项按 9:00 不落 0:00、展开结果必须升序
(客户端要按 iOS 64 条上限截断,顺序错了会把明天的排掉留下一周后的)、
项按自己时区算、LLM 输出容错(代码块/前后解释文字)与真失败必须报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
0b0ac16b3c
  1. 8
      apps/proto/errorcode.proto
  2. 91
      apps/proto/memory/memory_db.proto
  3. 136
      apps/proto/memory/memory_msg.proto
  4. 3
      apps/services/comm/const.go
  5. 188
      apps/services/comm/memory.go
  6. 132
      apps/services/comm/memory_test.go
  7. 32
      apps/services/comm/module.go
  8. 65
      apps/services/modules/echomeet/module.go
  9. 29
      apps/services/modules/echomeet/tasks.go
  10. 22
      apps/services/modules/memory/api.go
  11. 60
      apps/services/modules/memory/api_add.go
  12. 36
      apps/services/modules/memory/api_complete.go
  13. 37
      apps/services/modules/memory/api_del.go
  14. 58
      apps/services/modules/memory/api_list.go
  15. 127
      apps/services/modules/memory/api_report.go
  16. 47
      apps/services/modules/memory/api_stats.go
  17. 56
      apps/services/modules/memory/api_today.go
  18. 69
      apps/services/modules/memory/api_upcoming.go
  19. 121
      apps/services/modules/memory/api_update.go
  20. 136
      apps/services/modules/memory/api_validate.go
  21. 34
      apps/services/modules/memory/core.go
  22. 198
      apps/services/modules/memory/meeting_extract.go
  23. 53
      apps/services/modules/memory/meeting_extract_test.go
  24. 310
      apps/services/modules/memory/model.go
  25. 75
      apps/services/modules/memory/module.go
  26. 62
      apps/services/modules/memory/options.go
  27. 134
      apps/services/modules/memory/remind.go
  28. 166
      apps/services/modules/memory/remind_test.go
  29. 277
      apps/services/modules/memory/report.go
  30. 144
      apps/services/modules/memory/stats.go
  31. 13
      apps/services/modules/user/model_user.go
  32. 6
      apps/services/modules/user/module_test.go
  33. 29
      apps/services/pb/errorcode.pb.go
  34. 681
      apps/services/pb/memory_db.pb.go
  35. 1541
      apps/services/pb/memory_msg.pb.go
  36. 2
      apps/services/services/home/main.go
  37. 10
      deploy/app/confs/home.yaml.example

8
apps/proto/errorcode.proto

@ -95,6 +95,14 @@ enum ErrorCode {
IdVerifyProviderError = 5004; //服务商调用失败(网络/额度/鉴权等,非"不一致")
IdVerifyTooFrequent = 5005; //校验过于频繁,已被限流
//记忆中心(拾忆)相关错误码 5201-5206
MemoryCategoryInvalid = 5201; //分类不在白名单里(服务端 comm.MemoryCategories)
MemoryItemNotFound = 5202; //记忆项不存在或不属于当前用户
MemoryParamInvalid = 5203; //参数不合法(日期格式、金额为负、标题为空等)
MemoryReportNotFound = 5204; //报告不存在
MemoryReportNotReady = 5205; //报告还在生成中(客户端应转圈重试,不要弹错)
MemoryReportFailed = 5206; //报告生成失败(error_msg 里有原因)
//实时机器翻译相关错误码 5101-5103
TranslateNotConfigured = 5101; //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
TranslateParamInvalid = 5102; //翻译参数不合法(原文为空 / 目标语言为空)

91
apps/proto/memory/memory_db.proto

@ -0,0 +1,91 @@
syntax = "proto3";
option go_package = ".;pb";
// 记忆项分类(字符串常量,不做枚举——新增分类只加常量、不改表、不迁移;
// 取值约束靠服务端白名单 comm.MemoryCategories,客户端对未知分类必须兜底成 todo 渲染)
// alarm=闹钟 todo=待办 idea=灵感 expense=花销
// 记忆项状态
enum MemoryState {
MemoryState_Pending = 0; // 待办/未完成
MemoryState_Done = 1; // 已完成
MemoryState_Dropped = 2; // 已废弃
}
// 记忆项:一张表承载全部分类(拾忆页的唯一真相源)
//
// 字段类型说明(protobuf 没有 DECIMAL/DATE/DATETIME/null,逐个落地见设计文档 §3.2):
// - 金额用 int64 存「分」,与 pay/结算模块同口径,绝不用 double
// - happen_date 用 string + gorm type:date,值形如 2026-09-04
// - next_remind_at 用 int64 unix 秒,0 表示无
message DBMemoryItem {
uint64 id = 1; //@go_tags(`gorm:"primaryKey;autoIncrement" json:"id"`) 主键自增
string uid = 2; //@go_tags(`gorm:"size:50;index:idx_mem_uid_date,priority:1;index:idx_mem_uid_cat,priority:1;uniqueIndex:idx_mem_uid_ck,priority:1" json:"uid"`) 用户ID
string category = 3; //@go_tags(`gorm:"size:16;index:idx_mem_uid_cat,priority:2" json:"category"`) 分类 alarm/todo/idea/expense
string source = 4; //@go_tags(`gorm:"size:16" json:"source"`) 来源 meeting/assistant/manual/device
string source_id = 5; //@go_tags(`gorm:"size:64;index" json:"source_id"`) 会议记录id、端侧指令id等
string client_key = 6; //@go_tags(`gorm:"size:64;uniqueIndex:idx_mem_uid_ck,priority:2" json:"client_key"`) 幂等键(端侧指令id或本地uuid)
// ⚠️ 服务端保证非空:客户端没传就现生成一个随机值。
// MySQL 的唯一索引对空串**不**像 NULL 那样放行重复,
// 留空会让同一用户的第二条手动记录直接插入失败。
string title = 7; //@go_tags(`gorm:"size:200" json:"title"`) 主文案(所有分类通用)
string detail = 8; //@go_tags(`gorm:"type:text" json:"detail"`) 补充说明 / 灵感正文
string happen_date = 9; //@go_tags(`gorm:"type:date;index:idx_mem_uid_date,priority:2;index:idx_mem_uid_cat,priority:3" json:"happen_date"`) 归属日期 YYYY-MM-DD,永不为空
string happen_time = 10; //@go_tags(`gorm:"size:5" json:"happen_time"`) HH:mm,无具体时间则空
bool date_certain = 11; //@go_tags(`gorm:"default:true" json:"date_certain"`) false=日期是推断的(如落在会议当天)
string due_raw = 12; //@go_tags(`gorm:"size:64" json:"due_raw"`) 截止时间原文(「下周三前」)
string tz = 13; //@go_tags(`gorm:"size:48" json:"tz"`) 录入时的客户端时区(IANA名),展开提醒按它算
string repeat_rule = 14; //@go_tags(`gorm:"size:16" json:"repeat_rule"`) once/daily/weekly/weekdays/weekend/monthly
string repeat_raw = 15; //@go_tags(`gorm:"size:64" json:"repeat_raw"`) 重复规则原文(「每周一」)
int32 weekday = 16; //@go_tags(`gorm:"default:0" json:"weekday"`) weekly 时 1-7
int64 amount_cents = 17; //@go_tags(`gorm:"default:0" json:"amount_cents"`) 花销金额(分),category=expense
string currency = 18; //@go_tags(`gorm:"size:8" json:"currency"`) 货币码,默认 CNY
string owner = 19; //@go_tags(`gorm:"size:64" json:"owner"`) 负责人原文(会议待办)
MemoryState state = 20; //@go_tags(`gorm:"default:0;index" json:"state"`) 状态
int32 remind_ahead = 21; //@go_tags(`gorm:"default:5" json:"remind_ahead"`) 提前提醒分钟数,0=不提醒
int64 next_remind_at = 22; //@go_tags(`gorm:"default:0;index:idx_mem_remind,priority:1" json:"next_remind_at"`) 下一次提醒 unix 秒,0=无
bool user_edited = 23; //@go_tags(`gorm:"default:false" json:"user_edited"`) 用户改过 → 重新生成时不覆盖
int32 gen_round = 24; //@go_tags(`gorm:"default:0" json:"gen_round"`) 第几轮AI生成
string extra = 25; //@go_tags(`gorm:"size:2000" json:"extra"`) 扩展信息JSON
int64 create_time = 26; //@go_tags(`gorm:"index" json:"create_time"`)
int64 update_time = 27; //@go_tags(`json:"update_time"`)
int64 finish_time = 28; //@go_tags(`gorm:"index" json:"finish_time"`)
}
// 周期报告生成状态
enum MemoryReportState {
MemoryReportState_Pending = 0; // 待生成(入队)
MemoryReportState_Processing = 1; // 生成中
MemoryReportState_Done = 2; // 完成
MemoryReportState_Failed = 3; // 失败
}
// 周报/月报
// 唯一约束 (uid, period_type, period_key):同一周期只保留一条,重算按覆盖
message DBMemoryReport {
uint64 id = 1; //@go_tags(`gorm:"primaryKey;autoIncrement" json:"id"`)
string uid = 2; //@go_tags(`gorm:"size:50;uniqueIndex:idx_memrep_uid_period,priority:1;index" json:"uid"`) 用户ID
string period_type = 3; //@go_tags(`gorm:"size:8;uniqueIndex:idx_memrep_uid_period,priority:2" json:"period_type"`) week/month
string period_key = 4; //@go_tags(`gorm:"size:16;uniqueIndex:idx_memrep_uid_period,priority:3" json:"period_key"`) 2026-W35 / 2026-08
string period_start = 5; //@go_tags(`gorm:"type:date" json:"period_start"`) 周期起始日 YYYY-MM-DD
string period_end = 6; //@go_tags(`gorm:"type:date" json:"period_end"`) 周期结束日 YYYY-MM-DD
string stat_json = 7; //@go_tags(`gorm:"type:text" json:"stat_json"`) 结构化统计(服务端SQL算,不过LLM)
string summary = 8; //@go_tags(`gorm:"type:text" json:"summary"`) AI生成的播报文案
string llm_svc_id = 9; //@go_tags(`gorm:"size:64" json:"llm_svc_id"`) 实际用的总结服务id(排障用,同echomeet口径)
MemoryReportState state = 10; //@go_tags(`gorm:"default:0;index" json:"state"`)
string error_msg = 11; //@go_tags(`gorm:"size:500" json:"error_msg"`) 失败原因
bool confirmed = 12; //@go_tags(`gorm:"default:false" json:"confirmed"`) 用户是否已确认
int64 confirm_time = 13; //@go_tags(`json:"confirm_time"`)
int64 create_time = 14; //@go_tags(`gorm:"index" json:"create_time"`)
int64 update_time = 15; //@go_tags(`json:"update_time"`)
}

136
apps/proto/memory/memory_msg.proto

@ -0,0 +1,136 @@
syntax = "proto3";
option go_package = ".;pb";
import "memory/memory_db.proto";
// ===== 查询 =====
// 按日期范围/分类拉取记忆项
message MemoryListReq {
string start_date = 1; //@go_tags(`json:"start_date"`) 起始日期 YYYY-MM-DD(含)
string end_date = 2; //@go_tags(`json:"end_date"`) 结束日期 YYYY-MM-DD(含)
repeated string categories = 3; //@go_tags(`json:"categories"`) 分类筛选,空=全部
repeated int32 states = 4; //@go_tags(`json:"states"`) 状态筛选,空=全部
string tz = 5; //@go_tags(`json:"tz"`) 客户端时区(IANA名)
int32 page = 6; //@go_tags(`json:"page"`) 页码,从1开始;0/1 均按第一页
int32 size = 7; //@go_tags(`json:"size"`) 每页数量,0=默认200,上限1000
}
message MemoryListResp {
repeated DBMemoryItem items = 1; //@go_tags(`json:"items"`)
int64 total = 2; //@go_tags(`json:"total"`) 满足条件的总数(用于判断还有没有下一页)
}
// 当天全部事项(每日弹窗直接用,含待确认报告标记)
message MemoryTodayReq {
string date = 1; //@go_tags(`json:"date"`) 客户端本地日期 YYYY-MM-DD
string tz = 2; //@go_tags(`json:"tz"`) 客户端时区(IANA名)
}
message MemoryTodayResp {
repeated DBMemoryItem items = 1; //@go_tags(`json:"items"`) 当天事项
uint64 pending_report_id = 2; //@go_tags(`json:"pending_report_id"`) 有待确认的周报/月报时给出其id,0=没有
string pending_report_type = 3; //@go_tags(`json:"pending_report_type"`) week/month,配合上一字段
}
// 未来N天待提醒项:重复项已按 tz 展开成绝对时刻,客户端直接排通知
message MemoryUpcomingReq {
int32 days = 1; //@go_tags(`json:"days"`) 天数,0=默认7,上限30
string tz = 2; //@go_tags(`json:"tz"`) 客户端时区(IANA名)
}
// 一个展开后的提醒时刻
message MemoryRemindSlot {
uint64 item_id = 1; //@go_tags(`json:"item_id"`) 对应记忆项id
int64 remind_at = 2; //@go_tags(`json:"remind_at"`) 提醒时刻 unix 秒(已扣减 remind_ahead)
int64 happen_at = 3; //@go_tags(`json:"happen_at"`) 事项本身的时刻 unix 秒
string title = 4; //@go_tags(`json:"title"`) 冗余下发,客户端排通知不必再查
string detail = 5; //@go_tags(`json:"detail"`)
string category = 6; //@go_tags(`json:"category"`)
}
message MemoryUpcomingResp {
repeated MemoryRemindSlot slots = 1; //@go_tags(`json:"slots"`) 按 remind_at 升序
}
// 即时统计(EMAI问答与页面头部用)
message MemoryStatsReq {
string start_date = 1; //@go_tags(`json:"start_date"`)
string end_date = 2; //@go_tags(`json:"end_date"`)
repeated string categories = 3; //@go_tags(`json:"categories"`) 空=全部
}
// 单币种花销小计(不做汇率换算,多币种分行给出)
message MemoryExpenseSum {
string currency = 1; //@go_tags(`json:"currency"`)
int64 total_cents = 2; //@go_tags(`json:"total_cents"`)
int32 count = 3; //@go_tags(`json:"count"`)
}
message MemoryStatsResp {
int32 todo_total = 1; //@go_tags(`json:"todo_total"`)
int32 todo_done = 2; //@go_tags(`json:"todo_done"`)
int32 todo_undone = 3; //@go_tags(`json:"todo_undone"`)
int32 idea_count = 4; //@go_tags(`json:"idea_count"`)
int32 alarm_total = 5; //@go_tags(`json:"alarm_total"`)
repeated MemoryExpenseSum expenses = 6; //@go_tags(`json:"expenses"`) 按币种分行
}
// ===== 写入 =====
// 新增:整个 item 提交;client_key 非空时做幂等(重复提交返回已存在那条)
message MemoryAddReq {
DBMemoryItem item = 1; //@go_tags(`json:"item"`)
}
message MemoryAddResp {
DBMemoryItem item = 1; //@go_tags(`json:"item"`) 落库后的记录(含id)
bool duplicated = 2; //@go_tags(`json:"duplicated"`) true=命中幂等键,返回的是既有记录
}
// 修改:只覆盖 fields 里点名的字段,并自动置 user_edited=true
message MemoryUpdateReq {
uint64 id = 1; //@go_tags(`json:"id"`)
DBMemoryItem item = 2; //@go_tags(`json:"item"`) 新值载体
repeated string fields = 3; //@go_tags(`json:"fields"`) 要更新的字段名(json名),空=按白名单全量覆盖
}
message MemoryUpdateResp {
DBMemoryItem item = 1; //@go_tags(`json:"item"`)
}
message MemoryDelReq {
repeated uint64 ids = 1; //@go_tags(`json:"ids"`)
}
message MemoryDelResp {
int32 affected = 1; //@go_tags(`json:"affected"`)
}
// 标记完成/取消完成
message MemoryCompleteReq {
repeated uint64 ids = 1; //@go_tags(`json:"ids"`)
bool done = 2; //@go_tags(`json:"done"`) true=完成,false=退回未完成
}
message MemoryCompleteResp {
int32 affected = 1; //@go_tags(`json:"affected"`)
}
// ===== 周期报告 =====
// 取周报/月报;period_type 与 period_key 都为空时,返回最近一份未确认的
message MemoryGetReportReq {
string period_type = 1; //@go_tags(`json:"period_type"`) week/month
string period_key = 2; //@go_tags(`json:"period_key"`) 2026-W35 / 2026-08
}
message MemoryGetReportResp {
DBMemoryReport report = 1; //@go_tags(`json:"report"`) 没有时为 null
}
message MemoryConfirmReportReq {
uint64 report_id = 1; //@go_tags(`json:"report_id"`)
}
message MemoryConfirmReportResp {
DBMemoryReport report = 1; //@go_tags(`json:"report"`)
}
// 列出历史报告(拾忆页里翻旧报告用,避免一次弹多份)
message MemoryListReportsReq {
string period_type = 1; //@go_tags(`json:"period_type"`) 空=全部
int32 page = 2; //@go_tags(`json:"page"`)
int32 size = 3; //@go_tags(`json:"size"`)
}
message MemoryListReportsResp {
repeated DBMemoryReport reports = 1; //@go_tags(`json:"reports"`)
int64 total = 2; //@go_tags(`json:"total"`)
}

3
apps/services/comm/const.go

@ -32,6 +32,7 @@ const (
ModulePay core.M_Modules = "pay" //gate模块 网关服务模块
ModuleTimer core.M_Modules = "timer" //gate模块 网关服务模块
ModuleAllhelp core.M_Modules = "allhelp" //万能助理模块 定时提醒任务 + 每日聊天总结
ModuleMemory core.M_Modules = "memory" //记忆中心(拾忆)模块 闹钟/待办/灵感/花销统一存储 + 周报月报
ModuleBailian core.M_Modules = "bailian" //百炼设备计量中转模块(半托管License)
ModuleMigu core.M_Modules = "migu" //移动灵犀聊天模块 对外OpenAI兼容接口 内部调咪咕灵犀
ModuleAnalyze core.M_Modules = "analyze" //埋点分析模块 业务侧经 GetModule 取用 上报统计事件
@ -95,6 +96,8 @@ const (
TableAdminResLog = "admin_resource_log" //后台账号资源流水(超管→管理员/代理/运营)
TableAllhelpTask = "allhelp_task" //用户定时提醒任务表
TableChatSummary = "chat_summary" //用户每日聊天总结表
TableMemoryItem = "memory_item" //记忆项表(拾忆:闹钟/待办/灵感/花销统一一张表)⚠️ 新表要同步登记进 user 模块 cancel() 的注销清理清单
TableMemoryReport = "memory_report" //记忆周期报告表(周报/月报)⚠️ 同上
TableStatsGlobalDay = "stats_global_day" //平台每日统计表(应用×产品×区域×日)
TableProductStat = "product_stat" //产品激活/绑定统计表(重建统计时全量扫描 userdevice 落库)
TableFactoryPublicCode = "factory_public_code" //厂家公码表(一厂家多码,新用户扫码领奖,user.isgiveaway 判重)

188
apps/services/comm/memory.go

@ -0,0 +1,188 @@
package comm
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
)
/*
记忆中心(拾忆)的跨模块公共定义。
放在 comm 而不是 modules/memory 的理由:`mcp` 是独立进程、独立服务,它的工具要按
同一套口径读同一张表;分类白名单、日期口径、重复规则展开这三件事一旦两边各写一份,
改了一边不会报错、只会让 EMAI 答得和 App 里显示的不一样。
*/
// 记忆项分类白名单。新增分类在这里加一行即可——不改表、不迁移。
//
// ⚠️ 客户端对不认识的分类必须兜底成 todo 渲染(老包收到新分类不能崩),
// 所以这里加分类是安全的,但客户端的兜底一定要先发出去。
const (
MemoryCatAlarm = "alarm" //闹钟
MemoryCatTodo = "todo" //待办
MemoryCatIdea = "idea" //灵感
MemoryCatExpense = "expense" //花销
)
// 记忆项来源
const (
MemorySrcMeeting = "meeting" //会议纪要抽取
MemorySrcAssistant = "assistant" //EMAI 端侧指令
MemorySrcManual = "manual" //用户手动录入
MemorySrcDevice = "device" //设备侧
)
// 重复规则
const (
MemoryRepeatOnce = "once"
MemoryRepeatDaily = "daily"
MemoryRepeatWeekly = "weekly"
MemoryRepeatWeekdays = "weekdays"
MemoryRepeatWeekend = "weekend"
MemoryRepeatMonthly = "monthly"
)
// 周期报告类型
const (
MemoryPeriodWeek = "week"
MemoryPeriodMonth = "month"
)
var memoryCategories = map[string]bool{
MemoryCatAlarm: true,
MemoryCatTodo: true,
MemoryCatIdea: true,
MemoryCatExpense: true,
}
var memoryRepeatRules = map[string]bool{
MemoryRepeatOnce: true,
MemoryRepeatDaily: true,
MemoryRepeatWeekly: true,
MemoryRepeatWeekdays: true,
MemoryRepeatWeekend: true,
MemoryRepeatMonthly: true,
}
// IsValidMemoryCategory 分类是否在白名单里
func IsValidMemoryCategory(c string) bool { return memoryCategories[c] }
// IsValidMemoryRepeat 重复规则是否合法(空串等同 once)
func IsValidMemoryRepeat(r string) bool { return r == "" || memoryRepeatRules[r] }
// MemoryDateLayout 记忆项日期的唯一格式。happen_date 是「用户本地日期」,
// 不是 UTC 日期——服务端不做时区推断,一律以客户端上报的为准。
const MemoryDateLayout = "2006-01-02"
// ParseMemoryDate 解析 YYYY-MM-DD。失败返回零值与 false。
func ParseMemoryDate(s string) (time.Time, bool) {
t, err := time.Parse(MemoryDateLayout, strings.TrimSpace(s))
if err != nil {
return time.Time{}, false
}
return t, true
}
// FormatMemoryDate 按统一格式输出
func FormatMemoryDate(t time.Time) string { return t.Format(MemoryDateLayout) }
// LoadMemoryLocation 解析客户端上报的 IANA 时区名。
//
// ⚠️ 认不出来一律回退到容器本地时区(Asia/Shanghai,见 Dockerfile 与 compose 的 TZ),
// **绝不回退成 UTC**——那会让所有时刻整体偏移 8 小时,而且是静默的。
// 容器已 import _ "time/tzdata",缺 tzdata 的镜像也能解析。
func LoadMemoryLocation(tz string) *time.Location {
tz = strings.TrimSpace(tz)
if tz == "" {
return time.Local
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return loc
}
// ParseMemoryClock 解析 HH:mm。空串或非法返回 (0,0,false)。
func ParseMemoryClock(s string) (hour, minute int, ok bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0, false
}
t, err := time.Parse("15:04", s)
if err != nil {
return 0, 0, false
}
return t.Hour(), t.Minute(), true
}
// NewMemoryClientKey 生成幂等键。
//
// ⚠️ client_key 在库里参与 (uid, client_key) 唯一索引,而 **MySQL 的唯一索引对空串
// 不像对 NULL 那样放行重复**——留空会让同一用户的第二条手动记录直接插入失败。
// 所以客户端没传时服务端必须现生成一个。用 crypto/rand 而不是 math/rand:
// 这个值会被当成去重依据,可预测就意味着可被别人抢占。
func NewMemoryClientKey() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// crypto/rand 失败是极端情况;退化成时间戳仍然唯一(同一用户不可能同纳秒两次插入)
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// MemoryWeekKey 返回 ISO 周键,形如 2026-W35。
//
// ⚠️ 口径固定为 ISO-8601(周一为一周之首)。美国等地区习惯周日起,
// 这里不做地区适配——一个应用里「上周」必须只有一种含义,否则报告的
// 时间范围会因用户所在地而异,同一份数据算出两个结果。
func MemoryWeekKey(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
}
// MemoryMonthKey 返回月键,形如 2026-08
func MemoryMonthKey(t time.Time) string { return t.Format("2006-01") }
// MemoryWeekRange 返回 t 所在 ISO 周的周一与周日(按 t 自身的时区)
func MemoryWeekRange(t time.Time) (start, end time.Time) {
// Go 的 Weekday() 里周日是 0,ISO 周一为首,先换算成「距周一几天」
offset := (int(t.Weekday()) + 6) % 7
start = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
end = start.AddDate(0, 0, 6)
return
}
// MemoryMonthRange 返回 t 所在自然月的首末日(按 t 自身的时区)
func MemoryMonthRange(t time.Time) (start, end time.Time) {
start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
end = start.AddDate(0, 1, -1)
return
}
// MemoryRepeatHits 判断某一天是否命中重复规则。
//
// weekday 用 1-7 表示周一到周日(与客户端 Dart 的 DateTime.weekday 一致,
// **不是** Go 的 time.Weekday——Go 里周日是 0,直接比会把周日错判成周一)。
func MemoryRepeatHits(rule string, weekday int32, day time.Time, anchor time.Time) bool {
dartWeekday := int32(day.Weekday())
if dartWeekday == 0 {
dartWeekday = 7 // 周日:Go 的 0 → Dart 的 7
}
switch rule {
case MemoryRepeatDaily:
return true
case MemoryRepeatWeekly:
return dartWeekday == weekday
case MemoryRepeatWeekdays:
return dartWeekday >= 1 && dartWeekday <= 5
case MemoryRepeatWeekend:
return dartWeekday == 6 || dartWeekday == 7
case MemoryRepeatMonthly:
return day.Day() == anchor.Day()
}
return false
}

132
apps/services/comm/memory_test.go

@ -0,0 +1,132 @@
package comm
import (
"testing"
"time"
)
// 周日在 Go 里 Weekday()==0、在 Dart 里 weekday==7。这套换算错了不会报错,
// 只会让「每周日」的闹钟在周一响——所以单独守住。
func TestMemoryRepeatHits_WeekdayMapping(t *testing.T) {
loc := time.UTC
// 2026-09-06 是周日,2026-09-07 是周一
sunday := time.Date(2026, 9, 6, 0, 0, 0, 0, loc)
monday := time.Date(2026, 9, 7, 0, 0, 0, 0, loc)
if sunday.Weekday() != time.Sunday || monday.Weekday() != time.Monday {
t.Fatalf("测试基准日期不对: %v %v", sunday.Weekday(), monday.Weekday())
}
// weekly + weekday=7(Dart 口径的周日)应命中周日、不命中周一
if !MemoryRepeatHits(MemoryRepeatWeekly, 7, sunday, sunday) {
t.Error("weekday=7 应命中周日")
}
if MemoryRepeatHits(MemoryRepeatWeekly, 7, monday, sunday) {
t.Error("weekday=7 不应命中周一")
}
// weekday=1 是周一
if !MemoryRepeatHits(MemoryRepeatWeekly, 1, monday, sunday) {
t.Error("weekday=1 应命中周一")
}
}
func TestMemoryRepeatHits_WeekdaysAndWeekend(t *testing.T) {
loc := time.UTC
days := map[string]time.Time{
"周五": time.Date(2026, 9, 4, 0, 0, 0, 0, loc),
"周六": time.Date(2026, 9, 5, 0, 0, 0, 0, loc),
"周日": time.Date(2026, 9, 6, 0, 0, 0, 0, loc),
"周一": time.Date(2026, 9, 7, 0, 0, 0, 0, loc),
}
anchor := days["周五"]
cases := []struct {
day string
weekday bool
weekend bool
}{
{"周五", true, false},
{"周六", false, true},
{"周日", false, true}, // 周日必须算周末,不能因为 Go 的 0 被当成周一
{"周一", true, false},
}
for _, c := range cases {
if got := MemoryRepeatHits(MemoryRepeatWeekdays, 0, days[c.day], anchor); got != c.weekday {
t.Errorf("%s weekdays 期望 %v 得到 %v", c.day, c.weekday, got)
}
if got := MemoryRepeatHits(MemoryRepeatWeekend, 0, days[c.day], anchor); got != c.weekend {
t.Errorf("%s weekend 期望 %v 得到 %v", c.day, c.weekend, got)
}
}
}
// ISO 周口径:跨年那一周最容易出错
func TestMemoryWeekKeyAndRange(t *testing.T) {
loc := time.UTC
// 2026-01-01 是周四,按 ISO 属于 2026-W01,那一周的周一是 2025-12-29
d := time.Date(2026, 1, 1, 12, 0, 0, 0, loc)
if k := MemoryWeekKey(d); k != "2026-W01" {
t.Errorf("周键期望 2026-W01 得到 %s", k)
}
start, end := MemoryWeekRange(d)
if FormatMemoryDate(start) != "2025-12-29" {
t.Errorf("周起期望 2025-12-29 得到 %s", FormatMemoryDate(start))
}
if FormatMemoryDate(end) != "2026-01-04" {
t.Errorf("周止期望 2026-01-04 得到 %s", FormatMemoryDate(end))
}
// 周日当天取到的必须还是本周(周一~周日),不能滑到下一周
sunday := time.Date(2026, 1, 4, 23, 0, 0, 0, loc)
s2, e2 := MemoryWeekRange(sunday)
if FormatMemoryDate(s2) != "2025-12-29" || FormatMemoryDate(e2) != "2026-01-04" {
t.Errorf("周日取周范围错: %s ~ %s", FormatMemoryDate(s2), FormatMemoryDate(e2))
}
}
func TestMemoryMonthRange(t *testing.T) {
loc := time.UTC
// 闰年 2 月
d := time.Date(2028, 2, 10, 0, 0, 0, 0, loc)
s, e := MemoryMonthRange(d)
if FormatMemoryDate(s) != "2028-02-01" || FormatMemoryDate(e) != "2028-02-29" {
t.Errorf("2028-02 范围错: %s ~ %s", FormatMemoryDate(s), FormatMemoryDate(e))
}
}
// 认不出的时区必须回退到本地时区,绝不能回退成 UTC——
// 那会让所有时刻静默偏移 8 小时。
func TestLoadMemoryLocation_FallbackIsLocalNotUTC(t *testing.T) {
if got := LoadMemoryLocation("Nowhere/Nothing"); got != time.Local {
t.Errorf("非法时区应回退 time.Local,得到 %v", got)
}
if got := LoadMemoryLocation(""); got != time.Local {
t.Errorf("空时区应回退 time.Local,得到 %v", got)
}
if got := LoadMemoryLocation("Asia/Tokyo"); got.String() != "Asia/Tokyo" {
t.Errorf("合法时区解析失败,得到 %v", got)
}
}
func TestNewMemoryClientKey_NotEmptyAndUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
k := NewMemoryClientKey()
if k == "" {
t.Fatal("client_key 不能为空:它参与唯一索引,空串会让第二条记录插不进去")
}
if seen[k] {
t.Fatalf("client_key 重复: %s", k)
}
seen[k] = true
}
}
func TestParseMemoryClock(t *testing.T) {
if h, m, ok := ParseMemoryClock("15:30"); !ok || h != 15 || m != 30 {
t.Errorf("15:30 解析错: %d %d %v", h, m, ok)
}
if _, _, ok := ParseMemoryClock(""); ok {
t.Error("空串不该解析成功")
}
if _, _, ok := ParseMemoryClock("25:00"); ok {
t.Error("25:00 不该解析成功")
}
}

32
apps/services/comm/module.go

@ -1,6 +1,10 @@
package comm
import "yunyan/pb"
import (
"context"
"yunyan/pb"
)
// 本文件集中定义「模块对外接口」。
//
@ -121,4 +125,30 @@ type MeetCapabilities struct {
type IEchomeet interface {
// MeetCapabilities 返回当前编排快照下的会议记录能力。读内存快照,不查库。
MeetCapabilities() MeetCapabilities
// ChatLLM 用后台编排选出的总结模型跑一次对话,返回文本与实际用到的服务 id。
//
// 开这个口子是为了让别的模块(memory 的周报生成、会议待办抽取)也能走同一套
// 编排选路,而不是各自 import "yunyan/sys/doubao" 写死服务商——allhelp/summary.go
// 就是写死的,后果是海外应用照样去调国内豆包、后台换模型也换不掉它。
// svcId 传空则按编排取默认。
ChatLLM(ctx context.Context, svcId, systemPrompt, userContent string) (text string, usedSvcId string, err error)
}
// ===================== 记忆中心 memory =====================
// IMemory 记忆中心(拾忆)对外开放的能力。
//
// 目前只有一个:让 echomeet 在纪要生成完之后把待办抽出来落进 memory_item。
// 反向调用(memory → echomeet 的 ChatLLM)走 IEchomeet,两边都不直接 import 对方。
type IMemory interface {
// ExtractMeetingTodos 从会议纪要抽取待办并落库。
//
// **调用方必须另起 goroutine**:它内部要跑一次 LLM,同步调会把会议流水线卡住。
// 实现里已做 panic 兜底与失败隔离——抽取失败只记日志,绝不回头改会议记录的状态。
//
// 「第几轮」由 memory 自己按 source_id 现有的最大 gen_round 推出来,不从这里传:
// 调用方(echomeet)没有地方存轮次计数器,硬编码成 1 会让第二次「重新生成」
// 的清理条件 gen_round < 1 一条都匹配不上,旧待办从此永远留在库里。
ExtractMeetingTodos(ctx context.Context, uid, recordID, summary, meetingDate, llmSvcID string)
}

65
apps/services/modules/echomeet/module.go

@ -1,6 +1,11 @@
package echomeet
import (
"context"
"errors"
"strings"
"sync"
"yunyan/comm"
"yunyan/modules"
@ -21,6 +26,10 @@ type Echomeet struct {
tasks *tasksComp
providers *providersComp
analyze comm.IAnalyze
service core.IService
mem comm.IMemory // 同进程的 memory 模块(会议纪要 → 待办抽取),惰性取
memOnce sync.Once
}
func (this *Echomeet) GetType() core.M_Modules {
@ -31,12 +40,32 @@ func (this *Echomeet) NewOptions() (options core.IModuleOptions) {
}
func (this *Echomeet) Init(service core.IService, module core.IModule, options core.IModuleOptions) (err error) {
this.options = options.(*Options)
this.service = service
if err = this.ModuleBase.Init(service, module, options); err != nil {
return
}
return
}
// memoryModule 惰性取同进程的 memory 模块(会议纪要 → 待办抽取用)。
//
// 惰性而不是 Start 里取:模块 Start 顺序由 home/main.go 的 lego.Run 参数序决定,
// echomeet 排在 memory 前面,Start 阶段去拿会拿到一个还没 Start 完的实例。
// 这里只在真正要用时取一次,缓存住。
func (this *Echomeet) memoryModule() comm.IMemory {
this.memOnce.Do(func() {
if m, e := this.service.GetModule(comm.ModuleMemory); e == nil {
if mm, ok := m.(comm.IMemory); ok {
this.mem = mm
}
}
if this.mem == nil {
this.Warnf("未取到 memory 模块,会议待办抽取不可用(本服务可能没装载它)")
}
})
return this.mem
}
func (this *Echomeet) Start() (err error) {
if err = this.ModuleBase.Start(); err != nil {
return
@ -64,6 +93,42 @@ func (this *Echomeet) MeetCapabilities() (c comm.MeetCapabilities) {
return
}
// ChatLLM 用后台编排选出的总结模型跑一次对话(实现 comm.IEchomeet)。
//
// 开给其它模块用(memory 的周报生成、会议待办抽取),目的是让它们也走同一套编排选路,
// 不必各自 import sys/doubao 把服务商写死。svcId 传空按编排取默认;
// 返回实际用到的 svcId 便于排障时对齐「这条是谁生成的」。
func (this *Echomeet) ChatLLM(ctx context.Context, svcId, systemPrompt, userContent string) (string, string, error) {
if this.providers == nil {
return "", "", errors.New("echomeet providers 未就绪")
}
if strings.TrimSpace(userContent) == "" {
// 空内容送给大模型只会换回一个语焉不详的 400(通义/豆包都直接报
// InternalError.Algo.InvalidParameter),在这里挡掉更好排查。
return "", "", errors.New("userContent 为空")
}
usedSvcId := svcId
var (
s Summarizer
e error
)
if usedSvcId == "" {
usedSvcId, s, e = this.providers.PickLLM("")
} else {
s, e = this.providers.GetSummarizer(usedSvcId)
}
if e != nil {
return "", usedSvcId, e
}
msgs := make([]ChatMessage, 0, 2)
if systemPrompt != "" {
msgs = append(msgs, ChatMessage{Role: "system", Content: systemPrompt})
}
msgs = append(msgs, ChatMessage{Role: "user", Content: userContent})
text, err := s.Chat(ctx, msgs)
return text, usedSvcId, err
}
func (this *Echomeet) OnInstallComp() {
this.ModuleBase.OnInstallComp()
this.api = this.RegisterComp(new(apiComp)).(*apiComp)

29
apps/services/modules/echomeet/tasks.go

@ -521,6 +521,35 @@ func (this *tasksComp) AIProcess(task interface{}) {
this.module.Infof("AIProcess id:%d uid:%s → 任务全部完成", rec.Id, rec.Uid)
procKey := this.keyAIProc()
_ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err()
// 第三路:从纪要里抽结构化待办,落进记忆中心(拾忆)。
//
// 刻意排在 saverecord 与出队**之后**,且另起 goroutine:它内部要再跑一次 LLM,
// 同步调会把 AI 总结流水线的这个坑位一直占着。抽取失败只在 memory 那边记日志,
// 绝不回头改 rec.State —— 用户照样看得到纪要,只是没有自动待办。
this.extractMemoryTodos(rec)
}
// extractMemoryTodos 触发会议待办抽取。所有边界判断放这里,AIProcess 主流程只留一行。
func (this *tasksComp) extractMemoryTodos(rec *pb.DBEchoMeetRecord) {
if rec == nil || rec.Uid == "" || strings.TrimSpace(rec.Summary) == "" {
return
}
mem := this.module.memoryModule()
if mem == nil {
return
}
// 会议归属日期:DBEchoMeetRecord 上**没有「会议实际发生日期」这个字段**,
// 只能用 creationtime(记录创建=上传时间)。用户补录三天前的录音时会算错,
// 这是已知偏差;要做准得给表加 meet_date + tz 两列由客户端上报。
// 记录上也没有客户端时区,只能按容器时区(Asia/Shanghai)折算。
ts := rec.Creationtime
if ts <= 0 {
ts = time.Now().Unix()
}
meetingDate := comm.FormatMemoryDate(time.Unix(ts, 0))
go mem.ExtractMeetingTodos(context.Background(), rec.Uid, fmt.Sprintf("%d", rec.Id),
rec.Summary, meetingDate, rec.LlmSvcId)
}
// PollTranscribe 主动查询第三方转写任务状态(统一入口,按 record.AsrSvcId 路由到对应 provider)

22
apps/services/modules/memory/api.go

@ -0,0 +1,22 @@
package memory
import (
"yunyan/lego/base"
"yunyan/lego/core"
"yunyan/modules"
)
type apiComp struct {
modules.MCompHttpGate
service base.IRPCXService
module *Memory
options *Options
}
func (this *apiComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) {
this.MCompHttpGate.Init(service, module, comp, options)
this.service = service.(base.IRPCXService)
this.module = module.(*Memory)
this.options = options.(*Options)
return
}

60
apps/services/modules/memory/api_add.go

@ -0,0 +1,60 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
)
// Add 新增记忆项
// @Summary 新增记忆项
// @Description 新增一条拾忆条目;client_key 非空时做幂等,重复提交返回既有记录
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryAddReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryAddResp} "响应数据"
// @Router /api/home/memory_add [post]
func (this *apiComp) Add(session comm.IUserSession, req *pb.MemoryAddReq) (resp *pb.MemoryAddResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
item := req.Item
if errdata = normalizeItem(uid, item); errdata != nil {
return
}
// 手动新增的东西天然算「用户编辑过」,会议重新生成时不该被清掉
if item.Source == comm.MemorySrcManual {
item.UserEdited = true
}
// 这几个字段只由服务端决定,客户端传什么都不作数
item.Id = 0
item.GenRound = 0
item.FinishTime = 0
// 幂等:端侧指令会重试,客户端本地那套按指令 id 去重只在本进程内存里,
// 换机/重装/重启后全失效,服务端必须自己挡一道。
if exist, err := this.module.model.findItemByClientKey(uid, item.ClientKey); err != nil {
this.module.Errorf("memory_add uid:%s 幂等查询失败 err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "新增失败"}
return
} else if exist != nil {
resp = &pb.MemoryAddResp{Item: exist, Duplicated: true}
return
}
this.module.recalcRemind(item)
if err := this.module.model.addItem(item); err != nil {
// 唯一索引冲突 = 并发下的重复提交,回查一次当成幂等命中,不要报错给用户
if exist, e := this.module.model.findItemByClientKey(uid, item.ClientKey); e == nil && exist != nil {
resp = &pb.MemoryAddResp{Item: exist, Duplicated: true}
return
}
this.module.Errorf("memory_add uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "新增失败"}
return
}
resp = &pb.MemoryAddResp{Item: item}
return
}

36
apps/services/modules/memory/api_complete.go

@ -0,0 +1,36 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
)
// Complete 标记完成 / 退回未完成
// @Summary 标记完成
// @Description 批量把拾忆条目标记为完成,或退回未完成
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryCompleteReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryCompleteResp} "响应数据"
// @Router /api/home/memory_complete [post]
func (this *apiComp) Complete(session comm.IUserSession, req *pb.MemoryCompleteReq) (resp *pb.MemoryCompleteResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
ids := clampIDs(req.Ids)
if len(ids) == 0 {
resp = &pb.MemoryCompleteResp{}
return
}
n, err := this.module.model.completeItems(uid, ids, req.Done)
if err != nil {
this.module.Errorf("memory_complete uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "操作失败"}
return
}
resp = &pb.MemoryCompleteResp{Affected: int32(n)}
return
}

37
apps/services/modules/memory/api_del.go

@ -0,0 +1,37 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
)
// Del 删除记忆项
// @Summary 删除记忆项
// @Description 批量删除拾忆条目(只能删自己的)
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryDelReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryDelResp} "响应数据"
// @Router /api/home/memory_del [post]
func (this *apiComp) Del(session comm.IUserSession, req *pb.MemoryDelReq) (resp *pb.MemoryDelResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
ids := clampIDs(req.Ids)
if len(ids) == 0 {
resp = &pb.MemoryDelResp{}
return
}
// 删除条件里必须带 uid,只按 id 删等于任何人都能删别人的东西
n, err := this.module.model.delItems(uid, ids)
if err != nil {
this.module.Errorf("memory_del uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "删除失败"}
return
}
resp = &pb.MemoryDelResp{Affected: int32(n)}
return
}

58
apps/services/modules/memory/api_list.go

@ -0,0 +1,58 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
)
// List 按日期范围/分类拉取记忆项
// @Summary 拉取记忆项
// @Description 按日期范围与分类查询拾忆条目
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryListReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryListResp} "响应数据"
// @Router /api/home/memory_list [post]
func (this *apiComp) List(session comm.IUserSession, req *pb.MemoryListReq) (resp *pb.MemoryListResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
if req.StartDate != "" {
if _, ok := comm.ParseMemoryDate(req.StartDate); !ok {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "start_date 格式应为 YYYY-MM-DD"}
return
}
}
if req.EndDate != "" {
if _, ok := comm.ParseMemoryDate(req.EndDate); !ok {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "end_date 格式应为 YYYY-MM-DD"}
return
}
}
for _, c := range req.Categories {
if !comm.IsValidMemoryCategory(c) {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryCategoryInvalid, Message: "不支持的分类: " + c}
return
}
}
items, total, err := this.module.model.listItems(&itemQuery{
uid: uid,
startDate: req.StartDate,
endDate: req.EndDate,
categories: req.Categories,
states: req.States,
page: req.Page,
size: clampListSize(req.Size),
})
if err != nil {
this.module.Errorf("memory_list uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
resp = &pb.MemoryListResp{Items: items, Total: total}
return
}

127
apps/services/modules/memory/api_report.go

@ -0,0 +1,127 @@
package memory
import (
"time"
"yunyan/comm"
"yunyan/pb"
)
// GetReport 取周报/月报
//
// period_type 与 period_key 都为空时返回「最近一份未确认的」——用户一个月不开 App
// 会攒下 4 份周报 + 1 份月报,一次弹 5 次没人受得了,其余去 memory_listreports 里翻。
//
// 顺带承担补偿职责:发现报告卡在 pending/processing 超时就重新入队(容器在 00:30
// 重启一次就够让那一周的报告永远停住,而且不报任何错)。
//
// @Summary 取周期报告
// @Description 取指定周期的报告;不指定则返回最近一份未确认的
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryGetReportReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryGetReportResp} "响应数据"
// @Router /api/home/memory_getreport [post]
func (this *apiComp) GetReport(session comm.IUserSession, req *pb.MemoryGetReportReq) (resp *pb.MemoryGetReportResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
var (
rec *pb.DBMemoryReport
err error
)
if req.PeriodType != "" && req.PeriodKey != "" {
rec, err = this.module.model.getReportByPeriod(uid, req.PeriodType, req.PeriodKey)
} else {
rec, err = this.module.model.latestUnconfirmedReport(uid)
}
if err != nil {
this.module.Errorf("memory_getreport uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
if rec != nil {
// 补偿:卡住的重新入队。重新入队后按「还在生成中」回复,客户端转圈重试即可。
this.module.report.requeueIfStale(rec)
switch rec.State {
case pb.MemoryReportState_MemoryReportState_Pending,
pb.MemoryReportState_MemoryReportState_Processing:
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryReportNotReady, Message: "报告生成中"}
return
case pb.MemoryReportState_MemoryReportState_Failed:
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryReportFailed, Message: rec.ErrorMsg}
return
}
}
resp = &pb.MemoryGetReportResp{Report: rec}
return
}
// ConfirmReport 用户确认报告
// @Summary 确认周期报告
// @Description 标记报告为已确认,之后不再主动弹窗
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryConfirmReportReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryConfirmReportResp} "响应数据"
// @Router /api/home/memory_confirmreport [post]
func (this *apiComp) ConfirmReport(session comm.IUserSession, req *pb.MemoryConfirmReportReq) (resp *pb.MemoryConfirmReportResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
rec, err := this.module.model.getReport(req.ReportId)
if err != nil {
this.module.Errorf("memory_confirmreport uid:%s id:%d err:%v", uid, req.ReportId, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
// 归属校验:报告 id 是自增的,不校验 uid 等于任何人都能确认别人的报告
if rec == nil || rec.Uid != uid {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryReportNotFound, Message: "报告不存在"}
return
}
if !rec.Confirmed {
rec.Confirmed = true
rec.ConfirmTime = time.Now().Unix()
if err := this.module.model.saveReport(rec); err != nil {
this.module.Errorf("memory_confirmreport uid:%s id:%d 保存失败 err:%v", uid, req.ReportId, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "保存失败"}
return
}
}
resp = &pb.MemoryConfirmReportResp{Report: rec}
return
}
// ListReports 历史报告列表
// @Summary 历史报告列表
// @Description 分页列出用户的周报/月报
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryListReportsReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryListReportsResp} "响应数据"
// @Router /api/home/memory_listreports [post]
func (this *apiComp) ListReports(session comm.IUserSession, req *pb.MemoryListReportsReq) (resp *pb.MemoryListReportsResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
recs, total, err := this.module.model.listReports(uid, req.PeriodType, req.Page, req.Size)
if err != nil {
this.module.Errorf("memory_listreports uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
resp = &pb.MemoryListReportsResp{Reports: recs, Total: total}
return
}

47
apps/services/modules/memory/api_stats.go

@ -0,0 +1,47 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
)
// Stats 即时统计
//
// ⚠️ 花销**按币种分行**返回,不做汇率换算。把 CNY 和 JPY 加成一个数是错的,
// 而汇率换算需要实时汇率源与换算时点口径,那是另一个功能。
//
// @Summary 记忆统计
// @Description 按时间范围统计待办完成情况、灵感数、花销(分币种)
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryStatsReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryStatsResp} "响应数据"
// @Router /api/home/memory_stats [post]
func (this *apiComp) Stats(session comm.IUserSession, req *pb.MemoryStatsReq) (resp *pb.MemoryStatsResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
st, err := this.module.computeStats(uid, req.StartDate, req.EndDate)
if err != nil {
this.module.Errorf("memory_stats uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "统计失败"}
return
}
resp = &pb.MemoryStatsResp{
TodoTotal: st.TodoTotal,
TodoDone: st.TodoDone,
TodoUndone: st.TodoUndone,
IdeaCount: st.IdeaCount,
AlarmTotal: st.AlarmTotal,
}
for _, e := range st.Expenses {
resp.Expenses = append(resp.Expenses, &pb.MemoryExpenseSum{
Currency: e.Currency, TotalCents: e.TotalCents, Count: e.Count,
})
}
_ = comm.MemoryCatTodo
return
}

56
apps/services/modules/memory/api_today.go

@ -0,0 +1,56 @@
package memory
import (
"time"
"yunyan/comm"
"yunyan/pb"
)
// Today 当天全部事项(每日弹窗直接用)
//
// 单独开一个接口而不是复用 memory_list:弹窗要一次拿全(含待确认报告标记),
// 且便于单独做降级——它在启动路径附近被调用,出问题不能拖住别的东西。
//
// @Summary 当天事项
// @Description 拉取用户某一天的全部拾忆条目,并带出待确认的周报/月报标记
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryTodayReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryTodayResp} "响应数据"
// @Router /api/home/memory_today [post]
func (this *apiComp) Today(session comm.IUserSession, req *pb.MemoryTodayReq) (resp *pb.MemoryTodayResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
// 日期以客户端本地日期为准,服务端不做时区推断:用户「今天」的定义应该跟着人走。
// 客户端没报就按它给的时区算,时区也没给才退到容器时区。
date := req.Date
if _, ok := comm.ParseMemoryDate(date); !ok {
loc := comm.LoadMemoryLocation(req.Tz)
date = comm.FormatMemoryDate(time.Now().In(loc))
}
items, _, err := this.module.model.listItems(&itemQuery{
uid: uid, startDate: date, endDate: date, size: maxListSize,
})
if err != nil {
this.module.Errorf("memory_today uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
resp = &pb.MemoryTodayResp{Items: items}
// 待确认报告:查失败不算错误,弹窗还是要能出来(少一个角标 << 整个弹窗不出)
if rep, e := this.module.model.latestUnconfirmedReport(uid); e != nil {
this.module.Warnf("memory_today uid:%s 查待确认报告失败已忽略: %v", uid, e)
} else if rep != nil {
resp.PendingReportId = rep.Id
resp.PendingReportType = rep.PeriodType
}
return
}

69
apps/services/modules/memory/api_upcoming.go

@ -0,0 +1,69 @@
package memory
import (
"time"
"yunyan/comm"
"yunyan/pb"
)
// Upcoming 未来 N 天待提醒项(客户端排本地通知用)
//
// 返回的是**已展开成绝对时刻**的列表:重复项按它自己的时区就地摊开,客户端不必
// 再实现一遍容易出错的日期逻辑,也不需要理解 repeat_rule。
//
// @Summary 待提醒列表
// @Description 拉取未来 N 天的提醒时刻,重复项已展开成绝对时刻,按时间升序
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryUpcomingReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryUpcomingResp} "响应数据"
// @Router /api/home/memory_upcoming [post]
func (this *apiComp) Upcoming(session comm.IUserSession, req *pb.MemoryUpcomingReq) (resp *pb.MemoryUpcomingResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
days := int(req.Days)
if days <= 0 {
days = 7
}
if days > maxUpcomingDays {
days = maxUpcomingDays
}
loc := comm.LoadMemoryLocation(req.Tz)
now := time.Now().In(loc)
// 往前多取一年:重复项的锚点日可能远在过去(比如去年设的每周一闹钟),
// 只按未来窗口筛 happen_date 会把所有重复项漏掉。
start := comm.FormatMemoryDate(now.AddDate(-1, 0, 0))
end := comm.FormatMemoryDate(now.AddDate(0, 0, days))
items, _, err := this.module.model.listItems(&itemQuery{
uid: uid,
startDate: start,
endDate: end,
categories: []string{comm.MemoryCatAlarm, comm.MemoryCatTodo},
states: []int32{int32(pb.MemoryState_MemoryState_Pending)},
size: maxListSize,
})
if err != nil {
this.module.Errorf("memory_upcoming uid:%s err:%v", uid, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
slots := expandAll(items, days, req.Tz)
// 只保留窗口内的
limit := now.AddDate(0, 0, days).Unix()
kept := make([]*pb.MemoryRemindSlot, 0, len(slots))
for _, s := range slots {
if s.RemindAt <= limit {
kept = append(kept, s)
}
}
resp = &pb.MemoryUpcomingResp{Slots: kept}
return
}

121
apps/services/modules/memory/api_update.go

@ -0,0 +1,121 @@
package memory
import (
"yunyan/comm"
"yunyan/pb"
"google.golang.org/protobuf/proto"
)
// Update 修改记忆项
//
// 只覆盖 fields 点名的字段,并自动置 user_edited=true —— 会议重新生成时靠这个标记
// 保住用户的修改。fields 为空时按白名单全量覆盖。
//
// @Summary 修改记忆项
// @Description 修改一条拾忆条目,自动标记为用户已编辑
// @Tags Memory
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body pb.MemoryUpdateReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.MemoryUpdateResp} "响应数据"
// @Router /api/home/memory_update [post]
func (this *apiComp) Update(session comm.IUserSession, req *pb.MemoryUpdateReq) (resp *pb.MemoryUpdateResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
if req.Id == 0 || req.Item == nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "缺少 id 或 item"}
return
}
old, err := this.module.model.getItem(uid, req.Id)
if err != nil {
this.module.Errorf("memory_update uid:%s id:%d err:%v", uid, req.Id, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "查询失败"}
return
}
if old == nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_MemoryItemNotFound, Message: "记忆项不存在"}
return
}
// 在 old 的副本上套用改动,再整体校验一遍——只校验新值会漏掉
// 「把 category 改成 expense 但没给金额」这类跨字段问题。
// ⚠️ 不能写 merged := *old —— pb 结构体内嵌 protoimpl.MessageState(含 sync.Mutex),
// 值拷贝会把锁一起复制走,go vet 直接报 copylocks。
merged := proto.Clone(old).(*pb.DBMemoryItem)
fields := normalizeFields(req.Fields)
if len(fields) == 0 {
fields = make([]string, 0, len(itemFieldWhitelist))
for f := range itemFieldWhitelist {
fields = append(fields, f)
}
}
applyFields(merged, req.Item, fields)
if errdata = normalizeItem(uid, merged); errdata != nil {
return
}
// 这些字段不接受客户端修改,一律拿回原值
merged.Id = old.Id
merged.ClientKey = old.ClientKey
merged.Source = old.Source
merged.SourceId = old.SourceId
merged.GenRound = old.GenRound
merged.CreateTime = old.CreateTime
merged.UserEdited = true
this.module.recalcRemind(merged)
if err := this.module.model.saveItem(merged); err != nil {
this.module.Errorf("memory_update uid:%s id:%d 保存失败 err:%v", uid, req.Id, err)
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: "保存失败"}
return
}
resp = &pb.MemoryUpdateResp{Item: merged}
return
}
// applyFields 把 src 里被点名的字段搬到 dst
func applyFields(dst, src *pb.DBMemoryItem, fields []string) {
for _, f := range fields {
switch f {
case "category":
dst.Category = src.Category
case "title":
dst.Title = src.Title
case "detail":
dst.Detail = src.Detail
case "happen_date":
dst.HappenDate = src.HappenDate
case "happen_time":
dst.HappenTime = src.HappenTime
case "date_certain":
dst.DateCertain = src.DateCertain
case "due_raw":
dst.DueRaw = src.DueRaw
case "tz":
dst.Tz = src.Tz
case "repeat_rule":
dst.RepeatRule = src.RepeatRule
case "repeat_raw":
dst.RepeatRaw = src.RepeatRaw
case "weekday":
dst.Weekday = src.Weekday
case "amount_cents":
dst.AmountCents = src.AmountCents
case "currency":
dst.Currency = src.Currency
case "owner":
dst.Owner = src.Owner
case "state":
dst.State = src.State
case "remind_ahead":
dst.RemindAhead = src.RemindAhead
case "extra":
dst.Extra = src.Extra
}
}
}

136
apps/services/modules/memory/api_validate.go

@ -0,0 +1,136 @@
package memory
import (
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
)
// 分页与范围上限。放常量而不是散在各处:这些值同时约束着「数据导出拉全量」
// 与「月视图一次拉一个月」两种用法,改一处即可。
const (
defaultListSize = 200
maxListSize = 1000
maxUpcomingDays = 30
maxTitleLen = 200
maxBatchIDs = 200
)
// requireUID 取会话 uid。所有接口的第一步。
func requireUID(session comm.IUserSession) (string, *pb.ErrorData) {
uid := session.GetUserId()
if uid == "" {
return "", &pb.ErrorData{Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String()}
}
return uid, nil
}
// normalizeItem 校验并规整一条待写入的记忆项。
//
// 客户端传上来的 item 里,凡是不该由客户端决定的字段一律在这里覆盖掉
// (uid/id/create_time/gen_round/source_id…),不要指望调用方老实。
func normalizeItem(uid string, item *pb.DBMemoryItem) *pb.ErrorData {
if item == nil {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "item 为空"}
}
item.Category = strings.ToLower(strings.TrimSpace(item.Category))
if item.Category == "" {
item.Category = comm.MemoryCatTodo
}
if !comm.IsValidMemoryCategory(item.Category) {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryCategoryInvalid, Message: "不支持的分类: " + item.Category}
}
item.Title = strings.TrimSpace(item.Title)
if item.Title == "" {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "标题不能为空"}
}
if len([]rune(item.Title)) > maxTitleLen {
item.Title = string([]rune(item.Title)[:maxTitleLen])
}
// happen_date 永不为空——这是拾忆页能工作的前提,日历按日期排,
// 没有日期的项根本渲染不出来。客户端没给就落在它自己的「今天」。
item.HappenDate = strings.TrimSpace(item.HappenDate)
if item.HappenDate == "" {
loc := comm.LoadMemoryLocation(item.Tz)
item.HappenDate = comm.FormatMemoryDate(time.Now().In(loc))
item.DateCertain = false
} else if _, ok := comm.ParseMemoryDate(item.HappenDate); !ok {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "happen_date 格式应为 YYYY-MM-DD"}
}
if item.HappenTime != "" {
if _, _, ok := comm.ParseMemoryClock(item.HappenTime); !ok {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "happen_time 格式应为 HH:mm"}
}
}
if !comm.IsValidMemoryRepeat(item.RepeatRule) {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "不支持的重复规则: " + item.RepeatRule}
}
if item.RepeatRule == comm.MemoryRepeatWeekly && (item.Weekday < 1 || item.Weekday > 7) {
// 没给星期几就按 happen_date 那天推出来,别让它落成 0 永远匹配不上
if d, ok := comm.ParseMemoryDate(item.HappenDate); ok {
w := int32(d.Weekday())
if w == 0 {
w = 7
}
item.Weekday = w
} else {
item.Weekday = 1
}
}
if item.AmountCents < 0 {
return &pb.ErrorData{Code: pb.ErrorCode_MemoryParamInvalid, Message: "金额不能为负"}
}
if item.Currency == "" {
item.Currency = "CNY"
}
if item.RemindAhead < 0 {
item.RemindAhead = 0
}
if item.Source == "" {
item.Source = comm.MemorySrcManual
}
// client_key 服务端保证非空:它参与 (uid, client_key) 唯一索引,而 MySQL 的
// 唯一索引不像放行 NULL 那样放行空串,留空会让同一用户的第二条记录插不进去。
if strings.TrimSpace(item.ClientKey) == "" {
item.ClientKey = comm.NewMemoryClientKey()
}
item.Uid = uid
return nil
}
// clampListSize 分页尺寸夹取
func clampListSize(size int32) int32 {
if size <= 0 {
return defaultListSize
}
if size > maxListSize {
return maxListSize
}
return size
}
// clampIDs 批量操作的 id 数量夹取,顺带去重
func clampIDs(ids []uint64) []uint64 {
seen := map[uint64]bool{}
out := make([]uint64, 0, len(ids))
for _, id := range ids {
if id == 0 || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
if len(out) >= maxBatchIDs {
break
}
}
return out
}

34
apps/services/modules/memory/core.go

@ -0,0 +1,34 @@
package memory
import (
"yunyan/pb"
)
// IModel 模型组件接口
type IModel interface {
// 记忆项
addItem(item *pb.DBMemoryItem) error
findItemByClientKey(uid, clientKey string) (*pb.DBMemoryItem, error)
getItem(uid string, id uint64) (*pb.DBMemoryItem, error)
saveItem(item *pb.DBMemoryItem) error
listItems(q *itemQuery) ([]*pb.DBMemoryItem, int64, error)
delItems(uid string, ids []uint64) (int64, error)
completeItems(uid string, ids []uint64, done bool) (int64, error)
delMeetingItems(sourceID string, genRound int32) (int64, error)
// 周期报告
upsertReport(rec *pb.DBMemoryReport) error
saveReport(rec *pb.DBMemoryReport) error
getReport(id uint64) (*pb.DBMemoryReport, error)
getReportByPeriod(uid, ptype, pkey string) (*pb.DBMemoryReport, error)
latestUnconfirmedReport(uid string) (*pb.DBMemoryReport, error)
listReports(uid, ptype string, page, size int32) ([]*pb.DBMemoryReport, int64, error)
uidsWithItemsBetween(start, end string, minItems int) ([]string, error)
}
// Redis 队列键后缀(实际使用时还会经 service.GetTag() 与 redissys.RKey 加应用前缀,
// 测试机上多个应用共用一个 Redis,不加前缀会串号)
const (
QueueReportAwait = "memory:report:await" //报告生成等待队列
LockReportPrefix = "memory:report:lock" //cron 抢锁前缀,后面拼 <period_type>:<period_key>
)

198
apps/services/modules/memory/meeting_extract.go

@ -0,0 +1,198 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
)
/*
会议纪要 → 待办抽取(第三路)。
在 echomeet 的 AIProcess 写完 summary/overview、状态置 Completed **之后**由它回调进来,
独立跑、失败只记日志不改 rec.State —— 用户照样看得到纪要,只是没有自动待办。
几个刻意的选择:
- **prompt 放代码不放 echomeet_template**:模板有 1189 条(29 组 × 41 语言),改不动也改不齐;
各模板 outline 职责本就不同,塞进去会互相干扰;输出要被程序 parse 成 JSON,
格式严格性不能交给后台可随手编辑的文本。
- **输入用已生成的 summary 而不是转写全文**:summary 约千字,全文可达上万字,
成本只是前两路的零头。
- **必须注入会议日期**:不给日期,模型把「下周三前」换算成具体日期就是瞎猜。
- **复用 rec.LlmSvcId**:与本条会议的总结同一个模型。同一条会议两路用不同模型,
出问题时无法归因。
*/
const meetingExtractPrompt = `你是一个会议待办抽取器。用户会给你一份会议纪要,以及这次会议的日期。
请从纪要的「待办事项」部分抽取出结构化的任务列表。
严格要求:
1. 只输出一个 JSON 数组,不要 markdown 代码块,不要任何解释文字。数组为空就输出 []。
2. 数组每一项的字段固定为:
{"title":"任务内容","owner":"负责人","due_date":"YYYY-MM-DD","due_raw":"截止时间原文"}
3. title 必填。owner / due_date / due_raw 没有就填空字符串 ""。
4. **title 与 owner 必须与输入纪要使用同一种语言**,不要翻译成中文。
5. due_date 只在能从原文明确推算出具体日期时才填,推算基准是给定的会议日期。
含糊的表述(「尽快」「月底前」无法定位到具体某天)一律留空 due_date,
把原文放进 due_raw。**禁止猜测、禁止编造日期。**
6. 只收录会议中明确要求执行的任务;仅在讨论中提及、未拍板的设想不计入。
7. 同一件事被多次提及只输出一条,取最终确定的版本。`
// extractedTodo 抽取结果的一条
type extractedTodo struct {
Title string `json:"title"`
Owner string `json:"owner"`
DueDate string `json:"due_date"`
DueRaw string `json:"due_raw"`
}
// 一次会议最多落多少条待办。模型偶尔会把整篇纪要拆成几十条,
// 全落下去会把用户的日历那一天彻底刷爆。
const maxExtractPerMeeting = 30
// ExtractMeetingTodos 从会议纪要抽待办并落库(实现 comm.IMemory)。
//
// meetingDate 是会议归属日期(YYYY-MM-DD);抽不出截止日期的项落在这一天并置
// date_certain=false,客户端应把它们归到「待定日期」分组,不要和当天确定事项混排
// —— 一次会抽出 8 条没写截止时间的待办全堆在会议当天,那一格就没法看了。
func (this *Memory) ExtractMeetingTodos(ctx context.Context, uid, recordID, summary, meetingDate, llmSvcID string) {
defer func() {
if r := recover(); r != nil {
this.Errorf("会议待办抽取 panic record:%s err:%v", recordID, r)
}
}()
if !this.options.MeetingExtract {
return
}
if strings.TrimSpace(summary) == "" || uid == "" {
return
}
if _, ok := comm.ParseMemoryDate(meetingDate); !ok {
this.Warnf("会议待办抽取 record:%s 会议日期非法(%q),跳过", recordID, meetingDate)
return
}
if this.echo == nil {
this.Warnf("会议待办抽取 record:%s 未取到 echomeet,跳过", recordID)
return
}
// 本次是第几轮:取这条会议现有项的最大 gen_round + 1。
// 清理时删的是 gen_round < 本轮 且 user_edited=false 的项,所以轮次必须真的递增——
// 恒为 1 的话第二次重新生成会一条都清不掉,旧待办和新待办并存。
genRound := this.model.nextGenRound(recordID)
prompt := this.options.MeetingExtractPrompt
if prompt == "" {
prompt = meetingExtractPrompt
}
userContent := fmt.Sprintf("<meeting_date>%s</meeting_date>\n\n<summary>\n%s\n</summary>", meetingDate, summary)
c, cancel := context.WithTimeout(ctx, 90*time.Second)
text, usedSvc, err := this.echo.ChatLLM(c, llmSvcID, prompt, userContent)
cancel()
if err != nil {
// 失败隔离:只记日志,绝不改 rec.State。纪要是用户真正要的东西,
// 不能因为附赠的待办抽取失败就把它标成失败。
this.Errorf("会议待办抽取 record:%s 调用失败 svc:%s err:%v", recordID, usedSvc, err)
return
}
todos, err := parseExtractedTodos(text)
if err != nil {
this.Warnf("会议待办抽取 record:%s 解析失败已忽略 err:%v raw:%.200s", recordID, err, text)
return
}
if len(todos) == 0 {
this.Infof("会议待办抽取 record:%s 无待办", recordID)
return
}
if len(todos) > maxExtractPerMeeting {
this.Warnf("会议待办抽取 record:%s 返回 %d 条,截断到 %d", recordID, len(todos), maxExtractPerMeeting)
todos = todos[:maxExtractPerMeeting]
}
// 幂等:清掉上一轮**自动生成**的项,user_edited 的一律保留。
// 不做这个,用户点一次「重新生成」就会丢掉自己的修改。
if n, e := this.model.delMeetingItems(recordID, genRound); e != nil {
this.Warnf("会议待办抽取 record:%s 清理旧轮次失败已忽略 err:%v", recordID, e)
} else if n > 0 {
this.Infof("会议待办抽取 record:%s 清理旧轮次 %d 条", recordID, n)
}
meetDay, _ := comm.ParseMemoryDate(meetingDate)
saved := 0
for i, t := range todos {
t.Title = strings.TrimSpace(t.Title)
if t.Title == "" {
continue
}
item := &pb.DBMemoryItem{
Uid: uid, Category: comm.MemoryCatTodo, Source: comm.MemorySrcMeeting,
SourceId: recordID,
// client_key 用「记录id + 轮次 + 序号」,重跑同一轮不会插重
ClientKey: fmt.Sprintf("meet:%s:%d:%d", recordID, genRound, i),
Title: t.Title,
Owner: strings.TrimSpace(t.Owner),
DueRaw: strings.TrimSpace(t.DueRaw),
HappenDate: meetingDate,
DateCertain: false,
GenRound: genRound,
State: pb.MemoryState_MemoryState_Pending,
RemindAhead: 0, // 会议待办默认不提醒,用户改期时自己开
Currency: "CNY",
}
// 「未明确」是模板要求模型在原文没写时填的占位词,不是真的负责人
if item.Owner == "未明确" || strings.EqualFold(item.Owner, "unspecified") {
item.Owner = ""
}
// 服务端二次校验日期:解析不出、或早于会议当天的一律丢弃,只留 due_raw。
// 参考 SET_clock 踩过的坑——模型把「下午 3:30」的 time 填成 03:30。
if d, ok := comm.ParseMemoryDate(strings.TrimSpace(t.DueDate)); ok {
if !d.Before(meetDay) {
item.HappenDate = comm.FormatMemoryDate(d)
item.DateCertain = true
} else {
this.Warnf("会议待办抽取 record:%s 丢弃早于会议日的截止日 %s", recordID, t.DueDate)
}
}
if err := this.model.addItem(item); err != nil {
this.Warnf("会议待办抽取 record:%s 第%d条落库失败已跳过 err:%v", recordID, i, err)
continue
}
saved++
}
this.Infof("会议待办抽取 record:%s uid:%s svc:%s 落库 %d/%d 条", recordID, uid, usedSvc, saved, len(todos))
}
// parseExtractedTodos 解析模型输出。
//
// 模型时常无视「不要 markdown 代码块」这条,所以先剥一层 ```json ... ```;
// 也可能在数组前后带一句解释,再按第一个 [ 到最后一个 ] 截一次。
func parseExtractedTodos(text string) ([]extractedTodo, error) {
s := strings.TrimSpace(text)
if strings.HasPrefix(s, "```") {
if i := strings.Index(s, "\n"); i >= 0 {
s = s[i+1:]
}
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
}
if start, end := strings.Index(s, "["), strings.LastIndex(s, "]"); start >= 0 && end > start {
s = s[start : end+1]
}
out := make([]extractedTodo, 0)
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}

53
apps/services/modules/memory/meeting_extract_test.go

@ -0,0 +1,53 @@
package memory
import "testing"
// 模型时常无视「不要 markdown 代码块」,也常在数组前后带一句解释。
// 这些都不是异常输入,是实测里的常态,解析必须扛得住。
func TestParseExtractedTodos_Tolerance(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{"裸数组", `[{"title":"跟进A客户","owner":"王总","due_date":"2026-09-10","due_raw":"下周四前"}]`, 1},
{"带 json 代码块", "```json\n[{\"title\":\"跟进A客户\"}]\n```", 1},
{"带无语言代码块", "```\n[{\"title\":\"跟进A客户\"}]\n```", 1},
{"前后有解释文字", "好的,以下是待办:\n[{\"title\":\"跟进A客户\"}]\n希望对你有帮助。", 1},
{"空数组", `[]`, 0},
{"空数组带代码块", "```json\n[]\n```", 0},
{"多条", `[{"title":"A"},{"title":"B"},{"title":"C"}]`, 3},
}
for _, c := range cases {
got, err := parseExtractedTodos(c.raw)
if err != nil {
t.Errorf("%s: 解析失败 %v", c.name, err)
continue
}
if len(got) != c.want {
t.Errorf("%s: 期望 %d 条,得到 %d 条", c.name, c.want, len(got))
}
}
}
// 真正解析不出来时必须报错,不能悄悄返回空数组——
// 那样「模型输出坏了」和「本次会议确实没有待办」就分不开了。
func TestParseExtractedTodos_RealFailureReturnsError(t *testing.T) {
for _, raw := range []string{"", "本次会议无明确待办事项。", "{不是JSON"} {
if _, err := parseExtractedTodos(raw); err == nil {
t.Errorf("输入 %q 应该报错而不是返回空数组", raw)
}
}
}
func TestParseExtractedTodos_FieldMapping(t *testing.T) {
raw := `[{"title":"跟进A客户","owner":"王总","due_date":"2026-09-10","due_raw":"下周四前"}]`
got, err := parseExtractedTodos(raw)
if err != nil || len(got) != 1 {
t.Fatalf("解析失败 err=%v n=%d", err, len(got))
}
if got[0].Title != "跟进A客户" || got[0].Owner != "王总" ||
got[0].DueDate != "2026-09-10" || got[0].DueRaw != "下周四前" {
t.Errorf("字段映射错: %+v", got[0])
}
}

310
apps/services/modules/memory/model.go

@ -0,0 +1,310 @@
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
}

75
apps/services/modules/memory/module.go

@ -0,0 +1,75 @@
/*
Memory 记忆中心(拾忆)模块
一张 memory_item 表承载全部分类(闹钟/待办/灵感/花销),另有 memory_report 存周报月报。
由 home 服务装载(不是独立服务),HTTP 接口经反射注册为 memory_<方法名小写>,
客户端路径 /api/home/memory_xxx。
*/
package memory
import (
"yunyan/comm"
"yunyan/modules"
"yunyan/lego/base"
"yunyan/lego/core"
)
func NewModule() core.IModule {
m := new(Memory)
return m
}
type Memory struct {
modules.ModuleBase
service base.IRPCXService
api *apiComp
model *modelComp
report *reportComp
options *Options
// echo 是同进程的 echomeet 模块:走它的后台编排选 LLM,而不是各自 import
// sys/doubao 把服务商写死(allhelp/summary.go 就是写死的,海外应用照样调国内豆包)。
// 取不到时所有 LLM 相关能力降级:报告只出统计不出文案、会议抽取整条跳过。
echo comm.IEchomeet
}
func (this *Memory) GetType() core.M_Modules {
return comm.ModuleMemory
}
func (this *Memory) NewOptions() (options core.IModuleOptions) {
return new(Options)
}
func (this *Memory) Init(service core.IService, module core.IModule, options core.IModuleOptions) (err error) {
this.service = service.(base.IRPCXService)
this.options = options.(*Options)
if err = this.ModuleBase.Init(service, module, options); err != nil {
return
}
return
}
func (this *Memory) Start() (err error) {
if err = this.ModuleBase.Start(); err != nil {
return
}
if m, e := this.service.GetModule(comm.ModuleEchomeet); e == nil {
if em, ok := m.(comm.IEchomeet); ok {
this.echo = em
}
}
if this.echo == nil {
this.Warnf("未取到 echomeet 模块:周期报告将只有统计没有播报文案,会议待办抽取不可用")
}
return
}
func (this *Memory) OnInstallComp() {
this.ModuleBase.OnInstallComp()
this.api = this.RegisterComp(new(apiComp)).(*apiComp)
this.model = this.RegisterComp(new(modelComp)).(*modelComp)
this.report = this.RegisterComp(new(reportComp)).(*reportComp)
}

62
apps/services/modules/memory/options.go

@ -0,0 +1,62 @@
package memory
import (
"yunyan/modules"
"yunyan/lego/utils/mapstructure"
)
type Options struct {
modules.Options
// 周期报告
MaxReportProcess int `json:"max_report_process" mapstructure:"max_report_process"` //报告生成并发 worker 数,默认 3
ReportCronWeek string `json:"report_cron_week" mapstructure:"report_cron_week"` //周报 cron(秒级 6 位),默认每周一 00:30
ReportCronMonth string `json:"report_cron_month" mapstructure:"report_cron_month"` //月报 cron,默认每月 1 号 00:30
ReportMinItems int `json:"report_min_items" mapstructure:"report_min_items"` //上周期至少多少条记录才生成报告,默认 3
ReportPrompt string `json:"report_prompt" mapstructure:"report_prompt"` //报告播报文案的 system prompt
ReportStaleSec int64 `json:"report_stale_sec" mapstructure:"report_stale_sec"` //报告卡在 pending/processing 多久算死,默认 900s
// 会议纪要 → 待办抽取
MeetingExtract bool `json:"meeting_extract" mapstructure:"meeting_extract"` //是否开启会议待办抽取,默认 true
MeetingExtractPrompt string `json:"meeting_extract_prompt" mapstructure:"meeting_extract_prompt"` //抽取 prompt(代码内置默认值,不放 echomeet_template)
}
func (this *Options) LoadConfig(settings map[string]interface{}) (err error) {
// 布尔项的默认值必须在 Decode 之前给,否则配置里没写这一项时会被零值覆盖成 false
this.MeetingExtract = true
if settings != nil {
if err = this.Options.LoadConfig(settings); err != nil {
return
}
if err = mapstructure.Decode(settings, this); err != nil {
return
}
}
if this.MaxReportProcess <= 0 {
this.MaxReportProcess = 3
}
if this.ReportCronWeek == "" {
// 秒 分 时 日 月 周:每周一 00:30:00
// ⚠️ 这是容器本地时区(Asia/Shanghai,Dockerfile 与 compose 都设了 TZ),
// 对所有区域所有用户都是北京时间。海外用户的「上周」边界会偏,见设计文档 §5.3。
this.ReportCronWeek = "0 30 0 ? * MON"
}
if this.ReportCronMonth == "" {
this.ReportCronMonth = "0 30 0 1 * ?"
}
if this.ReportMinItems <= 0 {
this.ReportMinItems = 3
}
if this.ReportStaleSec <= 0 {
this.ReportStaleSec = 900
}
if this.ReportPrompt == "" {
this.ReportPrompt = "你是一位贴心的生活助理。用户会给你一段 JSON 格式的周期统计数据(已由程序精确算出)。" +
"请把它组织成一段适合语音朗读的自然中文短文,控制在 150 字以内。要求:" +
"1) 直接说结论,不要复述字段名,不要出现 JSON、字段、数据这类词;" +
"2) 数字必须原样引用,一个都不能改、不能四舍五入、不能自己做加减;" +
"3) 依次覆盖:完成情况、未完成的事、花销、灵感;某一项数据为空就跳过不提;" +
"4) 语气平实友好,不要用感叹号堆砌,不要说教;" +
"5) 只输出这段话本身,不要标题、不要分点、不要任何解释。"
}
return
}

134
apps/services/modules/memory/remind.go

@ -0,0 +1,134 @@
package memory
import (
"sort"
"time"
"yunyan/comm"
"yunyan/pb"
)
/*
提醒时刻的计算与展开。
设计文档 v0.1 写的是「remind_at 服务端算好、客户端直接用」。那对一次性项成立,
对重复项**不成立**:一条 daily 闹钟只有一行记录、一个时刻字段,谁在它过去之后
把它推到明天?所以这里的口径是:
- next_remind_at 只存「下一次」,供服务端侧索引用;
- memory_upcoming 返回时按 repeat_rule **就地展开**成未来 N 天的绝对时刻列表,
不落库。客户端拿到一串绝对时刻,直接排通知。
时区口径:单条记忆项按它自己的 tz 算(录入时记下的),**不跟着人走**。
「今天有哪些事」是视图问题可以跟着人走,「明早 8 点响」是承诺问题不能。
*/
// itemLocation 取这条记忆项该用的时区
func itemLocation(item *pb.DBMemoryItem, fallbackTz string) *time.Location {
if item.Tz != "" {
return comm.LoadMemoryLocation(item.Tz)
}
return comm.LoadMemoryLocation(fallbackTz)
}
// happenAt 算出这条项在某一天的发生时刻。没有 happen_time 的按当天 9:00 计
// (全天事项也得有个播报时间点,否则 0:00 提醒等于半夜吵人)。
func happenAt(item *pb.DBMemoryItem, day time.Time, loc *time.Location) time.Time {
h, m, ok := comm.ParseMemoryClock(item.HappenTime)
if !ok {
h, m = 9, 0
}
return time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, loc)
}
// recalcRemind 重算 next_remind_at(新增与修改后都要调)。
// 算不出或已过期的一律置 0——0 表示「没有下一次」,不是「1970 年」。
func (this *Memory) recalcRemind(item *pb.DBMemoryItem) {
item.NextRemindAt = 0
if item.RemindAhead <= 0 || item.State != pb.MemoryState_MemoryState_Pending {
return
}
// 灵感与花销是「记下来」不是「提醒我」,不排提醒
if item.Category == comm.MemoryCatIdea || item.Category == comm.MemoryCatExpense {
return
}
loc := itemLocation(item, "")
slots := expandItem(item, time.Now().In(loc), 366, loc)
if len(slots) > 0 {
item.NextRemindAt = slots[0].RemindAt
}
}
// expandItem 把一条记忆项在 [from, from+days) 内展开成提醒时刻。
// 只返回还没过去的(严格晚于 from)。
func expandItem(item *pb.DBMemoryItem, from time.Time, days int, loc *time.Location) []*pb.MemoryRemindSlot {
out := make([]*pb.MemoryRemindSlot, 0, 4)
if item.RemindAhead <= 0 || item.State != pb.MemoryState_MemoryState_Pending {
return out
}
if item.Category == comm.MemoryCatIdea || item.Category == comm.MemoryCatExpense {
return out
}
anchor, ok := comm.ParseMemoryDate(item.HappenDate)
if !ok {
return out
}
anchor = time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc)
ahead := time.Duration(item.RemindAhead) * time.Minute
rule := item.RepeatRule
if rule == "" {
rule = comm.MemoryRepeatOnce
}
appendSlot := func(day time.Time) {
hap := happenAt(item, day, loc)
rem := hap.Add(-ahead)
if !rem.After(from) {
return
}
out = append(out, &pb.MemoryRemindSlot{
ItemId: item.Id,
RemindAt: rem.Unix(),
HappenAt: hap.Unix(),
Title: item.Title,
Detail: item.Detail,
Category: item.Category,
})
}
if rule == comm.MemoryRepeatOnce {
appendSlot(anchor)
return out
}
// 重复项:从今天与锚点日的较晚者开始逐日试
start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
if anchor.After(start) {
start = anchor
}
for i := 0; i < days; i++ {
day := start.AddDate(0, 0, i)
if comm.MemoryRepeatHits(rule, item.Weekday, day, anchor) {
appendSlot(day)
}
}
return out
}
// expandAll 把一批记忆项展开并按时间排序。
//
// ⚠️ 重复项要**按天合并**:一条 daily 闹钟展开 7 天就吃掉 7 个通知名额,
// 而 iOS 只允许 64 条待处理通知,三条日常闹钟就占掉一半。这里不做合并,
// 而是交给客户端按 remind_at 取前 N 条——但顺序必须是升序,否则客户端截断
// 会把明天的排掉、留下一周后的。
func expandAll(items []*pb.DBMemoryItem, days int, fallbackTz string) []*pb.MemoryRemindSlot {
all := make([]*pb.MemoryRemindSlot, 0, len(items)*2)
now := time.Now()
for _, it := range items {
loc := itemLocation(it, fallbackTz)
all = append(all, expandItem(it, now.In(loc), days, loc)...)
}
sort.Slice(all, func(i, j int) bool { return all[i].RemindAt < all[j].RemindAt })
return all
}

166
apps/services/modules/memory/remind_test.go

@ -0,0 +1,166 @@
package memory
import (
"testing"
"time"
"yunyan/comm"
"yunyan/pb"
)
func mkItem(cat, date, clock, rule string, weekday int32, ahead int32) *pb.DBMemoryItem {
return &pb.DBMemoryItem{
Id: 1, Category: cat, HappenDate: date, HappenTime: clock,
RepeatRule: rule, Weekday: weekday, RemindAhead: ahead,
State: pb.MemoryState_MemoryState_Pending, Tz: "Asia/Shanghai",
}
}
// 一次性项:只在那一天产生一个时刻,且要扣掉提前量
func TestExpandItem_Once(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc)
it := mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5)
slots := expandItem(it, from, 7, loc)
if len(slots) != 1 {
t.Fatalf("一次性项应产生 1 个时刻,得到 %d", len(slots))
}
want := time.Date(2026, 9, 6, 8, 55, 0, 0, loc).Unix()
if slots[0].RemindAt != want {
t.Errorf("提醒时刻应为 08:55(09:00 提前5分钟),得到 %s",
time.Unix(slots[0].RemindAt, 0).In(loc).Format("2006-01-02 15:04"))
}
}
// 已经过去的一次性项不该再排。排了就是 App 一打开立刻弹一堆过期提醒。
func TestExpandItem_SkipsPast(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 10, 8, 0, 0, 0, loc)
it := mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5)
if slots := expandItem(it, from, 7, loc); len(slots) != 0 {
t.Errorf("过期项不该产生时刻,得到 %d 个", len(slots))
}
}
// 重复项是 v0.1 设计的坑:一个 remind_at 字段表达不了「每天」。
// 展开必须给出窗口内的每一天。
func TestExpandItem_Daily(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc)
it := mkItem(comm.MemoryCatAlarm, "2026-09-01", "09:00", comm.MemoryRepeatDaily, 0, 5)
slots := expandItem(it, from, 7, loc)
if len(slots) != 7 {
t.Fatalf("每天重复在 7 天窗口内应有 7 个时刻,得到 %d", len(slots))
}
// 今天 09:00 还没到(from 是 08:00),所以第一个应该是今天
first := time.Unix(slots[0].RemindAt, 0).In(loc)
if first.Format("2006-01-02") != "2026-09-05" {
t.Errorf("第一个时刻应是今天 2026-09-05,得到 %s", first.Format("2006-01-02"))
}
}
// 每周:锚点日远在过去时也必须能算出未来的时刻,
// 否则去年设的闹钟今年就不响了。
func TestExpandItem_WeeklyWithOldAnchor(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) // 周六
// 锚点在一年前,规则是每周一
it := mkItem(comm.MemoryCatAlarm, "2025-09-01", "09:00", comm.MemoryRepeatWeekly, 1, 5)
slots := expandItem(it, from, 14, loc)
if len(slots) != 2 {
t.Fatalf("14 天窗口内每周一应有 2 个时刻,得到 %d", len(slots))
}
for _, s := range slots {
d := time.Unix(s.HappenAt, 0).In(loc)
if d.Weekday() != time.Monday {
t.Errorf("每周一的时刻落在了 %v (%s)", d.Weekday(), d.Format("2006-01-02"))
}
}
}
// 灵感与花销是「记下来」不是「提醒我」,不该排提醒
func TestExpandItem_IdeaAndExpenseNeverRemind(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc)
for _, cat := range []string{comm.MemoryCatIdea, comm.MemoryCatExpense} {
it := mkItem(cat, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5)
if slots := expandItem(it, from, 7, loc); len(slots) != 0 {
t.Errorf("%s 不该产生提醒,得到 %d 个", cat, len(slots))
}
}
}
// remind_ahead=0 表示不提醒
func TestExpandItem_NoRemindWhenAheadZero(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc)
it := mkItem(comm.MemoryCatTodo, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 0)
if slots := expandItem(it, from, 7, loc); len(slots) != 0 {
t.Errorf("remind_ahead=0 不该产生提醒,得到 %d 个", len(slots))
}
}
// 已完成的项不再提醒
func TestExpandItem_DoneNeverRemind(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc)
it := mkItem(comm.MemoryCatTodo, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5)
it.State = pb.MemoryState_MemoryState_Done
if slots := expandItem(it, from, 7, loc); len(slots) != 0 {
t.Errorf("已完成项不该产生提醒,得到 %d 个", len(slots))
}
}
// 没有 happen_time 的全天项按 9:00 计,不能落在 0:00 半夜吵人
func TestExpandItem_AllDayUsesNineAM(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Shanghai")
from := time.Date(2026, 9, 5, 0, 30, 0, 0, loc)
it := mkItem(comm.MemoryCatTodo, "2026-09-05", "", comm.MemoryRepeatOnce, 0, 5)
slots := expandItem(it, from, 7, loc)
if len(slots) != 1 {
t.Fatalf("应有 1 个时刻,得到 %d", len(slots))
}
h := time.Unix(slots[0].HappenAt, 0).In(loc)
if h.Hour() != 9 || h.Minute() != 0 {
t.Errorf("全天项应按 09:00 计,得到 %02d:%02d", h.Hour(), h.Minute())
}
}
// expandAll 必须按时间升序:客户端要按 iOS 的 64 条上限截断,
// 顺序错了会把明天的排掉、留下一周后的
func TestExpandAll_SortedAscending(t *testing.T) {
items := []*pb.DBMemoryItem{
mkItem(comm.MemoryCatAlarm, "2026-09-30", "09:00", comm.MemoryRepeatOnce, 0, 5),
mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatDaily, 0, 5),
}
items[0].Id, items[1].Id = 10, 20
slots := expandAll(items, 30, "Asia/Shanghai")
for i := 1; i < len(slots); i++ {
if slots[i-1].RemindAt > slots[i].RemindAt {
t.Fatalf("展开结果未按时间升序:第 %d 个 %d > 第 %d 个 %d",
i-1, slots[i-1].RemindAt, i, slots[i].RemindAt)
}
}
}
// 单条项按它自己的 tz 算,不跟着请求方的时区走:
// 「明早 8 点」不该因为用户落地伦敦就变成伦敦时间 8 点
func TestExpandItem_UsesItemOwnTimezone(t *testing.T) {
it := mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 0)
it.RemindAhead = 5
it.Tz = "Asia/Shanghai"
// 请求方声称在伦敦,但项自己带了上海时区
slots := expandAll([]*pb.DBMemoryItem{it}, 30, "Europe/London")
if len(slots) != 1 {
t.Fatalf("应有 1 个时刻,得到 %d", len(slots))
}
sh, _ := time.LoadLocation("Asia/Shanghai")
got := time.Unix(slots[0].HappenAt, 0).In(sh)
if got.Hour() != 9 {
t.Errorf("应按项自己的上海时区算出 09:00,得到 %02d:%02d", got.Hour(), got.Minute())
}
}

277
apps/services/modules/memory/report.go

@ -0,0 +1,277 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"yunyan/comm"
"yunyan/lego/base"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
"yunyan/lego/sys/cron"
redissys "yunyan/lego/sys/redis"
"yunyan/pb"
)
/*
周期报告:cron 建单入队 → N 个 worker BRPop → SQL 算 stat_json → LLM 组织播报文案。
三条要点,全是踩过或推演出来的:
1. **金额与完成数走 SQL,LLM 只组织文案**。数字算错比话说得干巴严重得多。
2. **锁与队列 key 必须过 redissys.RKey 加应用前缀**。测试机上多个应用共用一个
Redis 实例,锁 key 不带前缀会让 A 应用抢到的锁挡住 B 应用整周不生成报告,
而且安安静静什么都不报。
3. **漏跑比重复更可能**。当前 compose 是单副本,"多副本重复生成"是前瞻性风险;
真会发生的是 00:30 跑到一半容器重启、cron 不补跑,报告永远停在 pending。
所以 memory_getreport 里有一条超时重新入队的补偿(见 requeueIfStale)。
*/
type reportComp struct {
cbase.ModuleCompBase
module *Memory
service base.IRPCXService
options *Options
ctx context.Context
cancel context.CancelFunc
}
func (this *reportComp) 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)
this.service = service.(base.IRPCXService)
this.options = opt.(*Options)
this.ctx, this.cancel = context.WithCancel(context.Background())
return nil
}
func (this *reportComp) Start() error {
if err := this.ModuleCompBase.Start(); err != nil {
return err
}
for i := 0; i < this.options.MaxReportProcess; i++ {
go this.consume(i)
}
if _, err := cron.AddFunc(this.options.ReportCronWeek, func() { this.tick(comm.MemoryPeriodWeek) }); err != nil {
this.module.Errorf("注册周报 cron 失败 spec:%s err:%v", this.options.ReportCronWeek, err)
}
if _, err := cron.AddFunc(this.options.ReportCronMonth, func() { this.tick(comm.MemoryPeriodMonth) }); err != nil {
this.module.Errorf("注册月报 cron 失败 spec:%s err:%v", this.options.ReportCronMonth, err)
}
this.module.Infof("周期报告已启动 workers:%d week:%q month:%q",
this.options.MaxReportProcess, this.options.ReportCronWeek, this.options.ReportCronMonth)
return nil
}
func (this *reportComp) Destroy() error {
if this.cancel != nil {
this.cancel()
}
return this.ModuleCompBase.Destroy()
}
// ===== key(一律经 RKey 加应用前缀)=====
func (this *reportComp) awaitKey() string {
return redissys.RKey(fmt.Sprintf("%s:%s", this.service.GetTag(), QueueReportAwait))
}
func (this *reportComp) lockKey(ptype, pkey string) string {
return redissys.RKey(fmt.Sprintf("%s:%s:%s:%s", this.service.GetTag(), LockReportPrefix, ptype, pkey))
}
// ===== cron =====
// tick 到点建单入队。多副本时用 SET NX EX 抢锁,只有一个实例真的干活。
func (this *reportComp) tick(ptype string) {
now := time.Now()
var start, end time.Time
var pkey string
switch ptype {
case comm.MemoryPeriodWeek:
// 周一 00:30 跑的是「上周」,往前退一天落到上周日再取那一周
prev := now.AddDate(0, 0, -1)
start, end = comm.MemoryWeekRange(prev)
pkey = comm.MemoryWeekKey(prev)
case comm.MemoryPeriodMonth:
prev := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1)
start, end = comm.MemoryMonthRange(prev)
pkey = comm.MemoryMonthKey(prev)
default:
return
}
// 锁存 2 小时:足够覆盖一次生成,又不会长到把下一个周期也挡掉
ok, err := redissys.Conn().SetNX(this.ctx, this.lockKey(ptype, pkey), "1", 2*time.Hour).Result()
if err != nil {
this.module.Errorf("报告 cron 抢锁失败 %s/%s err:%v", ptype, pkey, err)
return
}
if !ok {
this.module.Infof("报告 cron %s/%s 已被其它实例领走,跳过", ptype, pkey)
return
}
sd, ed := comm.FormatMemoryDate(start), comm.FormatMemoryDate(end)
uids, err := this.module.model.uidsWithItemsBetween(sd, ed, this.options.ReportMinItems)
if err != nil {
this.module.Errorf("报告 cron 扫描用户失败 %s/%s err:%v", ptype, pkey, err)
return
}
this.module.Infof("报告 cron %s/%s 范围 %s~%s 命中 %d 个用户", ptype, pkey, sd, ed, len(uids))
for _, uid := range uids {
rec := &pb.DBMemoryReport{
Uid: uid, PeriodType: ptype, PeriodKey: pkey,
PeriodStart: sd, PeriodEnd: ed,
State: pb.MemoryReportState_MemoryReportState_Pending,
}
if err := this.module.model.upsertReport(rec); err != nil {
this.module.Errorf("报告建单失败 uid:%s %s/%s err:%v", uid, ptype, pkey, err)
continue
}
if rec.State == pb.MemoryReportState_MemoryReportState_Done && rec.Confirmed {
continue // upsert 里对已确认的直接返回原记录,不重算
}
this.enqueue(rec.Id)
}
}
func (this *reportComp) enqueue(id uint64) {
if err := redissys.Conn().LPush(this.ctx, this.awaitKey(), strconv.FormatUint(id, 10)).Err(); err != nil {
this.module.Errorf("报告入队失败 id:%d err:%v", id, err)
}
}
// requeueIfStale 补偿:报告卡在 pending/processing 超过阈值就重新入队。
//
// 由 memory_getreport 驱动(同 echomeet 用 PollTranscribe 兜「转写回调丢了」的思路)。
// 没有这条,容器在 00:30 重启一次就够让那一周的报告永远停在 pending —— 而且不报任何错。
func (this *reportComp) requeueIfStale(rec *pb.DBMemoryReport) bool {
if rec == nil {
return false
}
if rec.State != pb.MemoryReportState_MemoryReportState_Pending &&
rec.State != pb.MemoryReportState_MemoryReportState_Processing {
return false
}
if time.Now().Unix()-rec.UpdateTime < this.options.ReportStaleSec {
return false
}
this.module.Warnf("报告 id:%d 卡在 state:%d 超过 %ds,重新入队", rec.Id, rec.State, this.options.ReportStaleSec)
rec.State = pb.MemoryReportState_MemoryReportState_Pending
if err := this.module.model.saveReport(rec); err != nil {
this.module.Errorf("报告 id:%d 重置状态失败 err:%v", rec.Id, err)
return false
}
this.enqueue(rec.Id)
return true
}
// ===== worker =====
func (this *reportComp) consume(idx int) {
key := this.awaitKey()
for {
select {
case <-this.ctx.Done():
return
default:
}
res, err := redissys.Conn().BRPop(this.ctx, 5*time.Second, key).Result()
if err != nil {
continue // 超时是常态,不刷日志
}
if len(res) < 2 {
continue
}
id, e := strconv.ParseUint(res[1], 10, 64)
if e != nil {
continue
}
this.process(id)
}
}
func (this *reportComp) process(id uint64) {
defer func() {
if r := recover(); r != nil {
this.module.Errorf("报告生成 panic id:%d err:%v", id, r)
}
}()
rec, err := this.module.model.getReport(id)
if err != nil || rec == nil {
this.module.Errorf("报告生成取记录失败 id:%d err:%v", id, err)
return
}
if rec.State == pb.MemoryReportState_MemoryReportState_Done {
return
}
rec.State = pb.MemoryReportState_MemoryReportState_Processing
_ = this.module.model.saveReport(rec)
// 1) 统计走 SQL,确定性的
st, err := this.module.computeStats(rec.Uid, rec.PeriodStart, rec.PeriodEnd)
if err != nil {
this.fail(rec, "统计失败: "+err.Error())
return
}
if !st.hasContent() {
// 到这儿说明入队后数据被删光了。空报告没有播报价值,直接标完成并确认掉,
// 免得它挂在「待确认」里天天弹。
rec.StatJson = toJSON(st)
rec.Summary = ""
rec.State = pb.MemoryReportState_MemoryReportState_Done
rec.Confirmed = true
rec.ConfirmTime = time.Now().Unix()
_ = this.module.model.saveReport(rec)
return
}
rec.StatJson = toJSON(st)
// 2) LLM 只把 stat_json 组织成一段适合朗读的话
if this.module.echo != nil {
ctx, cancel := context.WithTimeout(this.ctx, 60*time.Second)
text, svcId, e := this.module.echo.ChatLLM(ctx, "", this.options.ReportPrompt, rec.StatJson)
cancel()
rec.LlmSvcId = svcId
if e != nil {
// 文案生成失败**不算整体失败**:统计数据是完整的,客户端拿 stat_json
// 一样能显示,只是少一段朗读文案。宁可少一句话,不可让报告整个没有。
this.module.Warnf("报告 id:%d 文案生成失败,仅保留统计 err:%v", rec.Id, e)
} else {
rec.Summary = text
}
}
rec.State = pb.MemoryReportState_MemoryReportState_Done
rec.ErrorMsg = ""
if err := this.module.model.saveReport(rec); err != nil {
this.module.Errorf("报告 id:%d 回写失败 err:%v", rec.Id, err)
return
}
this.module.Infof("报告生成完成 id:%d uid:%s %s/%s svc:%s",
rec.Id, rec.Uid, rec.PeriodType, rec.PeriodKey, rec.LlmSvcId)
}
func (this *reportComp) fail(rec *pb.DBMemoryReport, msg string) {
rec.State = pb.MemoryReportState_MemoryReportState_Failed
if len([]rune(msg)) > 400 {
msg = string([]rune(msg)[:400])
}
rec.ErrorMsg = msg
_ = this.module.model.saveReport(rec)
this.module.Errorf("报告生成失败 id:%d uid:%s %s", rec.Id, rec.Uid, msg)
}
func toJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}

144
apps/services/modules/memory/stats.go

@ -0,0 +1,144 @@
package memory
import (
"sort"
"yunyan/comm"
"yunyan/lego/sys/mysql"
"yunyan/pb"
)
/*
周期统计。
⚠️ 金额与完成数**全部走 SQL**,绝不让 LLM 算。LLM 只负责把算好的 stat_json
组织成一段适合朗读的话——数字算错比话说得干巴严重得多。
*/
// 明细条数上限。一个重度用户一周能攒两百条未完成,全塞进 prompt 会顶爆上下文、
// 拖慢生成,而且**播报出来没人听得完**。总数用 TodoUndone 表达,明细只举几条。
const statItemLimit = 20
type statBrief struct {
Id uint64 `json:"id"`
Title string `json:"title"`
HappenDate string `json:"happen_date"`
}
type statExpense struct {
Currency string `json:"currency"`
TotalCents int64 `json:"total_cents"`
Count int32 `json:"count"`
}
type memoryStat struct {
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
TodoTotal int32 `json:"todo_total"`
TodoDone int32 `json:"todo_done"`
TodoUndone int32 `json:"todo_undone"`
AlarmTotal int32 `json:"alarm_total"`
IdeaCount int32 `json:"idea_count"`
UndoneItems []statBrief `json:"undone_items"`
UndoneTruncated bool `json:"undone_truncated"`
IdeaItems []statBrief `json:"idea_items"`
IdeaTruncated bool `json:"idea_truncated"`
Expenses []statExpense `json:"expenses"`
}
// hasContent 报告有没有值得播报的内容。全空就别生成了。
func (s *memoryStat) hasContent() bool {
return s.TodoTotal > 0 || s.AlarmTotal > 0 || s.IdeaCount > 0 || len(s.Expenses) > 0
}
// computeStats 按日期范围算统计(含 start 与 end 当天)
func (this *Memory) computeStats(uid, start, end string) (*memoryStat, error) {
st := &memoryStat{PeriodStart: start, PeriodEnd: end,
UndoneItems: []statBrief{}, IdeaItems: []statBrief{}, Expenses: []statExpense{}}
base := func() interface {
Count(*int64) error
} {
return nil
}
_ = base
type catCount struct {
Category string
State int32
N int64
}
rows := make([]catCount, 0)
err := mysql.Table(comm.TableMemoryItem).
Select("category, state, count(*) as n").
Where("uid = ? and happen_date >= ? and happen_date <= ?", uid, start, end).
Group("category, state").Find(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
switch r.Category {
case comm.MemoryCatTodo:
st.TodoTotal += int32(r.N)
if r.State == int32(pb.MemoryState_MemoryState_Done) {
st.TodoDone += int32(r.N)
}
case comm.MemoryCatAlarm:
st.AlarmTotal += int32(r.N)
case comm.MemoryCatIdea:
st.IdeaCount += int32(r.N)
}
}
st.TodoUndone = st.TodoTotal - st.TodoDone
// 花销按币种聚合。金额是 int64 分,SUM 不会有浮点误差。
expRows := make([]statExpense, 0)
err = mysql.Table(comm.TableMemoryItem).
Select("currency, sum(amount_cents) as total_cents, count(*) as count").
Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ?",
uid, start, end, comm.MemoryCatExpense).
Group("currency").Find(&expRows).Error
if err != nil {
return nil, err
}
sort.Slice(expRows, func(i, j int) bool { return expRows[i].TotalCents > expRows[j].TotalCents })
st.Expenses = expRows
// 明细:未完成待办
undone := make([]*pb.DBMemoryItem, 0)
err = mysql.Table(comm.TableMemoryItem).
Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ? and state = ?",
uid, start, end, comm.MemoryCatTodo, int32(pb.MemoryState_MemoryState_Pending)).
Order("happen_date asc").Limit(statItemLimit + 1).Find(&undone).Error
if err != nil {
return nil, err
}
if len(undone) > statItemLimit {
st.UndoneTruncated = true
undone = undone[:statItemLimit]
}
for _, it := range undone {
st.UndoneItems = append(st.UndoneItems, statBrief{Id: it.Id, Title: it.Title, HappenDate: it.HappenDate})
}
// 明细:灵感
ideas := make([]*pb.DBMemoryItem, 0)
err = mysql.Table(comm.TableMemoryItem).
Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ?",
uid, start, end, comm.MemoryCatIdea).
Order("happen_date asc").Limit(statItemLimit + 1).Find(&ideas).Error
if err != nil {
return nil, err
}
if len(ideas) > statItemLimit {
st.IdeaTruncated = true
ideas = ideas[:statItemLimit]
}
for _, it := range ideas {
st.IdeaItems = append(st.IdeaItems, statBrief{Id: it.Id, Title: it.Title, HappenDate: it.HappenDate})
}
return st, nil
}

13
apps/services/modules/user/model_user.go

@ -132,6 +132,19 @@ func (this *modelUserComp) cancel(user *pb.DBUser) (err error) {
if err = mysql.Delete(comm.TableEchomeetRecord, "uid=?", uid); err != nil {
return
}
// 记忆中心(拾忆):花销金额、灵感原文、全部行程都在这两张表里,注销必须清干净。
// ⚠️ 这个清单是**硬编码**的,没有任何机制会提醒你新表漏登记——漏了不报错,
// 只是用户注销后个人数据静静留在库里,发现时已经是合规问题。加新表记得回来加一行。
if err = mysql.Delete(comm.TableMemoryItem, "uid=?", uid); err != nil {
return
}
if err = mysql.Delete(comm.TableMemoryReport, "uid=?", uid); err != nil {
return
}
// 实名认证记录(身份证掩码 + 加盐指纹)。既有遗漏,本次一并补上。
if err = mysql.Delete(comm.TableUserIdVerify, "uid=?", uid); err != nil {
return
}
// 3) 清理 DBUser 上的业务字段与 PII,保留身份锚点、Createtime、Isgiveaway
user.Name = ""
user.Avatar = ""

6
apps/services/modules/user/module_test.go

@ -8,6 +8,7 @@ package user
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -37,6 +38,11 @@ type fakeEchomeet struct{ caps comm.MeetCapabilities }
func (f *fakeEchomeet) MeetCapabilities() comm.MeetCapabilities { return f.caps }
// ChatLLM 本测试用不到(user 模块只关心能力下发),给个空实现满足接口。
func (f *fakeEchomeet) ChatLLM(ctx context.Context, svcId, systemPrompt, userContent string) (string, string, error) {
return "", "", nil
}
// newV3Comp 造一个只够跑纯逻辑的 apiV3Comp:不 Init(Init 要真 service),
// 只挂一个带 echomeet 的 User。echomeet 传 nil = 本服务没装 echomeet 模块。
func newV3Comp(echomeet comm.IEchomeet) *apiV3Comp {

29
apps/services/pb/errorcode.pb.go

@ -105,6 +105,13 @@ const (
ErrorCode_IdVerifyMismatch ErrorCode = 5003 //姓名与身份证号不一致(服务商判定)
ErrorCode_IdVerifyProviderError ErrorCode = 5004 //服务商调用失败(网络/额度/鉴权等,非"不一致")
ErrorCode_IdVerifyTooFrequent ErrorCode = 5005 //校验过于频繁,已被限流
// 记忆中心(拾忆)相关错误码 5201-5206
ErrorCode_MemoryCategoryInvalid ErrorCode = 5201 //分类不在白名单里(服务端 comm.MemoryCategories)
ErrorCode_MemoryItemNotFound ErrorCode = 5202 //记忆项不存在或不属于当前用户
ErrorCode_MemoryParamInvalid ErrorCode = 5203 //参数不合法(日期格式、金额为负、标题为空等)
ErrorCode_MemoryReportNotFound ErrorCode = 5204 //报告不存在
ErrorCode_MemoryReportNotReady ErrorCode = 5205 //报告还在生成中(客户端应转圈重试,不要弹错)
ErrorCode_MemoryReportFailed ErrorCode = 5206 //报告生成失败(error_msg 里有原因)
// 实时机器翻译相关错误码 5101-5103
ErrorCode_TranslateNotConfigured ErrorCode = 5101 //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
ErrorCode_TranslateParamInvalid ErrorCode = 5102 //翻译参数不合法(原文为空 / 目标语言为空)
@ -184,6 +191,12 @@ var (
5003: "IdVerifyMismatch",
5004: "IdVerifyProviderError",
5005: "IdVerifyTooFrequent",
5201: "MemoryCategoryInvalid",
5202: "MemoryItemNotFound",
5203: "MemoryParamInvalid",
5204: "MemoryReportNotFound",
5205: "MemoryReportNotReady",
5206: "MemoryReportFailed",
5101: "TranslateNotConfigured",
5102: "TranslateParamInvalid",
5103: "TranslateProviderError",
@ -259,6 +272,12 @@ var (
"IdVerifyMismatch": 5003,
"IdVerifyProviderError": 5004,
"IdVerifyTooFrequent": 5005,
"MemoryCategoryInvalid": 5201,
"MemoryItemNotFound": 5202,
"MemoryParamInvalid": 5203,
"MemoryReportNotFound": 5204,
"MemoryReportNotReady": 5205,
"MemoryReportFailed": 5206,
"TranslateNotConfigured": 5101,
"TranslateParamInvalid": 5102,
"TranslateProviderError": 5103,
@ -296,7 +315,7 @@ var File_errorcode_proto protoreflect.FileDescriptor
const file_errorcode_proto_rawDesc = "" +
"\n" +
"\x0ferrorcode.proto*\xeb\f\n" +
"\x0ferrorcode.proto*\x88\x0e\n" +
"\tErrorCode\x12\v\n" +
"\aSuccess\x10\x00\x12\x14\n" +
"\x10GatewayException\x10\x01\x12\x11\n" +
@ -370,7 +389,13 @@ const file_errorcode_proto_rawDesc = "" +
"\x14IdVerifyParamInvalid\x10\x8a'\x12\x15\n" +
"\x10IdVerifyMismatch\x10\x8b'\x12\x1a\n" +
"\x15IdVerifyProviderError\x10\x8c'\x12\x18\n" +
"\x13IdVerifyTooFrequent\x10\x8d'\x12\x1b\n" +
"\x13IdVerifyTooFrequent\x10\x8d'\x12\x1a\n" +
"\x15MemoryCategoryInvalid\x10\xd1(\x12\x17\n" +
"\x12MemoryItemNotFound\x10\xd2(\x12\x17\n" +
"\x12MemoryParamInvalid\x10\xd3(\x12\x19\n" +
"\x14MemoryReportNotFound\x10\xd4(\x12\x19\n" +
"\x14MemoryReportNotReady\x10\xd5(\x12\x17\n" +
"\x12MemoryReportFailed\x10\xd6(\x12\x1b\n" +
"\x16TranslateNotConfigured\x10\xed'\x12\x1a\n" +
"\x15TranslateParamInvalid\x10\xee'\x12\x1b\n" +
"\x16TranslateProviderError\x10\xef'B\x06Z\x04.;pbb\x06proto3"

681
apps/services/pb/memory_db.pb.go

@ -0,0 +1,681 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v7.34.1
// source: memory/memory_db.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// 记忆项状态
type MemoryState int32
const (
MemoryState_MemoryState_Pending MemoryState = 0 // 待办/未完成
MemoryState_MemoryState_Done MemoryState = 1 // 已完成
MemoryState_MemoryState_Dropped MemoryState = 2 // 已废弃
)
// Enum value maps for MemoryState.
var (
MemoryState_name = map[int32]string{
0: "MemoryState_Pending",
1: "MemoryState_Done",
2: "MemoryState_Dropped",
}
MemoryState_value = map[string]int32{
"MemoryState_Pending": 0,
"MemoryState_Done": 1,
"MemoryState_Dropped": 2,
}
)
func (x MemoryState) Enum() *MemoryState {
p := new(MemoryState)
*p = x
return p
}
func (x MemoryState) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MemoryState) Descriptor() protoreflect.EnumDescriptor {
return file_memory_memory_db_proto_enumTypes[0].Descriptor()
}
func (MemoryState) Type() protoreflect.EnumType {
return &file_memory_memory_db_proto_enumTypes[0]
}
func (x MemoryState) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MemoryState.Descriptor instead.
func (MemoryState) EnumDescriptor() ([]byte, []int) {
return file_memory_memory_db_proto_rawDescGZIP(), []int{0}
}
// 周期报告生成状态
type MemoryReportState int32
const (
MemoryReportState_MemoryReportState_Pending MemoryReportState = 0 // 待生成(入队)
MemoryReportState_MemoryReportState_Processing MemoryReportState = 1 // 生成中
MemoryReportState_MemoryReportState_Done MemoryReportState = 2 // 完成
MemoryReportState_MemoryReportState_Failed MemoryReportState = 3 // 失败
)
// Enum value maps for MemoryReportState.
var (
MemoryReportState_name = map[int32]string{
0: "MemoryReportState_Pending",
1: "MemoryReportState_Processing",
2: "MemoryReportState_Done",
3: "MemoryReportState_Failed",
}
MemoryReportState_value = map[string]int32{
"MemoryReportState_Pending": 0,
"MemoryReportState_Processing": 1,
"MemoryReportState_Done": 2,
"MemoryReportState_Failed": 3,
}
)
func (x MemoryReportState) Enum() *MemoryReportState {
p := new(MemoryReportState)
*p = x
return p
}
func (x MemoryReportState) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MemoryReportState) Descriptor() protoreflect.EnumDescriptor {
return file_memory_memory_db_proto_enumTypes[1].Descriptor()
}
func (MemoryReportState) Type() protoreflect.EnumType {
return &file_memory_memory_db_proto_enumTypes[1]
}
func (x MemoryReportState) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MemoryReportState.Descriptor instead.
func (MemoryReportState) EnumDescriptor() ([]byte, []int) {
return file_memory_memory_db_proto_rawDescGZIP(), []int{1}
}
// 记忆项:一张表承载全部分类(拾忆页的唯一真相源)
//
// 字段类型说明(protobuf 没有 DECIMAL/DATE/DATETIME/null,逐个落地见设计文档 §3.2):
// - 金额用 int64 存「分」,与 pay/结算模块同口径,绝不用 double
// - happen_date 用 string + gorm type:date,值形如 2026-09-04
// - next_remind_at 用 int64 unix 秒,0 表示无
type DBMemoryItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"primaryKey;autoIncrement"` //@go_tags(`gorm:"primaryKey;autoIncrement" json:"id"`) 主键自增
Uid string `protobuf:"bytes,2,opt,name=uid,proto3" json:"uid" gorm:"size:50;index:idx_mem_uid_date,priority:1;index:idx_mem_uid_cat,priority:1;uniqueIndex:idx_mem_uid_ck,priority:1"` //@go_tags(`gorm:"size:50;index:idx_mem_uid_date,priority:1;index:idx_mem_uid_cat,priority:1;uniqueIndex:idx_mem_uid_ck,priority:1" json:"uid"`) 用户ID
Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category" gorm:"size:16;index:idx_mem_uid_cat,priority:2"` //@go_tags(`gorm:"size:16;index:idx_mem_uid_cat,priority:2" json:"category"`) 分类 alarm/todo/idea/expense
Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source" gorm:"size:16"` //@go_tags(`gorm:"size:16" json:"source"`) 来源 meeting/assistant/manual/device
SourceId string `protobuf:"bytes,5,opt,name=source_id,json=sourceId,proto3" json:"source_id" gorm:"size:64;index"` //@go_tags(`gorm:"size:64;index" json:"source_id"`) 会议记录id、端侧指令id等
ClientKey string `protobuf:"bytes,6,opt,name=client_key,json=clientKey,proto3" json:"client_key" gorm:"size:64;uniqueIndex:idx_mem_uid_ck,priority:2"` //@go_tags(`gorm:"size:64;uniqueIndex:idx_mem_uid_ck,priority:2" json:"client_key"`) 幂等键(端侧指令id或本地uuid)
Title string `protobuf:"bytes,7,opt,name=title,proto3" json:"title" gorm:"size:200"` //@go_tags(`gorm:"size:200" json:"title"`) 主文案(所有分类通用)
Detail string `protobuf:"bytes,8,opt,name=detail,proto3" json:"detail" gorm:"type:text"` //@go_tags(`gorm:"type:text" json:"detail"`) 补充说明 / 灵感正文
HappenDate string `protobuf:"bytes,9,opt,name=happen_date,json=happenDate,proto3" json:"happen_date" gorm:"type:date;index:idx_mem_uid_date,priority:2;index:idx_mem_uid_cat,priority:3"` //@go_tags(`gorm:"type:date;index:idx_mem_uid_date,priority:2;index:idx_mem_uid_cat,priority:3" json:"happen_date"`) 归属日期 YYYY-MM-DD,永不为空
HappenTime string `protobuf:"bytes,10,opt,name=happen_time,json=happenTime,proto3" json:"happen_time" gorm:"size:5"` //@go_tags(`gorm:"size:5" json:"happen_time"`) HH:mm,无具体时间则空
DateCertain bool `protobuf:"varint,11,opt,name=date_certain,json=dateCertain,proto3" json:"date_certain" gorm:"default:true"` //@go_tags(`gorm:"default:true" json:"date_certain"`) false=日期是推断的(如落在会议当天)
DueRaw string `protobuf:"bytes,12,opt,name=due_raw,json=dueRaw,proto3" json:"due_raw" gorm:"size:64"` //@go_tags(`gorm:"size:64" json:"due_raw"`) 截止时间原文(「下周三前」)
Tz string `protobuf:"bytes,13,opt,name=tz,proto3" json:"tz" gorm:"size:48"` //@go_tags(`gorm:"size:48" json:"tz"`) 录入时的客户端时区(IANA名),展开提醒按它算
RepeatRule string `protobuf:"bytes,14,opt,name=repeat_rule,json=repeatRule,proto3" json:"repeat_rule" gorm:"size:16"` //@go_tags(`gorm:"size:16" json:"repeat_rule"`) once/daily/weekly/weekdays/weekend/monthly
RepeatRaw string `protobuf:"bytes,15,opt,name=repeat_raw,json=repeatRaw,proto3" json:"repeat_raw" gorm:"size:64"` //@go_tags(`gorm:"size:64" json:"repeat_raw"`) 重复规则原文(「每周一」)
Weekday int32 `protobuf:"varint,16,opt,name=weekday,proto3" json:"weekday" gorm:"default:0"` //@go_tags(`gorm:"default:0" json:"weekday"`) weekly 时 1-7
AmountCents int64 `protobuf:"varint,17,opt,name=amount_cents,json=amountCents,proto3" json:"amount_cents" gorm:"default:0"` //@go_tags(`gorm:"default:0" json:"amount_cents"`) 花销金额(分),category=expense
Currency string `protobuf:"bytes,18,opt,name=currency,proto3" json:"currency" gorm:"size:8"` //@go_tags(`gorm:"size:8" json:"currency"`) 货币码,默认 CNY
Owner string `protobuf:"bytes,19,opt,name=owner,proto3" json:"owner" gorm:"size:64"` //@go_tags(`gorm:"size:64" json:"owner"`) 负责人原文(会议待办)
State MemoryState `protobuf:"varint,20,opt,name=state,proto3,enum=MemoryState" json:"state" gorm:"default:0;index"` //@go_tags(`gorm:"default:0;index" json:"state"`) 状态
RemindAhead int32 `protobuf:"varint,21,opt,name=remind_ahead,json=remindAhead,proto3" json:"remind_ahead" gorm:"default:5"` //@go_tags(`gorm:"default:5" json:"remind_ahead"`) 提前提醒分钟数,0=不提醒
NextRemindAt int64 `protobuf:"varint,22,opt,name=next_remind_at,json=nextRemindAt,proto3" json:"next_remind_at" gorm:"default:0;index:idx_mem_remind,priority:1"` //@go_tags(`gorm:"default:0;index:idx_mem_remind,priority:1" json:"next_remind_at"`) 下一次提醒 unix 秒,0=无
UserEdited bool `protobuf:"varint,23,opt,name=user_edited,json=userEdited,proto3" json:"user_edited" gorm:"default:false"` //@go_tags(`gorm:"default:false" json:"user_edited"`) 用户改过 → 重新生成时不覆盖
GenRound int32 `protobuf:"varint,24,opt,name=gen_round,json=genRound,proto3" json:"gen_round" gorm:"default:0"` //@go_tags(`gorm:"default:0" json:"gen_round"`) 第几轮AI生成
Extra string `protobuf:"bytes,25,opt,name=extra,proto3" json:"extra" gorm:"size:2000"` //@go_tags(`gorm:"size:2000" json:"extra"`) 扩展信息JSON
CreateTime int64 `protobuf:"varint,26,opt,name=create_time,json=createTime,proto3" json:"create_time" gorm:"index"` //@go_tags(`gorm:"index" json:"create_time"`)
UpdateTime int64 `protobuf:"varint,27,opt,name=update_time,json=updateTime,proto3" json:"update_time"` //@go_tags(`json:"update_time"`)
FinishTime int64 `protobuf:"varint,28,opt,name=finish_time,json=finishTime,proto3" json:"finish_time" gorm:"index"` //@go_tags(`gorm:"index" json:"finish_time"`)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DBMemoryItem) Reset() {
*x = DBMemoryItem{}
mi := &file_memory_memory_db_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DBMemoryItem) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DBMemoryItem) ProtoMessage() {}
func (x *DBMemoryItem) ProtoReflect() protoreflect.Message {
mi := &file_memory_memory_db_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DBMemoryItem.ProtoReflect.Descriptor instead.
func (*DBMemoryItem) Descriptor() ([]byte, []int) {
return file_memory_memory_db_proto_rawDescGZIP(), []int{0}
}
func (x *DBMemoryItem) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *DBMemoryItem) GetUid() string {
if x != nil {
return x.Uid
}
return ""
}
func (x *DBMemoryItem) GetCategory() string {
if x != nil {
return x.Category
}
return ""
}
func (x *DBMemoryItem) GetSource() string {
if x != nil {
return x.Source
}
return ""
}
func (x *DBMemoryItem) GetSourceId() string {
if x != nil {
return x.SourceId
}
return ""
}
func (x *DBMemoryItem) GetClientKey() string {
if x != nil {
return x.ClientKey
}
return ""
}
func (x *DBMemoryItem) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *DBMemoryItem) GetDetail() string {
if x != nil {
return x.Detail
}
return ""
}
func (x *DBMemoryItem) GetHappenDate() string {
if x != nil {
return x.HappenDate
}
return ""
}
func (x *DBMemoryItem) GetHappenTime() string {
if x != nil {
return x.HappenTime
}
return ""
}
func (x *DBMemoryItem) GetDateCertain() bool {
if x != nil {
return x.DateCertain
}
return false
}
func (x *DBMemoryItem) GetDueRaw() string {
if x != nil {
return x.DueRaw
}
return ""
}
func (x *DBMemoryItem) GetTz() string {
if x != nil {
return x.Tz
}
return ""
}
func (x *DBMemoryItem) GetRepeatRule() string {
if x != nil {
return x.RepeatRule
}
return ""
}
func (x *DBMemoryItem) GetRepeatRaw() string {
if x != nil {
return x.RepeatRaw
}
return ""
}
func (x *DBMemoryItem) GetWeekday() int32 {
if x != nil {
return x.Weekday
}
return 0
}
func (x *DBMemoryItem) GetAmountCents() int64 {
if x != nil {
return x.AmountCents
}
return 0
}
func (x *DBMemoryItem) GetCurrency() string {
if x != nil {
return x.Currency
}
return ""
}
func (x *DBMemoryItem) GetOwner() string {
if x != nil {
return x.Owner
}
return ""
}
func (x *DBMemoryItem) GetState() MemoryState {
if x != nil {
return x.State
}
return MemoryState_MemoryState_Pending
}
func (x *DBMemoryItem) GetRemindAhead() int32 {
if x != nil {
return x.RemindAhead
}
return 0
}
func (x *DBMemoryItem) GetNextRemindAt() int64 {
if x != nil {
return x.NextRemindAt
}
return 0
}
func (x *DBMemoryItem) GetUserEdited() bool {
if x != nil {
return x.UserEdited
}
return false
}
func (x *DBMemoryItem) GetGenRound() int32 {
if x != nil {
return x.GenRound
}
return 0
}
func (x *DBMemoryItem) GetExtra() string {
if x != nil {
return x.Extra
}
return ""
}
func (x *DBMemoryItem) GetCreateTime() int64 {
if x != nil {
return x.CreateTime
}
return 0
}
func (x *DBMemoryItem) GetUpdateTime() int64 {
if x != nil {
return x.UpdateTime
}
return 0
}
func (x *DBMemoryItem) GetFinishTime() int64 {
if x != nil {
return x.FinishTime
}
return 0
}
// 周报/月报
// 唯一约束 (uid, period_type, period_key):同一周期只保留一条,重算按覆盖
type DBMemoryReport struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"primaryKey;autoIncrement"` //@go_tags(`gorm:"primaryKey;autoIncrement" json:"id"`)
Uid string `protobuf:"bytes,2,opt,name=uid,proto3" json:"uid" gorm:"size:50;uniqueIndex:idx_memrep_uid_period,priority:1;index"` //@go_tags(`gorm:"size:50;uniqueIndex:idx_memrep_uid_period,priority:1;index" json:"uid"`) 用户ID
PeriodType string `protobuf:"bytes,3,opt,name=period_type,json=periodType,proto3" json:"period_type" gorm:"size:8;uniqueIndex:idx_memrep_uid_period,priority:2"` //@go_tags(`gorm:"size:8;uniqueIndex:idx_memrep_uid_period,priority:2" json:"period_type"`) week/month
PeriodKey string `protobuf:"bytes,4,opt,name=period_key,json=periodKey,proto3" json:"period_key" gorm:"size:16;uniqueIndex:idx_memrep_uid_period,priority:3"` //@go_tags(`gorm:"size:16;uniqueIndex:idx_memrep_uid_period,priority:3" json:"period_key"`) 2026-W35 / 2026-08
PeriodStart string `protobuf:"bytes,5,opt,name=period_start,json=periodStart,proto3" json:"period_start" gorm:"type:date"` //@go_tags(`gorm:"type:date" json:"period_start"`) 周期起始日 YYYY-MM-DD
PeriodEnd string `protobuf:"bytes,6,opt,name=period_end,json=periodEnd,proto3" json:"period_end" gorm:"type:date"` //@go_tags(`gorm:"type:date" json:"period_end"`) 周期结束日 YYYY-MM-DD
StatJson string `protobuf:"bytes,7,opt,name=stat_json,json=statJson,proto3" json:"stat_json" gorm:"type:text"` //@go_tags(`gorm:"type:text" json:"stat_json"`) 结构化统计(服务端SQL算,不过LLM)
Summary string `protobuf:"bytes,8,opt,name=summary,proto3" json:"summary" gorm:"type:text"` //@go_tags(`gorm:"type:text" json:"summary"`) AI生成的播报文案
LlmSvcId string `protobuf:"bytes,9,opt,name=llm_svc_id,json=llmSvcId,proto3" json:"llm_svc_id" gorm:"size:64"` //@go_tags(`gorm:"size:64" json:"llm_svc_id"`) 实际用的总结服务id(排障用,同echomeet口径)
State MemoryReportState `protobuf:"varint,10,opt,name=state,proto3,enum=MemoryReportState" json:"state" gorm:"default:0;index"` //@go_tags(`gorm:"default:0;index" json:"state"`)
ErrorMsg string `protobuf:"bytes,11,opt,name=error_msg,json=errorMsg,proto3" json:"error_msg" gorm:"size:500"` //@go_tags(`gorm:"size:500" json:"error_msg"`) 失败原因
Confirmed bool `protobuf:"varint,12,opt,name=confirmed,proto3" json:"confirmed" gorm:"default:false"` //@go_tags(`gorm:"default:false" json:"confirmed"`) 用户是否已确认
ConfirmTime int64 `protobuf:"varint,13,opt,name=confirm_time,json=confirmTime,proto3" json:"confirm_time"` //@go_tags(`json:"confirm_time"`)
CreateTime int64 `protobuf:"varint,14,opt,name=create_time,json=createTime,proto3" json:"create_time" gorm:"index"` //@go_tags(`gorm:"index" json:"create_time"`)
UpdateTime int64 `protobuf:"varint,15,opt,name=update_time,json=updateTime,proto3" json:"update_time"` //@go_tags(`json:"update_time"`)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DBMemoryReport) Reset() {
*x = DBMemoryReport{}
mi := &file_memory_memory_db_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DBMemoryReport) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DBMemoryReport) ProtoMessage() {}
func (x *DBMemoryReport) ProtoReflect() protoreflect.Message {
mi := &file_memory_memory_db_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DBMemoryReport.ProtoReflect.Descriptor instead.
func (*DBMemoryReport) Descriptor() ([]byte, []int) {
return file_memory_memory_db_proto_rawDescGZIP(), []int{1}
}
func (x *DBMemoryReport) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *DBMemoryReport) GetUid() string {
if x != nil {
return x.Uid
}
return ""
}
func (x *DBMemoryReport) GetPeriodType() string {
if x != nil {
return x.PeriodType
}
return ""
}
func (x *DBMemoryReport) GetPeriodKey() string {
if x != nil {
return x.PeriodKey
}
return ""
}
func (x *DBMemoryReport) GetPeriodStart() string {
if x != nil {
return x.PeriodStart
}
return ""
}
func (x *DBMemoryReport) GetPeriodEnd() string {
if x != nil {
return x.PeriodEnd
}
return ""
}
func (x *DBMemoryReport) GetStatJson() string {
if x != nil {
return x.StatJson
}
return ""
}
func (x *DBMemoryReport) GetSummary() string {
if x != nil {
return x.Summary
}
return ""
}
func (x *DBMemoryReport) GetLlmSvcId() string {
if x != nil {
return x.LlmSvcId
}
return ""
}
func (x *DBMemoryReport) GetState() MemoryReportState {
if x != nil {
return x.State
}
return MemoryReportState_MemoryReportState_Pending
}
func (x *DBMemoryReport) GetErrorMsg() string {
if x != nil {
return x.ErrorMsg
}
return ""
}
func (x *DBMemoryReport) GetConfirmed() bool {
if x != nil {
return x.Confirmed
}
return false
}
func (x *DBMemoryReport) GetConfirmTime() int64 {
if x != nil {
return x.ConfirmTime
}
return 0
}
func (x *DBMemoryReport) GetCreateTime() int64 {
if x != nil {
return x.CreateTime
}
return 0
}
func (x *DBMemoryReport) GetUpdateTime() int64 {
if x != nil {
return x.UpdateTime
}
return 0
}
var File_memory_memory_db_proto protoreflect.FileDescriptor
const file_memory_memory_db_proto_rawDesc = "" +
"\n" +
"\x16memory/memory_db.proto\"\xaf\x06\n" +
"\fDBMemoryItem\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12\x1a\n" +
"\bcategory\x18\x03 \x01(\tR\bcategory\x12\x16\n" +
"\x06source\x18\x04 \x01(\tR\x06source\x12\x1b\n" +
"\tsource_id\x18\x05 \x01(\tR\bsourceId\x12\x1d\n" +
"\n" +
"client_key\x18\x06 \x01(\tR\tclientKey\x12\x14\n" +
"\x05title\x18\a \x01(\tR\x05title\x12\x16\n" +
"\x06detail\x18\b \x01(\tR\x06detail\x12\x1f\n" +
"\vhappen_date\x18\t \x01(\tR\n" +
"happenDate\x12\x1f\n" +
"\vhappen_time\x18\n" +
" \x01(\tR\n" +
"happenTime\x12!\n" +
"\fdate_certain\x18\v \x01(\bR\vdateCertain\x12\x17\n" +
"\adue_raw\x18\f \x01(\tR\x06dueRaw\x12\x0e\n" +
"\x02tz\x18\r \x01(\tR\x02tz\x12\x1f\n" +
"\vrepeat_rule\x18\x0e \x01(\tR\n" +
"repeatRule\x12\x1d\n" +
"\n" +
"repeat_raw\x18\x0f \x01(\tR\trepeatRaw\x12\x18\n" +
"\aweekday\x18\x10 \x01(\x05R\aweekday\x12!\n" +
"\famount_cents\x18\x11 \x01(\x03R\vamountCents\x12\x1a\n" +
"\bcurrency\x18\x12 \x01(\tR\bcurrency\x12\x14\n" +
"\x05owner\x18\x13 \x01(\tR\x05owner\x12\"\n" +
"\x05state\x18\x14 \x01(\x0e2\f.MemoryStateR\x05state\x12!\n" +
"\fremind_ahead\x18\x15 \x01(\x05R\vremindAhead\x12$\n" +
"\x0enext_remind_at\x18\x16 \x01(\x03R\fnextRemindAt\x12\x1f\n" +
"\vuser_edited\x18\x17 \x01(\bR\n" +
"userEdited\x12\x1b\n" +
"\tgen_round\x18\x18 \x01(\x05R\bgenRound\x12\x14\n" +
"\x05extra\x18\x19 \x01(\tR\x05extra\x12\x1f\n" +
"\vcreate_time\x18\x1a \x01(\x03R\n" +
"createTime\x12\x1f\n" +
"\vupdate_time\x18\x1b \x01(\x03R\n" +
"updateTime\x12\x1f\n" +
"\vfinish_time\x18\x1c \x01(\x03R\n" +
"finishTime\"\xd3\x03\n" +
"\x0eDBMemoryReport\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12\x1f\n" +
"\vperiod_type\x18\x03 \x01(\tR\n" +
"periodType\x12\x1d\n" +
"\n" +
"period_key\x18\x04 \x01(\tR\tperiodKey\x12!\n" +
"\fperiod_start\x18\x05 \x01(\tR\vperiodStart\x12\x1d\n" +
"\n" +
"period_end\x18\x06 \x01(\tR\tperiodEnd\x12\x1b\n" +
"\tstat_json\x18\a \x01(\tR\bstatJson\x12\x18\n" +
"\asummary\x18\b \x01(\tR\asummary\x12\x1c\n" +
"\n" +
"llm_svc_id\x18\t \x01(\tR\bllmSvcId\x12(\n" +
"\x05state\x18\n" +
" \x01(\x0e2\x12.MemoryReportStateR\x05state\x12\x1b\n" +
"\terror_msg\x18\v \x01(\tR\berrorMsg\x12\x1c\n" +
"\tconfirmed\x18\f \x01(\bR\tconfirmed\x12!\n" +
"\fconfirm_time\x18\r \x01(\x03R\vconfirmTime\x12\x1f\n" +
"\vcreate_time\x18\x0e \x01(\x03R\n" +
"createTime\x12\x1f\n" +
"\vupdate_time\x18\x0f \x01(\x03R\n" +
"updateTime*U\n" +
"\vMemoryState\x12\x17\n" +
"\x13MemoryState_Pending\x10\x00\x12\x14\n" +
"\x10MemoryState_Done\x10\x01\x12\x17\n" +
"\x13MemoryState_Dropped\x10\x02*\x8e\x01\n" +
"\x11MemoryReportState\x12\x1d\n" +
"\x19MemoryReportState_Pending\x10\x00\x12 \n" +
"\x1cMemoryReportState_Processing\x10\x01\x12\x1a\n" +
"\x16MemoryReportState_Done\x10\x02\x12\x1c\n" +
"\x18MemoryReportState_Failed\x10\x03B\x06Z\x04.;pbb\x06proto3"
var (
file_memory_memory_db_proto_rawDescOnce sync.Once
file_memory_memory_db_proto_rawDescData []byte
)
func file_memory_memory_db_proto_rawDescGZIP() []byte {
file_memory_memory_db_proto_rawDescOnce.Do(func() {
file_memory_memory_db_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_memory_memory_db_proto_rawDesc), len(file_memory_memory_db_proto_rawDesc)))
})
return file_memory_memory_db_proto_rawDescData
}
var file_memory_memory_db_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_memory_memory_db_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_memory_memory_db_proto_goTypes = []any{
(MemoryState)(0), // 0: MemoryState
(MemoryReportState)(0), // 1: MemoryReportState
(*DBMemoryItem)(nil), // 2: DBMemoryItem
(*DBMemoryReport)(nil), // 3: DBMemoryReport
}
var file_memory_memory_db_proto_depIdxs = []int32{
0, // 0: DBMemoryItem.state:type_name -> MemoryState
1, // 1: DBMemoryReport.state:type_name -> MemoryReportState
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_memory_memory_db_proto_init() }
func file_memory_memory_db_proto_init() {
if File_memory_memory_db_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_memory_memory_db_proto_rawDesc), len(file_memory_memory_db_proto_rawDesc)),
NumEnums: 2,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_memory_memory_db_proto_goTypes,
DependencyIndexes: file_memory_memory_db_proto_depIdxs,
EnumInfos: file_memory_memory_db_proto_enumTypes,
MessageInfos: file_memory_memory_db_proto_msgTypes,
}.Build()
File_memory_memory_db_proto = out.File
file_memory_memory_db_proto_goTypes = nil
file_memory_memory_db_proto_depIdxs = nil
}

1541
apps/services/pb/memory_msg.pb.go

File diff suppressed because it is too large

2
apps/services/services/home/main.go

@ -8,6 +8,7 @@ import (
"yunyan/modules/allhelp"
"yunyan/modules/analyze"
"yunyan/modules/echomeet"
"yunyan/modules/memory"
musicmodule "yunyan/modules/music"
"yunyan/modules/pay"
"yunyan/modules/svccfg"
@ -81,6 +82,7 @@ func main() {
echomeet.NewModule(),
musicmodule.NewModule(),
allhelp.NewModule(),
memory.NewModule(), //记忆中心(拾忆):闹钟/待办/灵感/花销 + 周报月报
// bailianmodule.NewModule(),
)
}

10
deploy/app/confs/home.yaml.example

@ -146,6 +146,16 @@ modules:
CBCKey: ""
allhelp:
max_summary_process: 5
memory: #记忆中心(拾忆):闹钟/待办/灵感/花销统一存储 + 周报月报
Debug: ${LOG_DEBUG:-false}
max_report_process: 3 #报告生成 worker 数
report_min_items: 3 #上周期至少这么多条记录才生成报告(当前唯一的成本闸门)
# ⚠️ cron 走的是容器时区,恒为 Asia/Shanghai(Dockerfile 与本 compose 都设了 TZ),
# 对所有区域所有用户都是北京时间。海外用户的「上周」边界会偏,详见记忆中心设计文档 §5.3。
report_cron_week: "0 30 0 ? * MON" #每周一 00:30 生成上周报告
report_cron_month: "0 30 0 1 * ?" #每月 1 号 00:30 生成上月报告
report_stale_sec: 900 #报告卡在待生成/生成中超过这么久,由 memory_getreport 补偿重新入队
meeting_extract: true #会议纪要生成后自动抽取待办(失败只记日志,不影响纪要)
echomeet: #会议记录:识别/翻译/总结服务不再走 yaml——全部由后台「会议记录服务」编排
# (console 共享库 echomeet_orch + 服务池 svc_config)配置,运行时直读 + NATS 热重载。
# 部署身份与解密密钥由 .env 注入:ANALYZE_APP_NAME / ANALYZE_REGION / FIELD_ENCRYPT_KEY。

Loading…
Cancel
Save