From 0b0ac16b3cb53759511aefa4c1d8aa8fba0cc6ae Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 00:15:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B0=E5=BF=86=E4=B8=AD=E5=BF=83=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9Amemory=20=E6=A8=A1=E5=9D=97=20+=20=E4=BC=9A?= =?UTF-8?q?=E8=AE=AE=E5=BE=85=E5=8A=9E=E6=8A=BD=E5=8F=96=20+=20=E5=91=A8?= =?UTF-8?q?=E6=8A=A5=E6=9C=88=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按设计文档一~三期的后端部分落地。全量 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 而非转写全文。注入 ,服务端二次校验 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) --- apps/proto/errorcode.proto | 8 + apps/proto/memory/memory_db.proto | 91 + apps/proto/memory/memory_msg.proto | 136 ++ apps/services/comm/const.go | 3 + apps/services/comm/memory.go | 188 ++ apps/services/comm/memory_test.go | 132 ++ apps/services/comm/module.go | 32 +- apps/services/modules/echomeet/module.go | 65 + apps/services/modules/echomeet/tasks.go | 29 + apps/services/modules/memory/api.go | 22 + apps/services/modules/memory/api_add.go | 60 + apps/services/modules/memory/api_complete.go | 36 + apps/services/modules/memory/api_del.go | 37 + apps/services/modules/memory/api_list.go | 58 + apps/services/modules/memory/api_report.go | 127 ++ apps/services/modules/memory/api_stats.go | 47 + apps/services/modules/memory/api_today.go | 56 + apps/services/modules/memory/api_upcoming.go | 69 + apps/services/modules/memory/api_update.go | 121 ++ apps/services/modules/memory/api_validate.go | 136 ++ apps/services/modules/memory/core.go | 34 + .../modules/memory/meeting_extract.go | 198 +++ .../modules/memory/meeting_extract_test.go | 53 + apps/services/modules/memory/model.go | 310 ++++ apps/services/modules/memory/module.go | 75 + apps/services/modules/memory/options.go | 62 + apps/services/modules/memory/remind.go | 134 ++ apps/services/modules/memory/remind_test.go | 166 ++ apps/services/modules/memory/report.go | 277 +++ apps/services/modules/memory/stats.go | 144 ++ apps/services/modules/user/model_user.go | 13 + apps/services/modules/user/module_test.go | 6 + apps/services/pb/errorcode.pb.go | 29 +- apps/services/pb/memory_db.pb.go | 681 ++++++++ apps/services/pb/memory_msg.pb.go | 1541 +++++++++++++++++ apps/services/services/home/main.go | 2 + deploy/app/confs/home.yaml.example | 10 + 37 files changed, 5185 insertions(+), 3 deletions(-) create mode 100644 apps/proto/memory/memory_db.proto create mode 100644 apps/proto/memory/memory_msg.proto create mode 100644 apps/services/comm/memory.go create mode 100644 apps/services/comm/memory_test.go create mode 100644 apps/services/modules/memory/api.go create mode 100644 apps/services/modules/memory/api_add.go create mode 100644 apps/services/modules/memory/api_complete.go create mode 100644 apps/services/modules/memory/api_del.go create mode 100644 apps/services/modules/memory/api_list.go create mode 100644 apps/services/modules/memory/api_report.go create mode 100644 apps/services/modules/memory/api_stats.go create mode 100644 apps/services/modules/memory/api_today.go create mode 100644 apps/services/modules/memory/api_upcoming.go create mode 100644 apps/services/modules/memory/api_update.go create mode 100644 apps/services/modules/memory/api_validate.go create mode 100644 apps/services/modules/memory/core.go create mode 100644 apps/services/modules/memory/meeting_extract.go create mode 100644 apps/services/modules/memory/meeting_extract_test.go create mode 100644 apps/services/modules/memory/model.go create mode 100644 apps/services/modules/memory/module.go create mode 100644 apps/services/modules/memory/options.go create mode 100644 apps/services/modules/memory/remind.go create mode 100644 apps/services/modules/memory/remind_test.go create mode 100644 apps/services/modules/memory/report.go create mode 100644 apps/services/modules/memory/stats.go create mode 100644 apps/services/pb/memory_db.pb.go create mode 100644 apps/services/pb/memory_msg.pb.go diff --git a/apps/proto/errorcode.proto b/apps/proto/errorcode.proto index ce5b1d0d..e397b621 100644 --- a/apps/proto/errorcode.proto +++ b/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; //翻译参数不合法(原文为空 / 目标语言为空) diff --git a/apps/proto/memory/memory_db.proto b/apps/proto/memory/memory_db.proto new file mode 100644 index 00000000..98ffcdf2 --- /dev/null +++ b/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"`) +} diff --git a/apps/proto/memory/memory_msg.proto b/apps/proto/memory/memory_msg.proto new file mode 100644 index 00000000..604b80c7 --- /dev/null +++ b/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"`) +} diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index 699ba042..71d66a34 100644 --- a/apps/services/comm/const.go +++ b/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 判重) diff --git a/apps/services/comm/memory.go b/apps/services/comm/memory.go new file mode 100644 index 00000000..198478e8 --- /dev/null +++ b/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 +} diff --git a/apps/services/comm/memory_test.go b/apps/services/comm/memory_test.go new file mode 100644 index 00000000..19371c5c --- /dev/null +++ b/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 不该解析成功") + } +} diff --git a/apps/services/comm/module.go b/apps/services/comm/module.go index a1755448..054f1625 100644 --- a/apps/services/comm/module.go +++ b/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) } diff --git a/apps/services/modules/echomeet/module.go b/apps/services/modules/echomeet/module.go index 46a30cd5..87915d69 100644 --- a/apps/services/modules/echomeet/module.go +++ b/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) diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index 03b8c086..658fc95a 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/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) diff --git a/apps/services/modules/memory/api.go b/apps/services/modules/memory/api.go new file mode 100644 index 00000000..4490ec0c --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_add.go b/apps/services/modules/memory/api_add.go new file mode 100644 index 00000000..cf2bb471 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_complete.go b/apps/services/modules/memory/api_complete.go new file mode 100644 index 00000000..e0f5d703 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_del.go b/apps/services/modules/memory/api_del.go new file mode 100644 index 00000000..610b0d94 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_list.go b/apps/services/modules/memory/api_list.go new file mode 100644 index 00000000..c17a2500 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_report.go b/apps/services/modules/memory/api_report.go new file mode 100644 index 00000000..bd1261be --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_stats.go b/apps/services/modules/memory/api_stats.go new file mode 100644 index 00000000..2055b777 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_today.go b/apps/services/modules/memory/api_today.go new file mode 100644 index 00000000..dd37420c --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_upcoming.go b/apps/services/modules/memory/api_upcoming.go new file mode 100644 index 00000000..9d8fd3ca --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/api_update.go b/apps/services/modules/memory/api_update.go new file mode 100644 index 00000000..1d7031fd --- /dev/null +++ b/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 + } + } +} diff --git a/apps/services/modules/memory/api_validate.go b/apps/services/modules/memory/api_validate.go new file mode 100644 index 00000000..4f12ff1f --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/core.go b/apps/services/modules/memory/core.go new file mode 100644 index 00000000..4f51ed5c --- /dev/null +++ b/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 抢锁前缀,后面拼 : +) diff --git a/apps/services/modules/memory/meeting_extract.go b/apps/services/modules/memory/meeting_extract.go new file mode 100644 index 00000000..77958e32 --- /dev/null +++ b/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("%s\n\n\n%s\n", 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 +} diff --git a/apps/services/modules/memory/meeting_extract_test.go b/apps/services/modules/memory/meeting_extract_test.go new file mode 100644 index 00000000..4fe85e07 --- /dev/null +++ b/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]) + } +} diff --git a/apps/services/modules/memory/model.go b/apps/services/modules/memory/model.go new file mode 100644 index 00000000..a677cffa --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/module.go b/apps/services/modules/memory/module.go new file mode 100644 index 00000000..c10545b9 --- /dev/null +++ b/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) +} diff --git a/apps/services/modules/memory/options.go b/apps/services/modules/memory/options.go new file mode 100644 index 00000000..817f03e5 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/remind.go b/apps/services/modules/memory/remind.go new file mode 100644 index 00000000..54b25fd6 --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/memory/remind_test.go b/apps/services/modules/memory/remind_test.go new file mode 100644 index 00000000..35fdecd7 --- /dev/null +++ b/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()) + } +} diff --git a/apps/services/modules/memory/report.go b/apps/services/modules/memory/report.go new file mode 100644 index 00000000..188e2589 --- /dev/null +++ b/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) +} diff --git a/apps/services/modules/memory/stats.go b/apps/services/modules/memory/stats.go new file mode 100644 index 00000000..3fa26c6c --- /dev/null +++ b/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 +} diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go index d8e226ae..47846ff4 100644 --- a/apps/services/modules/user/model_user.go +++ b/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 = "" diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go index 089039de..7af83743 100644 --- a/apps/services/modules/user/module_test.go +++ b/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 { diff --git a/apps/services/pb/errorcode.pb.go b/apps/services/pb/errorcode.pb.go index 239ec73c..68f100b1 100644 --- a/apps/services/pb/errorcode.pb.go +++ b/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" diff --git a/apps/services/pb/memory_db.pb.go b/apps/services/pb/memory_db.pb.go new file mode 100644 index 00000000..0a725815 --- /dev/null +++ b/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 +} diff --git a/apps/services/pb/memory_msg.pb.go b/apps/services/pb/memory_msg.pb.go new file mode 100644 index 00000000..75bd2bfc --- /dev/null +++ b/apps/services/pb/memory_msg.pb.go @@ -0,0 +1,1541 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v7.34.1 +// source: memory/memory_msg.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 MemoryListReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + StartDate string `protobuf:"bytes,1,opt,name=start_date,json=startDate,proto3" json:"start_date"` //@go_tags(`json:"start_date"`) 起始日期 YYYY-MM-DD(含) + EndDate string `protobuf:"bytes,2,opt,name=end_date,json=endDate,proto3" json:"end_date"` //@go_tags(`json:"end_date"`) 结束日期 YYYY-MM-DD(含) + Categories []string `protobuf:"bytes,3,rep,name=categories,proto3" json:"categories"` //@go_tags(`json:"categories"`) 分类筛选,空=全部 + States []int32 `protobuf:"varint,4,rep,packed,name=states,proto3" json:"states"` //@go_tags(`json:"states"`) 状态筛选,空=全部 + Tz string `protobuf:"bytes,5,opt,name=tz,proto3" json:"tz"` //@go_tags(`json:"tz"`) 客户端时区(IANA名) + Page int32 `protobuf:"varint,6,opt,name=page,proto3" json:"page"` //@go_tags(`json:"page"`) 页码,从1开始;0/1 均按第一页 + Size int32 `protobuf:"varint,7,opt,name=size,proto3" json:"size"` //@go_tags(`json:"size"`) 每页数量,0=默认200,上限1000 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryListReq) Reset() { + *x = MemoryListReq{} + mi := &file_memory_memory_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryListReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryListReq) ProtoMessage() {} + +func (x *MemoryListReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_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 MemoryListReq.ProtoReflect.Descriptor instead. +func (*MemoryListReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{0} +} + +func (x *MemoryListReq) GetStartDate() string { + if x != nil { + return x.StartDate + } + return "" +} + +func (x *MemoryListReq) GetEndDate() string { + if x != nil { + return x.EndDate + } + return "" +} + +func (x *MemoryListReq) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +func (x *MemoryListReq) GetStates() []int32 { + if x != nil { + return x.States + } + return nil +} + +func (x *MemoryListReq) GetTz() string { + if x != nil { + return x.Tz + } + return "" +} + +func (x *MemoryListReq) GetPage() int32 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *MemoryListReq) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type MemoryListResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*DBMemoryItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items"` //@go_tags(`json:"items"`) + Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total"` //@go_tags(`json:"total"`) 满足条件的总数(用于判断还有没有下一页) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryListResp) Reset() { + *x = MemoryListResp{} + mi := &file_memory_memory_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryListResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryListResp) ProtoMessage() {} + +func (x *MemoryListResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_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 MemoryListResp.ProtoReflect.Descriptor instead. +func (*MemoryListResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *MemoryListResp) GetItems() []*DBMemoryItem { + if x != nil { + return x.Items + } + return nil +} + +func (x *MemoryListResp) GetTotal() int64 { + if x != nil { + return x.Total + } + return 0 +} + +// 当天全部事项(每日弹窗直接用,含待确认报告标记) +type MemoryTodayReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Date string `protobuf:"bytes,1,opt,name=date,proto3" json:"date"` //@go_tags(`json:"date"`) 客户端本地日期 YYYY-MM-DD + Tz string `protobuf:"bytes,2,opt,name=tz,proto3" json:"tz"` //@go_tags(`json:"tz"`) 客户端时区(IANA名) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryTodayReq) Reset() { + *x = MemoryTodayReq{} + mi := &file_memory_memory_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryTodayReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryTodayReq) ProtoMessage() {} + +func (x *MemoryTodayReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[2] + 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 MemoryTodayReq.ProtoReflect.Descriptor instead. +func (*MemoryTodayReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *MemoryTodayReq) GetDate() string { + if x != nil { + return x.Date + } + return "" +} + +func (x *MemoryTodayReq) GetTz() string { + if x != nil { + return x.Tz + } + return "" +} + +type MemoryTodayResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*DBMemoryItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items"` //@go_tags(`json:"items"`) 当天事项 + PendingReportId uint64 `protobuf:"varint,2,opt,name=pending_report_id,json=pendingReportId,proto3" json:"pending_report_id"` //@go_tags(`json:"pending_report_id"`) 有待确认的周报/月报时给出其id,0=没有 + PendingReportType string `protobuf:"bytes,3,opt,name=pending_report_type,json=pendingReportType,proto3" json:"pending_report_type"` //@go_tags(`json:"pending_report_type"`) week/month,配合上一字段 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryTodayResp) Reset() { + *x = MemoryTodayResp{} + mi := &file_memory_memory_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryTodayResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryTodayResp) ProtoMessage() {} + +func (x *MemoryTodayResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[3] + 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 MemoryTodayResp.ProtoReflect.Descriptor instead. +func (*MemoryTodayResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *MemoryTodayResp) GetItems() []*DBMemoryItem { + if x != nil { + return x.Items + } + return nil +} + +func (x *MemoryTodayResp) GetPendingReportId() uint64 { + if x != nil { + return x.PendingReportId + } + return 0 +} + +func (x *MemoryTodayResp) GetPendingReportType() string { + if x != nil { + return x.PendingReportType + } + return "" +} + +// 未来N天待提醒项:重复项已按 tz 展开成绝对时刻,客户端直接排通知 +type MemoryUpcomingReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Days int32 `protobuf:"varint,1,opt,name=days,proto3" json:"days"` //@go_tags(`json:"days"`) 天数,0=默认7,上限30 + Tz string `protobuf:"bytes,2,opt,name=tz,proto3" json:"tz"` //@go_tags(`json:"tz"`) 客户端时区(IANA名) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryUpcomingReq) Reset() { + *x = MemoryUpcomingReq{} + mi := &file_memory_memory_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryUpcomingReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryUpcomingReq) ProtoMessage() {} + +func (x *MemoryUpcomingReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[4] + 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 MemoryUpcomingReq.ProtoReflect.Descriptor instead. +func (*MemoryUpcomingReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *MemoryUpcomingReq) GetDays() int32 { + if x != nil { + return x.Days + } + return 0 +} + +func (x *MemoryUpcomingReq) GetTz() string { + if x != nil { + return x.Tz + } + return "" +} + +// 一个展开后的提醒时刻 +type MemoryRemindSlot struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemId uint64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id"` //@go_tags(`json:"item_id"`) 对应记忆项id + RemindAt int64 `protobuf:"varint,2,opt,name=remind_at,json=remindAt,proto3" json:"remind_at"` //@go_tags(`json:"remind_at"`) 提醒时刻 unix 秒(已扣减 remind_ahead) + HappenAt int64 `protobuf:"varint,3,opt,name=happen_at,json=happenAt,proto3" json:"happen_at"` //@go_tags(`json:"happen_at"`) 事项本身的时刻 unix 秒 + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) 冗余下发,客户端排通知不必再查 + Detail string `protobuf:"bytes,5,opt,name=detail,proto3" json:"detail"` //@go_tags(`json:"detail"`) + Category string `protobuf:"bytes,6,opt,name=category,proto3" json:"category"` //@go_tags(`json:"category"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryRemindSlot) Reset() { + *x = MemoryRemindSlot{} + mi := &file_memory_memory_msg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryRemindSlot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryRemindSlot) ProtoMessage() {} + +func (x *MemoryRemindSlot) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[5] + 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 MemoryRemindSlot.ProtoReflect.Descriptor instead. +func (*MemoryRemindSlot) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{5} +} + +func (x *MemoryRemindSlot) GetItemId() uint64 { + if x != nil { + return x.ItemId + } + return 0 +} + +func (x *MemoryRemindSlot) GetRemindAt() int64 { + if x != nil { + return x.RemindAt + } + return 0 +} + +func (x *MemoryRemindSlot) GetHappenAt() int64 { + if x != nil { + return x.HappenAt + } + return 0 +} + +func (x *MemoryRemindSlot) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *MemoryRemindSlot) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *MemoryRemindSlot) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +type MemoryUpcomingResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Slots []*MemoryRemindSlot `protobuf:"bytes,1,rep,name=slots,proto3" json:"slots"` //@go_tags(`json:"slots"`) 按 remind_at 升序 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryUpcomingResp) Reset() { + *x = MemoryUpcomingResp{} + mi := &file_memory_memory_msg_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryUpcomingResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryUpcomingResp) ProtoMessage() {} + +func (x *MemoryUpcomingResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[6] + 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 MemoryUpcomingResp.ProtoReflect.Descriptor instead. +func (*MemoryUpcomingResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{6} +} + +func (x *MemoryUpcomingResp) GetSlots() []*MemoryRemindSlot { + if x != nil { + return x.Slots + } + return nil +} + +// 即时统计(EMAI问答与页面头部用) +type MemoryStatsReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + StartDate string `protobuf:"bytes,1,opt,name=start_date,json=startDate,proto3" json:"start_date"` //@go_tags(`json:"start_date"`) + EndDate string `protobuf:"bytes,2,opt,name=end_date,json=endDate,proto3" json:"end_date"` //@go_tags(`json:"end_date"`) + Categories []string `protobuf:"bytes,3,rep,name=categories,proto3" json:"categories"` //@go_tags(`json:"categories"`) 空=全部 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryStatsReq) Reset() { + *x = MemoryStatsReq{} + mi := &file_memory_memory_msg_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryStatsReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryStatsReq) ProtoMessage() {} + +func (x *MemoryStatsReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[7] + 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 MemoryStatsReq.ProtoReflect.Descriptor instead. +func (*MemoryStatsReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{7} +} + +func (x *MemoryStatsReq) GetStartDate() string { + if x != nil { + return x.StartDate + } + return "" +} + +func (x *MemoryStatsReq) GetEndDate() string { + if x != nil { + return x.EndDate + } + return "" +} + +func (x *MemoryStatsReq) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +// 单币种花销小计(不做汇率换算,多币种分行给出) +type MemoryExpenseSum struct { + state protoimpl.MessageState `protogen:"open.v1"` + Currency string `protobuf:"bytes,1,opt,name=currency,proto3" json:"currency"` //@go_tags(`json:"currency"`) + TotalCents int64 `protobuf:"varint,2,opt,name=total_cents,json=totalCents,proto3" json:"total_cents"` //@go_tags(`json:"total_cents"`) + Count int32 `protobuf:"varint,3,opt,name=count,proto3" json:"count"` //@go_tags(`json:"count"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryExpenseSum) Reset() { + *x = MemoryExpenseSum{} + mi := &file_memory_memory_msg_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryExpenseSum) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryExpenseSum) ProtoMessage() {} + +func (x *MemoryExpenseSum) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[8] + 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 MemoryExpenseSum.ProtoReflect.Descriptor instead. +func (*MemoryExpenseSum) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{8} +} + +func (x *MemoryExpenseSum) GetCurrency() string { + if x != nil { + return x.Currency + } + return "" +} + +func (x *MemoryExpenseSum) GetTotalCents() int64 { + if x != nil { + return x.TotalCents + } + return 0 +} + +func (x *MemoryExpenseSum) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +type MemoryStatsResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + TodoTotal int32 `protobuf:"varint,1,opt,name=todo_total,json=todoTotal,proto3" json:"todo_total"` //@go_tags(`json:"todo_total"`) + TodoDone int32 `protobuf:"varint,2,opt,name=todo_done,json=todoDone,proto3" json:"todo_done"` //@go_tags(`json:"todo_done"`) + TodoUndone int32 `protobuf:"varint,3,opt,name=todo_undone,json=todoUndone,proto3" json:"todo_undone"` //@go_tags(`json:"todo_undone"`) + IdeaCount int32 `protobuf:"varint,4,opt,name=idea_count,json=ideaCount,proto3" json:"idea_count"` //@go_tags(`json:"idea_count"`) + AlarmTotal int32 `protobuf:"varint,5,opt,name=alarm_total,json=alarmTotal,proto3" json:"alarm_total"` //@go_tags(`json:"alarm_total"`) + Expenses []*MemoryExpenseSum `protobuf:"bytes,6,rep,name=expenses,proto3" json:"expenses"` //@go_tags(`json:"expenses"`) 按币种分行 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryStatsResp) Reset() { + *x = MemoryStatsResp{} + mi := &file_memory_memory_msg_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryStatsResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryStatsResp) ProtoMessage() {} + +func (x *MemoryStatsResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[9] + 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 MemoryStatsResp.ProtoReflect.Descriptor instead. +func (*MemoryStatsResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{9} +} + +func (x *MemoryStatsResp) GetTodoTotal() int32 { + if x != nil { + return x.TodoTotal + } + return 0 +} + +func (x *MemoryStatsResp) GetTodoDone() int32 { + if x != nil { + return x.TodoDone + } + return 0 +} + +func (x *MemoryStatsResp) GetTodoUndone() int32 { + if x != nil { + return x.TodoUndone + } + return 0 +} + +func (x *MemoryStatsResp) GetIdeaCount() int32 { + if x != nil { + return x.IdeaCount + } + return 0 +} + +func (x *MemoryStatsResp) GetAlarmTotal() int32 { + if x != nil { + return x.AlarmTotal + } + return 0 +} + +func (x *MemoryStatsResp) GetExpenses() []*MemoryExpenseSum { + if x != nil { + return x.Expenses + } + return nil +} + +// 新增:整个 item 提交;client_key 非空时做幂等(重复提交返回已存在那条) +type MemoryAddReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Item *DBMemoryItem `protobuf:"bytes,1,opt,name=item,proto3" json:"item"` //@go_tags(`json:"item"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryAddReq) Reset() { + *x = MemoryAddReq{} + mi := &file_memory_memory_msg_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryAddReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryAddReq) ProtoMessage() {} + +func (x *MemoryAddReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[10] + 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 MemoryAddReq.ProtoReflect.Descriptor instead. +func (*MemoryAddReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{10} +} + +func (x *MemoryAddReq) GetItem() *DBMemoryItem { + if x != nil { + return x.Item + } + return nil +} + +type MemoryAddResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Item *DBMemoryItem `protobuf:"bytes,1,opt,name=item,proto3" json:"item"` //@go_tags(`json:"item"`) 落库后的记录(含id) + Duplicated bool `protobuf:"varint,2,opt,name=duplicated,proto3" json:"duplicated"` //@go_tags(`json:"duplicated"`) true=命中幂等键,返回的是既有记录 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryAddResp) Reset() { + *x = MemoryAddResp{} + mi := &file_memory_memory_msg_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryAddResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryAddResp) ProtoMessage() {} + +func (x *MemoryAddResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[11] + 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 MemoryAddResp.ProtoReflect.Descriptor instead. +func (*MemoryAddResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{11} +} + +func (x *MemoryAddResp) GetItem() *DBMemoryItem { + if x != nil { + return x.Item + } + return nil +} + +func (x *MemoryAddResp) GetDuplicated() bool { + if x != nil { + return x.Duplicated + } + return false +} + +// 修改:只覆盖 fields 里点名的字段,并自动置 user_edited=true +type MemoryUpdateReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) + Item *DBMemoryItem `protobuf:"bytes,2,opt,name=item,proto3" json:"item"` //@go_tags(`json:"item"`) 新值载体 + Fields []string `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields"` //@go_tags(`json:"fields"`) 要更新的字段名(json名),空=按白名单全量覆盖 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryUpdateReq) Reset() { + *x = MemoryUpdateReq{} + mi := &file_memory_memory_msg_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryUpdateReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryUpdateReq) ProtoMessage() {} + +func (x *MemoryUpdateReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[12] + 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 MemoryUpdateReq.ProtoReflect.Descriptor instead. +func (*MemoryUpdateReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{12} +} + +func (x *MemoryUpdateReq) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *MemoryUpdateReq) GetItem() *DBMemoryItem { + if x != nil { + return x.Item + } + return nil +} + +func (x *MemoryUpdateReq) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +type MemoryUpdateResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Item *DBMemoryItem `protobuf:"bytes,1,opt,name=item,proto3" json:"item"` //@go_tags(`json:"item"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryUpdateResp) Reset() { + *x = MemoryUpdateResp{} + mi := &file_memory_memory_msg_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryUpdateResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryUpdateResp) ProtoMessage() {} + +func (x *MemoryUpdateResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[13] + 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 MemoryUpdateResp.ProtoReflect.Descriptor instead. +func (*MemoryUpdateResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{13} +} + +func (x *MemoryUpdateResp) GetItem() *DBMemoryItem { + if x != nil { + return x.Item + } + return nil +} + +type MemoryDelReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids"` //@go_tags(`json:"ids"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryDelReq) Reset() { + *x = MemoryDelReq{} + mi := &file_memory_memory_msg_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryDelReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryDelReq) ProtoMessage() {} + +func (x *MemoryDelReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[14] + 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 MemoryDelReq.ProtoReflect.Descriptor instead. +func (*MemoryDelReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{14} +} + +func (x *MemoryDelReq) GetIds() []uint64 { + if x != nil { + return x.Ids + } + return nil +} + +type MemoryDelResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Affected int32 `protobuf:"varint,1,opt,name=affected,proto3" json:"affected"` //@go_tags(`json:"affected"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryDelResp) Reset() { + *x = MemoryDelResp{} + mi := &file_memory_memory_msg_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryDelResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryDelResp) ProtoMessage() {} + +func (x *MemoryDelResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[15] + 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 MemoryDelResp.ProtoReflect.Descriptor instead. +func (*MemoryDelResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{15} +} + +func (x *MemoryDelResp) GetAffected() int32 { + if x != nil { + return x.Affected + } + return 0 +} + +// 标记完成/取消完成 +type MemoryCompleteReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids"` //@go_tags(`json:"ids"`) + Done bool `protobuf:"varint,2,opt,name=done,proto3" json:"done"` //@go_tags(`json:"done"`) true=完成,false=退回未完成 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryCompleteReq) Reset() { + *x = MemoryCompleteReq{} + mi := &file_memory_memory_msg_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryCompleteReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryCompleteReq) ProtoMessage() {} + +func (x *MemoryCompleteReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[16] + 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 MemoryCompleteReq.ProtoReflect.Descriptor instead. +func (*MemoryCompleteReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{16} +} + +func (x *MemoryCompleteReq) GetIds() []uint64 { + if x != nil { + return x.Ids + } + return nil +} + +func (x *MemoryCompleteReq) GetDone() bool { + if x != nil { + return x.Done + } + return false +} + +type MemoryCompleteResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Affected int32 `protobuf:"varint,1,opt,name=affected,proto3" json:"affected"` //@go_tags(`json:"affected"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryCompleteResp) Reset() { + *x = MemoryCompleteResp{} + mi := &file_memory_memory_msg_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryCompleteResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryCompleteResp) ProtoMessage() {} + +func (x *MemoryCompleteResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[17] + 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 MemoryCompleteResp.ProtoReflect.Descriptor instead. +func (*MemoryCompleteResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{17} +} + +func (x *MemoryCompleteResp) GetAffected() int32 { + if x != nil { + return x.Affected + } + return 0 +} + +// 取周报/月报;period_type 与 period_key 都为空时,返回最近一份未确认的 +type MemoryGetReportReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeriodType string `protobuf:"bytes,1,opt,name=period_type,json=periodType,proto3" json:"period_type"` //@go_tags(`json:"period_type"`) week/month + PeriodKey string `protobuf:"bytes,2,opt,name=period_key,json=periodKey,proto3" json:"period_key"` //@go_tags(`json:"period_key"`) 2026-W35 / 2026-08 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryGetReportReq) Reset() { + *x = MemoryGetReportReq{} + mi := &file_memory_memory_msg_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryGetReportReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryGetReportReq) ProtoMessage() {} + +func (x *MemoryGetReportReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[18] + 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 MemoryGetReportReq.ProtoReflect.Descriptor instead. +func (*MemoryGetReportReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{18} +} + +func (x *MemoryGetReportReq) GetPeriodType() string { + if x != nil { + return x.PeriodType + } + return "" +} + +func (x *MemoryGetReportReq) GetPeriodKey() string { + if x != nil { + return x.PeriodKey + } + return "" +} + +type MemoryGetReportResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Report *DBMemoryReport `protobuf:"bytes,1,opt,name=report,proto3" json:"report"` //@go_tags(`json:"report"`) 没有时为 null + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryGetReportResp) Reset() { + *x = MemoryGetReportResp{} + mi := &file_memory_memory_msg_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryGetReportResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryGetReportResp) ProtoMessage() {} + +func (x *MemoryGetReportResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[19] + 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 MemoryGetReportResp.ProtoReflect.Descriptor instead. +func (*MemoryGetReportResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{19} +} + +func (x *MemoryGetReportResp) GetReport() *DBMemoryReport { + if x != nil { + return x.Report + } + return nil +} + +type MemoryConfirmReportReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReportId uint64 `protobuf:"varint,1,opt,name=report_id,json=reportId,proto3" json:"report_id"` //@go_tags(`json:"report_id"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryConfirmReportReq) Reset() { + *x = MemoryConfirmReportReq{} + mi := &file_memory_memory_msg_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryConfirmReportReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryConfirmReportReq) ProtoMessage() {} + +func (x *MemoryConfirmReportReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[20] + 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 MemoryConfirmReportReq.ProtoReflect.Descriptor instead. +func (*MemoryConfirmReportReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{20} +} + +func (x *MemoryConfirmReportReq) GetReportId() uint64 { + if x != nil { + return x.ReportId + } + return 0 +} + +type MemoryConfirmReportResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Report *DBMemoryReport `protobuf:"bytes,1,opt,name=report,proto3" json:"report"` //@go_tags(`json:"report"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryConfirmReportResp) Reset() { + *x = MemoryConfirmReportResp{} + mi := &file_memory_memory_msg_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryConfirmReportResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryConfirmReportResp) ProtoMessage() {} + +func (x *MemoryConfirmReportResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[21] + 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 MemoryConfirmReportResp.ProtoReflect.Descriptor instead. +func (*MemoryConfirmReportResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{21} +} + +func (x *MemoryConfirmReportResp) GetReport() *DBMemoryReport { + if x != nil { + return x.Report + } + return nil +} + +// 列出历史报告(拾忆页里翻旧报告用,避免一次弹多份) +type MemoryListReportsReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeriodType string `protobuf:"bytes,1,opt,name=period_type,json=periodType,proto3" json:"period_type"` //@go_tags(`json:"period_type"`) 空=全部 + Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page"` //@go_tags(`json:"page"`) + Size int32 `protobuf:"varint,3,opt,name=size,proto3" json:"size"` //@go_tags(`json:"size"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryListReportsReq) Reset() { + *x = MemoryListReportsReq{} + mi := &file_memory_memory_msg_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryListReportsReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryListReportsReq) ProtoMessage() {} + +func (x *MemoryListReportsReq) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[22] + 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 MemoryListReportsReq.ProtoReflect.Descriptor instead. +func (*MemoryListReportsReq) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{22} +} + +func (x *MemoryListReportsReq) GetPeriodType() string { + if x != nil { + return x.PeriodType + } + return "" +} + +func (x *MemoryListReportsReq) GetPage() int32 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *MemoryListReportsReq) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type MemoryListReportsResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*DBMemoryReport `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports"` //@go_tags(`json:"reports"`) + Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total"` //@go_tags(`json:"total"`) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryListReportsResp) Reset() { + *x = MemoryListReportsResp{} + mi := &file_memory_memory_msg_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryListReportsResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryListReportsResp) ProtoMessage() {} + +func (x *MemoryListReportsResp) ProtoReflect() protoreflect.Message { + mi := &file_memory_memory_msg_proto_msgTypes[23] + 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 MemoryListReportsResp.ProtoReflect.Descriptor instead. +func (*MemoryListReportsResp) Descriptor() ([]byte, []int) { + return file_memory_memory_msg_proto_rawDescGZIP(), []int{23} +} + +func (x *MemoryListReportsResp) GetReports() []*DBMemoryReport { + if x != nil { + return x.Reports + } + return nil +} + +func (x *MemoryListReportsResp) GetTotal() int64 { + if x != nil { + return x.Total + } + return 0 +} + +var File_memory_memory_msg_proto protoreflect.FileDescriptor + +const file_memory_memory_msg_proto_rawDesc = "" + + "\n" + + "\x17memory/memory_msg.proto\x1a\x16memory/memory_db.proto\"\xb9\x01\n" + + "\rMemoryListReq\x12\x1d\n" + + "\n" + + "start_date\x18\x01 \x01(\tR\tstartDate\x12\x19\n" + + "\bend_date\x18\x02 \x01(\tR\aendDate\x12\x1e\n" + + "\n" + + "categories\x18\x03 \x03(\tR\n" + + "categories\x12\x16\n" + + "\x06states\x18\x04 \x03(\x05R\x06states\x12\x0e\n" + + "\x02tz\x18\x05 \x01(\tR\x02tz\x12\x12\n" + + "\x04page\x18\x06 \x01(\x05R\x04page\x12\x12\n" + + "\x04size\x18\a \x01(\x05R\x04size\"K\n" + + "\x0eMemoryListResp\x12#\n" + + "\x05items\x18\x01 \x03(\v2\r.DBMemoryItemR\x05items\x12\x14\n" + + "\x05total\x18\x02 \x01(\x03R\x05total\"4\n" + + "\x0eMemoryTodayReq\x12\x12\n" + + "\x04date\x18\x01 \x01(\tR\x04date\x12\x0e\n" + + "\x02tz\x18\x02 \x01(\tR\x02tz\"\x92\x01\n" + + "\x0fMemoryTodayResp\x12#\n" + + "\x05items\x18\x01 \x03(\v2\r.DBMemoryItemR\x05items\x12*\n" + + "\x11pending_report_id\x18\x02 \x01(\x04R\x0fpendingReportId\x12.\n" + + "\x13pending_report_type\x18\x03 \x01(\tR\x11pendingReportType\"7\n" + + "\x11MemoryUpcomingReq\x12\x12\n" + + "\x04days\x18\x01 \x01(\x05R\x04days\x12\x0e\n" + + "\x02tz\x18\x02 \x01(\tR\x02tz\"\xaf\x01\n" + + "\x10MemoryRemindSlot\x12\x17\n" + + "\aitem_id\x18\x01 \x01(\x04R\x06itemId\x12\x1b\n" + + "\tremind_at\x18\x02 \x01(\x03R\bremindAt\x12\x1b\n" + + "\thappen_at\x18\x03 \x01(\x03R\bhappenAt\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12\x16\n" + + "\x06detail\x18\x05 \x01(\tR\x06detail\x12\x1a\n" + + "\bcategory\x18\x06 \x01(\tR\bcategory\"=\n" + + "\x12MemoryUpcomingResp\x12'\n" + + "\x05slots\x18\x01 \x03(\v2\x11.MemoryRemindSlotR\x05slots\"j\n" + + "\x0eMemoryStatsReq\x12\x1d\n" + + "\n" + + "start_date\x18\x01 \x01(\tR\tstartDate\x12\x19\n" + + "\bend_date\x18\x02 \x01(\tR\aendDate\x12\x1e\n" + + "\n" + + "categories\x18\x03 \x03(\tR\n" + + "categories\"e\n" + + "\x10MemoryExpenseSum\x12\x1a\n" + + "\bcurrency\x18\x01 \x01(\tR\bcurrency\x12\x1f\n" + + "\vtotal_cents\x18\x02 \x01(\x03R\n" + + "totalCents\x12\x14\n" + + "\x05count\x18\x03 \x01(\x05R\x05count\"\xdd\x01\n" + + "\x0fMemoryStatsResp\x12\x1d\n" + + "\n" + + "todo_total\x18\x01 \x01(\x05R\ttodoTotal\x12\x1b\n" + + "\ttodo_done\x18\x02 \x01(\x05R\btodoDone\x12\x1f\n" + + "\vtodo_undone\x18\x03 \x01(\x05R\n" + + "todoUndone\x12\x1d\n" + + "\n" + + "idea_count\x18\x04 \x01(\x05R\tideaCount\x12\x1f\n" + + "\valarm_total\x18\x05 \x01(\x05R\n" + + "alarmTotal\x12-\n" + + "\bexpenses\x18\x06 \x03(\v2\x11.MemoryExpenseSumR\bexpenses\"1\n" + + "\fMemoryAddReq\x12!\n" + + "\x04item\x18\x01 \x01(\v2\r.DBMemoryItemR\x04item\"R\n" + + "\rMemoryAddResp\x12!\n" + + "\x04item\x18\x01 \x01(\v2\r.DBMemoryItemR\x04item\x12\x1e\n" + + "\n" + + "duplicated\x18\x02 \x01(\bR\n" + + "duplicated\"\\\n" + + "\x0fMemoryUpdateReq\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x04R\x02id\x12!\n" + + "\x04item\x18\x02 \x01(\v2\r.DBMemoryItemR\x04item\x12\x16\n" + + "\x06fields\x18\x03 \x03(\tR\x06fields\"5\n" + + "\x10MemoryUpdateResp\x12!\n" + + "\x04item\x18\x01 \x01(\v2\r.DBMemoryItemR\x04item\" \n" + + "\fMemoryDelReq\x12\x10\n" + + "\x03ids\x18\x01 \x03(\x04R\x03ids\"+\n" + + "\rMemoryDelResp\x12\x1a\n" + + "\baffected\x18\x01 \x01(\x05R\baffected\"9\n" + + "\x11MemoryCompleteReq\x12\x10\n" + + "\x03ids\x18\x01 \x03(\x04R\x03ids\x12\x12\n" + + "\x04done\x18\x02 \x01(\bR\x04done\"0\n" + + "\x12MemoryCompleteResp\x12\x1a\n" + + "\baffected\x18\x01 \x01(\x05R\baffected\"T\n" + + "\x12MemoryGetReportReq\x12\x1f\n" + + "\vperiod_type\x18\x01 \x01(\tR\n" + + "periodType\x12\x1d\n" + + "\n" + + "period_key\x18\x02 \x01(\tR\tperiodKey\">\n" + + "\x13MemoryGetReportResp\x12'\n" + + "\x06report\x18\x01 \x01(\v2\x0f.DBMemoryReportR\x06report\"5\n" + + "\x16MemoryConfirmReportReq\x12\x1b\n" + + "\treport_id\x18\x01 \x01(\x04R\breportId\"B\n" + + "\x17MemoryConfirmReportResp\x12'\n" + + "\x06report\x18\x01 \x01(\v2\x0f.DBMemoryReportR\x06report\"_\n" + + "\x14MemoryListReportsReq\x12\x1f\n" + + "\vperiod_type\x18\x01 \x01(\tR\n" + + "periodType\x12\x12\n" + + "\x04page\x18\x02 \x01(\x05R\x04page\x12\x12\n" + + "\x04size\x18\x03 \x01(\x05R\x04size\"X\n" + + "\x15MemoryListReportsResp\x12)\n" + + "\areports\x18\x01 \x03(\v2\x0f.DBMemoryReportR\areports\x12\x14\n" + + "\x05total\x18\x02 \x01(\x03R\x05totalB\x06Z\x04.;pbb\x06proto3" + +var ( + file_memory_memory_msg_proto_rawDescOnce sync.Once + file_memory_memory_msg_proto_rawDescData []byte +) + +func file_memory_memory_msg_proto_rawDescGZIP() []byte { + file_memory_memory_msg_proto_rawDescOnce.Do(func() { + file_memory_memory_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_memory_memory_msg_proto_rawDesc), len(file_memory_memory_msg_proto_rawDesc))) + }) + return file_memory_memory_msg_proto_rawDescData +} + +var file_memory_memory_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_memory_memory_msg_proto_goTypes = []any{ + (*MemoryListReq)(nil), // 0: MemoryListReq + (*MemoryListResp)(nil), // 1: MemoryListResp + (*MemoryTodayReq)(nil), // 2: MemoryTodayReq + (*MemoryTodayResp)(nil), // 3: MemoryTodayResp + (*MemoryUpcomingReq)(nil), // 4: MemoryUpcomingReq + (*MemoryRemindSlot)(nil), // 5: MemoryRemindSlot + (*MemoryUpcomingResp)(nil), // 6: MemoryUpcomingResp + (*MemoryStatsReq)(nil), // 7: MemoryStatsReq + (*MemoryExpenseSum)(nil), // 8: MemoryExpenseSum + (*MemoryStatsResp)(nil), // 9: MemoryStatsResp + (*MemoryAddReq)(nil), // 10: MemoryAddReq + (*MemoryAddResp)(nil), // 11: MemoryAddResp + (*MemoryUpdateReq)(nil), // 12: MemoryUpdateReq + (*MemoryUpdateResp)(nil), // 13: MemoryUpdateResp + (*MemoryDelReq)(nil), // 14: MemoryDelReq + (*MemoryDelResp)(nil), // 15: MemoryDelResp + (*MemoryCompleteReq)(nil), // 16: MemoryCompleteReq + (*MemoryCompleteResp)(nil), // 17: MemoryCompleteResp + (*MemoryGetReportReq)(nil), // 18: MemoryGetReportReq + (*MemoryGetReportResp)(nil), // 19: MemoryGetReportResp + (*MemoryConfirmReportReq)(nil), // 20: MemoryConfirmReportReq + (*MemoryConfirmReportResp)(nil), // 21: MemoryConfirmReportResp + (*MemoryListReportsReq)(nil), // 22: MemoryListReportsReq + (*MemoryListReportsResp)(nil), // 23: MemoryListReportsResp + (*DBMemoryItem)(nil), // 24: DBMemoryItem + (*DBMemoryReport)(nil), // 25: DBMemoryReport +} +var file_memory_memory_msg_proto_depIdxs = []int32{ + 24, // 0: MemoryListResp.items:type_name -> DBMemoryItem + 24, // 1: MemoryTodayResp.items:type_name -> DBMemoryItem + 5, // 2: MemoryUpcomingResp.slots:type_name -> MemoryRemindSlot + 8, // 3: MemoryStatsResp.expenses:type_name -> MemoryExpenseSum + 24, // 4: MemoryAddReq.item:type_name -> DBMemoryItem + 24, // 5: MemoryAddResp.item:type_name -> DBMemoryItem + 24, // 6: MemoryUpdateReq.item:type_name -> DBMemoryItem + 24, // 7: MemoryUpdateResp.item:type_name -> DBMemoryItem + 25, // 8: MemoryGetReportResp.report:type_name -> DBMemoryReport + 25, // 9: MemoryConfirmReportResp.report:type_name -> DBMemoryReport + 25, // 10: MemoryListReportsResp.reports:type_name -> DBMemoryReport + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_memory_memory_msg_proto_init() } +func file_memory_memory_msg_proto_init() { + if File_memory_memory_msg_proto != nil { + return + } + file_memory_memory_db_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_memory_memory_msg_proto_rawDesc), len(file_memory_memory_msg_proto_rawDesc)), + NumEnums: 0, + NumMessages: 24, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_memory_memory_msg_proto_goTypes, + DependencyIndexes: file_memory_memory_msg_proto_depIdxs, + MessageInfos: file_memory_memory_msg_proto_msgTypes, + }.Build() + File_memory_memory_msg_proto = out.File + file_memory_memory_msg_proto_goTypes = nil + file_memory_memory_msg_proto_depIdxs = nil +} diff --git a/apps/services/services/home/main.go b/apps/services/services/home/main.go index 773e6e33..a464fc3c 100644 --- a/apps/services/services/home/main.go +++ b/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(), ) } diff --git a/deploy/app/confs/home.yaml.example b/deploy/app/confs/home.yaml.example index c19c9dd7..048dacd9 100644 --- a/deploy/app/confs/home.yaml.example +++ b/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。