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