Browse Source
按设计文档一~三期的后端部分落地。全量 go build / go vet 通过,新增 22 个测试。 memory 模块(modules/memory,由 home 装载,路径 /api/home/memory_*) 11 个接口:list / today / add / update / del / complete / upcoming / stats / getreport / confirmreport / listreports。 表由 proto + gorm tag 生成(不是手写 DDL——本仓所有业务表都走 mysql.CreateTable + AutoMigrate)。protobuf 没有 DECIMAL/DATE/DATETIME/null, 逐个落成:金额 int64 存分(与 pay/结算同口径,绝不用 double)、happen_date string + gorm type:date、next_remind_at int64 unix 秒(0=无)。 几处刻意的选择 - 建表失败只 Errorln 不外传。lego.Run 里 Init 返回非 nil 就 Panicf,而五个服务 同镜像、entrypoint 见任一 pid 退出就全杀,一次建表失败等于全站 502。 allhelp/model.go 那段用命名返回值 err,第二张表失败时会漏出去,是反例。 - client_key 服务端保证非空。它参与 (uid, client_key) 唯一索引,而 MySQL 的唯一 索引不像放行 NULL 那样放行空串,留空会让同一用户的第二条记录直接插不进去。 用 crypto/rand 生成:这个值是去重依据,可预测就可被抢占。 - 重复项的提醒改成「就地展开」。设计文档 v0.1 写的「remind_at 服务端算好客户端 直接用」对重复项不成立——一个字段表达不了「每天」,且没人在它过去后往前推。 现在 memory_upcoming 按项自己的 tz 展开成绝对时刻列表返回,不落库。 单条项按自己的 tz 算而不跟着请求方走:「今天有哪些事」是视图问题可以跟着人走, 「明早 8 点响」是承诺问题不能。 - 花销按币种分行统计,不做汇率换算。把 CNY 和 JPY 加成一个数是错的。 - 统计全部走 SQL,LLM 只把 stat_json 组织成一段适合朗读的话。明细截断到 20 条 (重度用户一周两百条未完成,全塞进 prompt 会顶爆上下文,播报也没人听得完)。 - 查询/删除/更新一律带 uid 作条件,报告确认校验 uid 归属。 - 更新字段走白名单而不是黑名单,漏挡一个 uid/id 就是越权改别人的数据。 选路走编排,不再写死服务商 新增 comm.IEchomeet.ChatLLM,由 echomeet 用它的 PickLLM/GetSummarizer 实现。 memory 的报告生成与会议抽取都经它选模型。allhelp/summary.go 直接 import sys/doubao 是历史债:绕过后台编排,海外应用照样调国内豆包、后台换模型也换不掉。 新模块不复制这个写法。 会议纪要 → 待办抽取(第三路) 挂在 AIProcess 写完 summary、状态置 Completed、出队之后,另起 goroutine。 prompt 内置代码不放 echomeet_template(1189 条模板改不齐,且输出要被程序 parse)。 输入用已生成的 summary 而非转写全文。注入 <meeting_date>,服务端二次校验 due_date:解析不出或早于会议当天的一律只留 due_raw、落会议当天并置 date_certain=false。抽取失败只记日志,绝不回头改 rec.State。 - gen_round 由 memory 按 source_id 现有最大值 +1 推出,不由调用方传。 echomeet 没有地方存轮次计数器,硬编码成 1 会让第二次「重新生成」的清理条件 gen_round < 1 一条都匹配不上,旧待办从此永远留在库里。 - 会议归属日期只能用 creationtime(上传时间)——DBEchoMeetRecord 上没有「会议实际 发生日期」字段,也没有客户端时区。补录历史录音会算错,是已知偏差,注释已记。 周报月报 cron 建单 → Redis 队列 → N worker。锁与队列 key 都过 redissys.RKey 加应用前缀: 测试机多应用共用一个 Redis,锁 key 不带前缀会让 A 应用挡住 B 应用整周不生成报告, 而且安安静静什么都不报。 补偿比防重更要紧:当前 compose 是单副本,多副本重复是前瞻性风险;真会发生的是 00:30 跑到一半容器重启、cron 不补跑、报告永远停在 pending。所以 memory_getreport 里做超时重新入队(同 echomeet 用 PollTranscribe 兜「转写回调丢了」的思路)。 文案生成失败不算整体失败——统计是完整的,宁可少一句话不可让报告整个没有。 顺带修的既有缺陷 - user 模块 cancel()(账号注销)补上 memory_item / memory_report 两张表, 以及既有遗漏的 useridverify(身份证掩码 + 加盐指纹)。这个清单是硬编码的, 漏了不报错,只是个人数据静静留在库里。 - errorcode.proto 新开 5201-5206 段。复用 ReqParameterError 会让客户端无法分流, 「报告还没生成好」该转圈重试而不是弹错。 测试 comm/memory_test.go 8 个:周日在 Go 里是 0、Dart 里是 7 的换算(错了不报错, 只会让每周日的闹钟在周一响)、ISO 跨年周、闰年二月、非法时区必须回退 time.Local 而不是 UTC(否则静默偏移 8 小时)、client_key 非空且不重复。 modules/memory 14 个:一次性/每天/每周展开、锚点在一年前仍能算出未来时刻、 过期项不排、灵感花销不提醒、全天项按 9:00 不落 0:00、展开结果必须升序 (客户端要按 iOS 64 条上限截断,顺序错了会把明天的排掉留下一周后的)、 项按自己时区算、LLM 输出容错(代码块/前后解释文字)与真失败必须报错。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
37 changed files with 5185 additions and 3 deletions
@ -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"`) |
|||
} |
|||
@ -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"`) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 不该解析成功") |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// IModel 模型组件接口
|
|||
type IModel interface { |
|||
// 记忆项
|
|||
addItem(item *pb.DBMemoryItem) error |
|||
findItemByClientKey(uid, clientKey string) (*pb.DBMemoryItem, error) |
|||
getItem(uid string, id uint64) (*pb.DBMemoryItem, error) |
|||
saveItem(item *pb.DBMemoryItem) error |
|||
listItems(q *itemQuery) ([]*pb.DBMemoryItem, int64, error) |
|||
delItems(uid string, ids []uint64) (int64, error) |
|||
completeItems(uid string, ids []uint64, done bool) (int64, error) |
|||
delMeetingItems(sourceID string, genRound int32) (int64, error) |
|||
|
|||
// 周期报告
|
|||
upsertReport(rec *pb.DBMemoryReport) error |
|||
saveReport(rec *pb.DBMemoryReport) error |
|||
getReport(id uint64) (*pb.DBMemoryReport, error) |
|||
getReportByPeriod(uid, ptype, pkey string) (*pb.DBMemoryReport, error) |
|||
latestUnconfirmedReport(uid string) (*pb.DBMemoryReport, error) |
|||
listReports(uid, ptype string, page, size int32) ([]*pb.DBMemoryReport, int64, error) |
|||
uidsWithItemsBetween(start, end string, minItems int) ([]string, error) |
|||
} |
|||
|
|||
// Redis 队列键后缀(实际使用时还会经 service.GetTag() 与 redissys.RKey 加应用前缀,
|
|||
// 测试机上多个应用共用一个 Redis,不加前缀会串号)
|
|||
const ( |
|||
QueueReportAwait = "memory:report:await" //报告生成等待队列
|
|||
LockReportPrefix = "memory:report:lock" //cron 抢锁前缀,后面拼 <period_type>:<period_key>
|
|||
) |
|||
@ -0,0 +1,198 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
"fmt" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
/* |
|||
会议纪要 → 待办抽取(第三路)。 |
|||
|
|||
在 echomeet 的 AIProcess 写完 summary/overview、状态置 Completed **之后**由它回调进来, |
|||
独立跑、失败只记日志不改 rec.State —— 用户照样看得到纪要,只是没有自动待办。 |
|||
|
|||
几个刻意的选择: |
|||
|
|||
- **prompt 放代码不放 echomeet_template**:模板有 1189 条(29 组 × 41 语言),改不动也改不齐; |
|||
各模板 outline 职责本就不同,塞进去会互相干扰;输出要被程序 parse 成 JSON, |
|||
格式严格性不能交给后台可随手编辑的文本。 |
|||
- **输入用已生成的 summary 而不是转写全文**:summary 约千字,全文可达上万字, |
|||
成本只是前两路的零头。 |
|||
- **必须注入会议日期**:不给日期,模型把「下周三前」换算成具体日期就是瞎猜。 |
|||
- **复用 rec.LlmSvcId**:与本条会议的总结同一个模型。同一条会议两路用不同模型, |
|||
出问题时无法归因。 |
|||
*/ |
|||
|
|||
const meetingExtractPrompt = `你是一个会议待办抽取器。用户会给你一份会议纪要,以及这次会议的日期。 |
|||
请从纪要的「待办事项」部分抽取出结构化的任务列表。 |
|||
|
|||
严格要求: |
|||
1. 只输出一个 JSON 数组,不要 markdown 代码块,不要任何解释文字。数组为空就输出 []。 |
|||
2. 数组每一项的字段固定为: |
|||
{"title":"任务内容","owner":"负责人","due_date":"YYYY-MM-DD","due_raw":"截止时间原文"} |
|||
3. title 必填。owner / due_date / due_raw 没有就填空字符串 ""。 |
|||
4. **title 与 owner 必须与输入纪要使用同一种语言**,不要翻译成中文。 |
|||
5. due_date 只在能从原文明确推算出具体日期时才填,推算基准是给定的会议日期。 |
|||
含糊的表述(「尽快」「月底前」无法定位到具体某天)一律留空 due_date, |
|||
把原文放进 due_raw。**禁止猜测、禁止编造日期。** |
|||
6. 只收录会议中明确要求执行的任务;仅在讨论中提及、未拍板的设想不计入。 |
|||
7. 同一件事被多次提及只输出一条,取最终确定的版本。` |
|||
|
|||
// extractedTodo 抽取结果的一条
|
|||
type extractedTodo struct { |
|||
Title string `json:"title"` |
|||
Owner string `json:"owner"` |
|||
DueDate string `json:"due_date"` |
|||
DueRaw string `json:"due_raw"` |
|||
} |
|||
|
|||
// 一次会议最多落多少条待办。模型偶尔会把整篇纪要拆成几十条,
|
|||
// 全落下去会把用户的日历那一天彻底刷爆。
|
|||
const maxExtractPerMeeting = 30 |
|||
|
|||
// ExtractMeetingTodos 从会议纪要抽待办并落库(实现 comm.IMemory)。
|
|||
//
|
|||
// meetingDate 是会议归属日期(YYYY-MM-DD);抽不出截止日期的项落在这一天并置
|
|||
// date_certain=false,客户端应把它们归到「待定日期」分组,不要和当天确定事项混排
|
|||
// —— 一次会抽出 8 条没写截止时间的待办全堆在会议当天,那一格就没法看了。
|
|||
func (this *Memory) ExtractMeetingTodos(ctx context.Context, uid, recordID, summary, meetingDate, llmSvcID string) { |
|||
defer func() { |
|||
if r := recover(); r != nil { |
|||
this.Errorf("会议待办抽取 panic record:%s err:%v", recordID, r) |
|||
} |
|||
}() |
|||
if !this.options.MeetingExtract { |
|||
return |
|||
} |
|||
if strings.TrimSpace(summary) == "" || uid == "" { |
|||
return |
|||
} |
|||
if _, ok := comm.ParseMemoryDate(meetingDate); !ok { |
|||
this.Warnf("会议待办抽取 record:%s 会议日期非法(%q),跳过", recordID, meetingDate) |
|||
return |
|||
} |
|||
if this.echo == nil { |
|||
this.Warnf("会议待办抽取 record:%s 未取到 echomeet,跳过", recordID) |
|||
return |
|||
} |
|||
|
|||
// 本次是第几轮:取这条会议现有项的最大 gen_round + 1。
|
|||
// 清理时删的是 gen_round < 本轮 且 user_edited=false 的项,所以轮次必须真的递增——
|
|||
// 恒为 1 的话第二次重新生成会一条都清不掉,旧待办和新待办并存。
|
|||
genRound := this.model.nextGenRound(recordID) |
|||
|
|||
prompt := this.options.MeetingExtractPrompt |
|||
if prompt == "" { |
|||
prompt = meetingExtractPrompt |
|||
} |
|||
userContent := fmt.Sprintf("<meeting_date>%s</meeting_date>\n\n<summary>\n%s\n</summary>", meetingDate, summary) |
|||
|
|||
c, cancel := context.WithTimeout(ctx, 90*time.Second) |
|||
text, usedSvc, err := this.echo.ChatLLM(c, llmSvcID, prompt, userContent) |
|||
cancel() |
|||
if err != nil { |
|||
// 失败隔离:只记日志,绝不改 rec.State。纪要是用户真正要的东西,
|
|||
// 不能因为附赠的待办抽取失败就把它标成失败。
|
|||
this.Errorf("会议待办抽取 record:%s 调用失败 svc:%s err:%v", recordID, usedSvc, err) |
|||
return |
|||
} |
|||
|
|||
todos, err := parseExtractedTodos(text) |
|||
if err != nil { |
|||
this.Warnf("会议待办抽取 record:%s 解析失败已忽略 err:%v raw:%.200s", recordID, err, text) |
|||
return |
|||
} |
|||
if len(todos) == 0 { |
|||
this.Infof("会议待办抽取 record:%s 无待办", recordID) |
|||
return |
|||
} |
|||
if len(todos) > maxExtractPerMeeting { |
|||
this.Warnf("会议待办抽取 record:%s 返回 %d 条,截断到 %d", recordID, len(todos), maxExtractPerMeeting) |
|||
todos = todos[:maxExtractPerMeeting] |
|||
} |
|||
|
|||
// 幂等:清掉上一轮**自动生成**的项,user_edited 的一律保留。
|
|||
// 不做这个,用户点一次「重新生成」就会丢掉自己的修改。
|
|||
if n, e := this.model.delMeetingItems(recordID, genRound); e != nil { |
|||
this.Warnf("会议待办抽取 record:%s 清理旧轮次失败已忽略 err:%v", recordID, e) |
|||
} else if n > 0 { |
|||
this.Infof("会议待办抽取 record:%s 清理旧轮次 %d 条", recordID, n) |
|||
} |
|||
|
|||
meetDay, _ := comm.ParseMemoryDate(meetingDate) |
|||
saved := 0 |
|||
for i, t := range todos { |
|||
t.Title = strings.TrimSpace(t.Title) |
|||
if t.Title == "" { |
|||
continue |
|||
} |
|||
item := &pb.DBMemoryItem{ |
|||
Uid: uid, Category: comm.MemoryCatTodo, Source: comm.MemorySrcMeeting, |
|||
SourceId: recordID, |
|||
// client_key 用「记录id + 轮次 + 序号」,重跑同一轮不会插重
|
|||
ClientKey: fmt.Sprintf("meet:%s:%d:%d", recordID, genRound, i), |
|||
Title: t.Title, |
|||
Owner: strings.TrimSpace(t.Owner), |
|||
DueRaw: strings.TrimSpace(t.DueRaw), |
|||
HappenDate: meetingDate, |
|||
DateCertain: false, |
|||
GenRound: genRound, |
|||
State: pb.MemoryState_MemoryState_Pending, |
|||
RemindAhead: 0, // 会议待办默认不提醒,用户改期时自己开
|
|||
Currency: "CNY", |
|||
} |
|||
// 「未明确」是模板要求模型在原文没写时填的占位词,不是真的负责人
|
|||
if item.Owner == "未明确" || strings.EqualFold(item.Owner, "unspecified") { |
|||
item.Owner = "" |
|||
} |
|||
|
|||
// 服务端二次校验日期:解析不出、或早于会议当天的一律丢弃,只留 due_raw。
|
|||
// 参考 SET_clock 踩过的坑——模型把「下午 3:30」的 time 填成 03:30。
|
|||
if d, ok := comm.ParseMemoryDate(strings.TrimSpace(t.DueDate)); ok { |
|||
if !d.Before(meetDay) { |
|||
item.HappenDate = comm.FormatMemoryDate(d) |
|||
item.DateCertain = true |
|||
} else { |
|||
this.Warnf("会议待办抽取 record:%s 丢弃早于会议日的截止日 %s", recordID, t.DueDate) |
|||
} |
|||
} |
|||
|
|||
if err := this.model.addItem(item); err != nil { |
|||
this.Warnf("会议待办抽取 record:%s 第%d条落库失败已跳过 err:%v", recordID, i, err) |
|||
continue |
|||
} |
|||
saved++ |
|||
} |
|||
this.Infof("会议待办抽取 record:%s uid:%s svc:%s 落库 %d/%d 条", recordID, uid, usedSvc, saved, len(todos)) |
|||
} |
|||
|
|||
// parseExtractedTodos 解析模型输出。
|
|||
//
|
|||
// 模型时常无视「不要 markdown 代码块」这条,所以先剥一层 ```json ... ```;
|
|||
// 也可能在数组前后带一句解释,再按第一个 [ 到最后一个 ] 截一次。
|
|||
func parseExtractedTodos(text string) ([]extractedTodo, error) { |
|||
s := strings.TrimSpace(text) |
|||
if strings.HasPrefix(s, "```") { |
|||
if i := strings.Index(s, "\n"); i >= 0 { |
|||
s = s[i+1:] |
|||
} |
|||
if i := strings.LastIndex(s, "```"); i >= 0 { |
|||
s = s[:i] |
|||
} |
|||
s = strings.TrimSpace(s) |
|||
} |
|||
if start, end := strings.Index(s, "["), strings.LastIndex(s, "]"); start >= 0 && end > start { |
|||
s = s[start : end+1] |
|||
} |
|||
out := make([]extractedTodo, 0) |
|||
if err := json.Unmarshal([]byte(s), &out); err != nil { |
|||
return nil, err |
|||
} |
|||
return out, nil |
|||
} |
|||
@ -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]) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue