diff --git a/docs/记忆中心-设计与开发文档.md b/docs/记忆中心-设计与开发文档.md index d58e11b0..d233e4f5 100644 --- a/docs/记忆中心-设计与开发文档.md +++ b/docs/记忆中心-设计与开发文档.md @@ -1,22 +1,40 @@ -# 记忆中心(Memory Center)设计与开发文档 +# 记忆中心(拾忆)设计与开发文档 -> 版本:v0.1 草案  日期:2026-08-31 +> 版本:v0.2  日期:2026-09-04(v0.1 为 2026-08-31) > 范围:`apps/services`(后端)、`apps/client`(Flutter)、`apps/admin`(后台)、百炼 Agent 配置 -> 状态:设计评审中,未开工 +> 状态:设计评审中,**未开工**(代码侧目前只有 tab 文案改名,见 §2.3) + +## v0.2 修订说明 + +v0.1 之后主仓有 4 次提交(翻译延迟优化、设备表合表、收拢积压改动、上传改预签名 URL), +本次逐条复核了 v0.1 的「现状盘点」,**有 6 处已经不成立**,另发现 3 个既有缺陷会直接绊到本设计: + +| # | v0.1 的说法 | 实际 | 影响章节 | +|---|---|---|---| +| 1 | echomeet 按 `ServiceType` 四套固定服务商,总结用 `GetSummarizer(svcType, lang)` | 已改为**后台编排**:`PickLLM()` / `GetSummarizer(svcId)`,`ResolveServiceType` 已不存在 | §7.2 | +| 2 | 会议总结只吃文本 | 已支持**多模态图片**(`imageurls`),小票识别不用另造轮子 | §12 待确认 1 | +| 3 | 客户端只有花销 i18n 文案 | `CalendarEventCategory{todo,expense}` + 花销卡片 + 范围内合计**已经写好了** | §6.1 | +| 4 | 客户端上传对象存储用腾讯 COS、密钥明文下发 | 已改**服务端签发预签名 URL**,`scene` 是白名单 | §6.4 | +| 5 | `flutter_local_notifications` 只用于录音提示 | 属实,但已有**两个各自独立的 plugin 实例**,且**缺 `timezone` 依赖** | §5.1 | +| 6 | 「代办」页要改名 | zh-CN/HK/TW 已改「拾忆/拾憶」,但**只有 11/41 个语言文件有 `tabTodo` 这个 key** | §2.3 | + +新增三条既有缺陷(详见 §2.4):**MCP 完全没有鉴权**、**账号注销漏表**、**i18n 改名没做完**。 --- ## 一、背景与目标 -App 现有的「代办」页(`TodoTab`)只是一个日历壳子:数据来自 12 条写死的演示数据加上 EMAI 助手落下来的闹钟,全部存在本地 `GetStorage`,服务端没有任何对应存储。 +App 的第二个 tab 已由「代办」更名为「**拾忆**」(`tabTodo`),但页面内部还是原样:数据来自 12 条写死的 +演示数据(`mockCalendarEvents()`)加上 EMAI 助手落下来的闹钟,全部存在本地 `GetStorage`, +服务端没有任何对应存储。 -本次要把它升级成**「记忆」页**——用户一天里发生的各类事情的统一入口,并围绕它形成「录入 → 提醒 → 复盘 → 问答」的闭环。 +本次要把它做实——用户一天里发生的各类事情的统一入口,并围绕它形成「录入 → 提醒 → 复盘 → 问答」的闭环。 ### 六项目标需求 | # | 需求 | 关键约束 | |---|---|---| -| 1 | 记忆页展示多类事项:闹钟、灵感记忆、花销、待办,**分类可扩展** | 一天多件事,按分类组织 | +| 1 | 拾忆页展示多类事项:闹钟、灵感记忆、花销、待办,**分类可扩展** | 一天多件事,按分类组织 | | 2 | 每日首次登录,自动弹窗展示当天事项并**语音播报**,播报可随时关闭 | 播报可中断 | | 3 | 待办/闹钟**提前 5 分钟提醒**,语音播报内容 | App 未打开时也要触发 | | 4 | 每周一凌晨生成上周总结:未完成事项、花销合计、灵感汇总,播报后**需用户确认** | 服务端定时生成 | @@ -29,6 +47,8 @@ App 现有的「代办」页(`TodoTab`)只是一个日历壳子:数据来 2. **服务端为真相源**:本地只做缓存与离线兜底,换设备数据要在。 3. **失败隔离**:AI 抽取、总结生成失败不影响主流程,宁可少一条记忆,不可让会议纪要或登录卡住。 4. **不重复造轮子**:`allhelp` 已有任务体系与 MCP 工具,在其上演进。 +5. **选路走编排,不写死服务商**(v0.2 新增):本模块所有 LLM 调用一律经 echomeet 的 `providers` 选路, + 不再出现第二处硬编码 `sys/doubao`。理由见 §7.2。 --- @@ -40,27 +60,114 @@ App 现有的「代办」页(`TodoTab`)只是一个日历壳子:数据来 |---|---|---| | **用户任务表 `DBTask`** | [allhelp_db.proto](../apps/proto/allhelp/allhelp_db.proto) | `task_name/task_desc/task_type/trigger_time/cron_expr/status/extra`,存 **MySQL 业务库** | | 任务增删改查接口 | `allhelp` 模块 `api_addtask/gettasks/canceltask/completetask` | 已上线 | -| **MCP 工具(EMAI 可调)** | [tool_get_user_tasks.go](../apps/services/modules/mcp/tool_get_user_tasks.go)、`tool_allhelp_task.go`、`tool_cancel_user_task.go` | EMAI 已能查/建/取消任务 | -| **异步 AI 总结流水线** | [allhelp/summary.go](../apps/services/modules/allhelp/summary.go) | Redis 队列 + N worker + `doubao.Chat`,周报/月报可照搬 | +| **MCP 工具(EMAI 可调)** | [tool_get_user_tasks.go](../apps/services/modules/mcp/tool_get_user_tasks.go)、`tool_allhelp_task.go`、`tool_cancel_user_task.go` | EMAI 已能查/建/取消任务。⚠️ **无鉴权**,见 §2.4 | +| **异步 AI 流水线(两套范例)** | [allhelp/summary.go](../apps/services/modules/allhelp/summary.go)(Redis 队列 + N worker)
[echomeet/tasks.go](../apps/services/modules/echomeet/tasks.go)(Redis 队列 + ants 池 + 每秒 cron 调度) | 周报/月报照搬。**选路照 echomeet,不要照 allhelp**(后者硬编码 doubao) | | 每日聊天总结表 `DBChatSummary` | 同上 | `(uid, summary_date)` 唯一,覆盖重算 | +| **LLM 选路(编排)** | [echomeet/providers.go](../apps/services/modules/echomeet/providers.go) `PickLLM` / `GetSummarizer(svcId)` | 后台配置驱动、按应用/区域分层、NATS 热重载。`memory` 与 `echomeet` **同在 home 进程**,可直接复用 | +| **多模态图片入 LLM** | `AIProcess` 的 `ChatMessage.Images` → 各 Summarizer | 花销小票 OCR 可直接走这条 | | **TTS 播报** | [tts_service.dart](../apps/client/lib/data/services/tts_service.dart) | `startspeak/speakOnce/speakStream/flushStream/stop`,**已支持中断** | -| 本地通知插件 | `flutter_local_notifications ^18.0.1` | 目前只用于录音后台提示 | +| 本地通知插件 | `flutter_local_notifications ^18.0.1` | 目前只用于录音后台提示。⚠️ 见 §5.1 的三个坑 | +| 通知相关 Android 权限 | `AndroidManifest.xml` | `POST_NOTIFICATIONS` / `SCHEDULE_EXACT_ALARM` / `RECEIVE_BOOT_COMPLETED` **已声明**,不用再加 | | 助手端侧指令 | [assistant_directive_service.dart](../apps/client/lib/data/services/assistant_directive_service.dart) | `SET_clock` 已落地成提醒 | +| **日历分类与花销卡片** | [calendar_event.dart](../apps/client/lib/modules/home/views/widgets/calendar/calendar_event.dart)、[todo_scope_list.dart](../apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart) | `CalendarEventCategory{todo,expense}`、`amount` 字段、`_expenseCard`、`expensesForScope` 合计条**都已存在** | | 会议纪要生成 | [echomeet/tasks.go](../apps/services/modules/echomeet/tasks.go) `AIProcess` | `summary` 已含「四、待办事项」章节 | +| **服务端签发上传 URL** | [api_getuploadurl.go](../apps/services/modules/user/api_getuploadurl.go) | 客户端不持密钥;`scene` 白名单,加新场景要改这里 | ### 2.2 缺口 | 缺口 | 影响需求 | |---|---| | `DBTask` **「服务端仅做记录,不自动触发」**(proto 注释原文) | 3/4/5 | -| 没有分类概念,无法承载灵感/花销 | 1 | -| **花销功能后端完全空白**,客户端只有 i18n 文案和 mock | 1/4/5 | +| 没有分类概念,无法承载灵感/花销(客户端只有 todo/expense 两类,且只是 mock) | 1 | +| **花销后端完全空白**,客户端的花销卡片喂的是 mock 数据 | 1/4/5 | | 没有周报/月报表与生成任务 | 4/5 | | 客户端待办数据不落服务端(`synced` 恒为 false) | 全部 | | 会议纪要里的待办是自然语言,无结构化字段 | 1 | -| 本地通知从未用于事项提醒 | 3 | +| 本地通知从未用于事项提醒;**`timezone` 依赖未引入** | 3 | +| MCP 工具无鉴权,uid 由模型自报 | 6 | + +### 2.3 「拾忆」改名的真实进度(v0.2 复核) + +改名只动了一个 i18n key,且**没改全**: + +| 语言 | `tabTodo` 取值 | +|---|---| +| zh_cn | `拾忆` | +| zh_hk / zh_tw | `拾憶` | +| de / en / es / fr / ja / ko / pt / ru | `Remin` | +| 其余 **30 个语言文件** | **该 key 不存在** | + +- ⚠️ **`'Remin'` 不是任何语言里的词**,看着像 `Reminisce` 打了一半。8 个语言的 tab 上现在就顶着它。 +- ⚠️ 缺 key 的 30 个语言不会显示 `tabTodo` 字面量——GetX 会回退到 `fallbackLocale`(en_US), + 于是**这 30 个语言也全都显示 `Remin`**。也就是说 41 个语言里有 38 个是坏的,只是不报错。 +- 这条与本设计无关也得修,但既然拾忆页要动,顺手一起做。 +- ⚠️ 批量补 key 时**单双引号两种风格都要查**,本仓的语言文件两种都有,只按单引号查会漏, + 漏了会在 const map 里出现重复键,`flutter build` 阶段才炸。 + +⚠️ **`tools/i18n_audit.py` 抓不到这个问题,别指望它兜底**(v0.2 实测)。它的循环是 +`for k, v in kv.items()` —— 遍历的是**该语言文件已有的 key**,且对 `baseline.get(k) is None` +直接 `continue`。**结构上就不报告缺失的 key**。而对那 8 个值为 `Remin` 的语言, +`_looks_english_sentence("Remin")` 也返回 False(它靠一张英文常用词表判断),同样不报。 +**这个工具对本问题双重失明**——这正是 30 个语言悄悄丢掉 `tabTodo` 而没人发现的原因。 + +⚠️ **而且这不是孤例,是系统性的**(v0.2 实测):以 zh_cn 的 1748 个 key 为基准, +**40 个语言文件里只有 1 个不缺 key**,其余最少缺 233 个、最多缺 388 个(nl_nl), +即 13%~22% 的文案在多数语言里是缺的、全部静默回退英文。 +审计工具当前还报着 **514 处**「值等于英文基线」的未翻译项——新增信号会直接淹没在里面。 + +**结论:拾忆要新增的 30~50 个 key 会掉进一条已经在漏的管道。** +要么先给 `i18n_audit.py` 加一个「缺失 key」检查并接进 CI,要么接受新 key 同样会漏掉大半。 + +### 2.4 v0.2 新发现:三个会绊到本设计的既有缺陷 + +#### (1) MCP 完全没有鉴权,uid 由模型当参数传 —— 高危 + +[module.go](../apps/services/modules/mcp/module.go) 的 `authFromRequest` 只是把 +`Authorization` 头塞进 context: + +```go +func authFromRequest(ctx context.Context, r *http.Request) context.Context { + return withAuthKey(ctx, r.Header.Get("Authorization")) +} +``` + +**全仓没有任何地方读回这个 key,也没有任何 JWT 校验**。三个用户工具 +(`get_user_tasks` / `allhelp_task` / `cancel_user_task`)的 `uid` 都是 +`mcp.WithString("uid", mcp.Required())`——由大模型填进来的普通参数。 + +现状下的后果已经不小(能读改任意人的提醒),但记忆项装的是**花销金额、灵感原文、 +每日行程**,一旦按 §8.2 把 5 个新工具挂上去,等于开放了一个「给 uid 就返回这个人 +生活流水」的公开接口。 + +**决策:§8.2 的新工具落地前,MCP 必须先补 uid 归属校验**——从 `Authorization` 解出会话 uid, +**忽略**参数里的 uid(或校验二者一致,不一致直接拒)。这是三期的**前置任务**,不是可选优化。 + +#### (2) 账号注销 `cancel()` 是逐表硬编码的,新表必须登记 + +[model_user.go](../apps/services/modules/user/model_user.go) 的 `cancel()` 手写了要清哪几张表: -### 2.3 存储分工(迁移讨论已确认) +```go +mysql.Delete(comm.TableUserdevice, "uid=?", uid) +mysql.Delete(comm.TableUserStatistics, "uid=?", uid) +mysql.Delete(comm.TableUserUseLog, "uid=?", uid) +mysql.Delete(comm.TableAllhelpTask, "uid=?", uid) +mysql.Delete(comm.TableChatSummary, "uid=?", uid) +mysql.Delete(comm.TableEchomeetRecord, "uid=?", uid) +``` + +`memory_item` / `memory_report` **必须加进这个列表**,否则用户注销后花销金额、 +灵感原文、全部行程仍留在库里。这里没有任何机制会提醒你漏了表——**漏了不报错**。 + +⚠️ 顺带:`TableUserIdVerify`(实名认证记录,存身份证掩码 + 加盐指纹)**当前也不在清单里**, +是既有遗漏,建议本次一并补上。 + +#### (3) 删 mock 会留下约 40 个死 i18n key + +`mockCalendarEvents()` 引用了 `demoEvt1..12` / `demoDesc1..12` / `demoLoc1..12` / `demoTag*` +共约 40 个 key × 41 个语言文件。§6.2 删 mock 之后这些 key 全部无人引用, +要跟着一起删干净,别留下 1600 行无主文案。 + +### 2.5 存储分工(迁移讨论已确认) | 数据 | 库 | |---|---| @@ -82,13 +189,33 @@ App 现有的「代办」页(`TodoTab`)只是一个日历壳子:数据来 ### 3.2 表结构 `memory_item`(MySQL 业务库) +⚠️ **下面的 DDL 只是说明用,本项目不手写建表语句**(v0.2 补)。本仓所有业务表都是 +`mysql.CreateTable(表名, &pb.DBXxx{})` → gorm `AutoMigrate`,**schema 完全由 `.proto` 的字段类型 +加 `@go_tags(gorm:...)` 决定**(见 `user/model_user.go`、`allhelp/model.go`)。 +所以真正要写的是 `apps/proto/memory/memory_db.proto`,而 protobuf 没有 DECIMAL / DATE / +DATETIME / 可空 这几种类型,逐个要落地成: + +| DDL 里想要的 | proto 里实际写法 | +|---|---| +| `amount DECIMAL(12,2)` | `int64 amount_cents`(**分**)——见下 | +| `happen_date DATE` | `string happen_date` + `gorm:"type:date;index"`,值形如 `2026-09-04` | +| `next_remind_at DATETIME NULL` | `int64 next_remind_at`(unix 秒,**0 表示无**,proto3 没有 null) | +| `UNIQUE KEY (uid, client_key)` | `gorm:"uniqueIndex:idx_mem_uid_ck,priority:N"`,照 `DBChatSummary` 的写法 | + +⚠️ **金额一律用 `int64` 存「分」,不要 DECIMAL 也不要 double**(v0.2 修正)。 +v0.1 写 `DECIMAL(12,2)` 是对的方向(怕浮点误差)但**与全项目口径冲突**: +`pay_db.proto` 的 `amount`、`db.proto` 的 `base_amount`/`channel_amount`/`platform_amount` +**全是 `int64` 分**。多一种金额表示法,跨模块对账时必然有人换算漏乘 100。 +(`db.proto` 里 `DBProduct.price` 是 `double`,那是历史遗留,不要照抄。) + ```sql CREATE TABLE memory_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, uid VARCHAR(50) NOT NULL, category VARCHAR(16) NOT NULL, -- alarm/todo/idea/expense/…(可扩展) source VARCHAR(16) NOT NULL, -- meeting/assistant/manual/device - source_id VARCHAR(64) DEFAULT '', -- 会议记录 id、指令 id 等 + source_id VARCHAR(64) DEFAULT '', -- 会议记录 id、端侧指令 id 等 + client_key VARCHAR(64) DEFAULT '', -- 幂等键(见 3.3) title VARCHAR(200) NOT NULL, -- 主文案(所有分类通用) detail TEXT, -- 补充说明 / 灵感正文 @@ -97,18 +224,19 @@ CREATE TABLE memory_item ( happen_time VARCHAR(5) DEFAULT '', -- HH:mm,无具体时间则空 date_certain TINYINT DEFAULT 1, -- 0=日期是推断的(如落在会议当天) due_raw VARCHAR(64) DEFAULT '', -- 截止时间原文(「下周三前」) + tz VARCHAR(48) DEFAULT '', -- 录入时的客户端时区(IANA 名) repeat_rule VARCHAR(16) DEFAULT '', -- once/daily/weekly/weekdays/weekend/monthly repeat_raw VARCHAR(64) DEFAULT '', -- 重复规则原文 weekday TINYINT DEFAULT 0, -- weekly 时 1-7 - amount DECIMAL(12,2) DEFAULT 0, -- 花销金额(category=expense) - currency VARCHAR(8) DEFAULT 'CNY', + amount_cents BIGINT DEFAULT 0, -- 花销金额(分,category=expense) + currency VARCHAR(8) DEFAULT 'CNY', -- ⚠️ 多币种问题见 3.3 owner VARCHAR(64) DEFAULT '', -- 负责人原文(会议待办) state TINYINT DEFAULT 0, -- 0待办 1完成 2废弃 remind_ahead INT DEFAULT 5, -- 提前提醒分钟数,0=不提醒 - remind_at DATETIME NULL, -- 计算好的提醒时刻(服务端算,客户端直接用) + next_remind_at DATETIME NULL, -- 下一次提醒时刻(重复项由服务端滚动推进,见 3.3) user_edited TINYINT DEFAULT 0, -- 用户改过 → 重新生成时不覆盖 gen_round INT DEFAULT 0, -- 第几轮 AI 生成 @@ -118,19 +246,45 @@ CREATE TABLE memory_item ( INDEX idx_uid_date (uid, happen_date), INDEX idx_uid_cat (uid, category, happen_date), - INDEX idx_remind (remind_at, state) + INDEX idx_remind (next_remind_at, state), + UNIQUE KEY uk_uid_clientkey (uid, client_key) -- client_key 非空时生效,见 3.3 ); ``` ### 3.3 字段设计要点 -**`happen_date` 永不为空**——这是记忆页能工作的前提。日历按日期排,没有日期的项根本渲染不出来。会议待办抽不出截止日期时,落在**会议当天**并置 `date_certain=0`,卡片上用 `due_raw` 标注「下周三前」,用户可改期。 +**`happen_date` 永不为空**——这是拾忆页能工作的前提。日历按日期排,没有日期的项根本渲染不出来。会议待办抽不出截止日期时,落在**会议当天**并置 `date_certain=0`,卡片上用 `due_raw` 标注「下周三前」,用户可改期。 **`category` 是字符串不是枚举**——新增分类(如 `health`、`travel`)只加常量、不改表、不迁移。代价是无法在 DB 层约束取值,靠服务端白名单校验。 - -**`amount` 用 DECIMAL 不用 float**——月度金额聚合,浮点误差不可接受。 - -**`remind_at` 由服务端算好**——重复规则展开、时区换算、提前量扣减都在服务端做一次,客户端拿到就是一个绝对时刻,不重复实现容易出错的日期逻辑。 +⚠️ 相应地,**客户端必须对不认识的 `category` 有兜底**:按 `todo` 渲染并用中性配色, +绝不能 `switch` 不到就抛。否则服务端一加分类,所有存量客户端的拾忆页整页崩。 + +**`amount_cents` 用 int64 存分**——月度金额聚合,浮点误差不可接受;用分而不用 DECIMAL 是为了 +和 `pay` / 结算模块同口径(§3.2)。客户端展示时除以 100。 + +**多币种是没解决的问题(v0.2 提出)**——`currency` 字段存了,但 `stat_json` 的 `expense_total` +是**单个数字**,把 CNY 和 JPY 直接相加是错的。App 是 41 语言全球发行,用户出国消费很正常。 +另外客户端现在的花销卡片是**硬编码 `¥`**([todo_scope_list.dart:291](../apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart#L291) +的 `'¥${...}'`),`currency` 存了也没地方显示。 +一期的可行取舍:**只支持单一货币**(跟随用户所在地区,存在 `currency` 里但不做换算), +月报里若出现多种货币就**分币种各列一行**,绝不合并成一个数——不做汇率换算,也别假装能换算。 + +**`client_key` 是幂等键(v0.2 新增)**——端侧指令会重试,`memory_add` 没有幂等保护就会插重。 +客户端用「端侧指令 id」或本地生成的 uuid 填这一列,服务端建 `(uid, client_key)` 唯一索引, +重复提交返回已存在那条而不是报错。`AssistantDirectiveService` 现在的按指令 id 去重**只在本进程内存里**, +换手机、重装、进程重启后全部失效——不能当服务端幂等用。 + +**重复项的提醒推进(v0.2 修正)**——v0.1 写的是「`remind_at` 服务端算好、客户端直接用」。 +这对一次性项成立,对**重复项不成立**:一条 `repeat_rule=daily` 的闹钟只有一行记录, +一个 `remind_at` 字段只能表示一个时刻,谁在它过去之后把它推到明天? +本版改为: +- 字段改名 `next_remind_at`,语义是「下一次」; +- **`memory_upcoming` 在返回时按 `repeat_rule` 就地展开成未来 N 天的具体时刻列表**(不落库), + 客户端拿到的是一串绝对时刻,直接排通知; +- `next_remind_at` 只用于服务端侧的索引与将来可能的推送,不作为客户端唯一依据。 + +**`tz` 记录录入时区**——跨时区旅行时「今天」跟着人走(§4.1),但一条已经录好的 +「明早 8 点」不该因为落地伦敦就变成 8 点伦敦时间。存下录入时区,展开提醒时按它算。 **`user_edited` + `gen_round`**——会议重新总结时,删掉上一轮 `user_edited=0` 的项,保留用户勾过改过的。不做这个,用户点一次「重新生成」就会丢掉自己的修改。 @@ -157,6 +311,7 @@ CREATE TABLE memory_report ( stat_json TEXT, -- 结构化统计(见下) summary TEXT, -- AI 生成的播报文案 + llm_svc_id VARCHAR(64), -- 实际用了哪个总结服务(排障用,同 echomeet 的口径) state TINYINT DEFAULT 0, -- 0待生成 1生成中 2完成 3失败 error_msg VARCHAR(500), @@ -174,32 +329,58 @@ CREATE TABLE memory_report ( { "todo_total": 12, "todo_done": 8, "todo_undone": 4, "undone_items": [{"id":123,"title":"跟进 A 客户","happen_date":"2026-08-26"}], + "undone_truncated": false, "expense_total": 1280.50, "expense_count": 15, "expense_by_day": {"2026-08-25": 120.00}, "idea_count": 3, "idea_items": [{"id":456,"title":"做一个会议纪要转待办的功能"}], + "idea_truncated": false, "alarm_total": 7 } ``` **金额与完成数绝不能让 LLM 算**——它只负责把 `stat_json` 组织成一段自然、适合朗读的话。数字算错比话说得干巴严重得多。 +⚠️ **`undone_items` / `idea_items` 必须截断**(建议各 20 条,超出置 `*_truncated=true`)。 +一个重度用户一周攒 200 条未完成,全塞进 prompt 会顶爆上下文、拖慢生成、并且**播报出来 +没人听得完**。总数用 `todo_undone` 表达,明细只举前几条。 + --- ## 四、服务端接口 -新建 `memory` 模块(`apps/services/modules/memory/`),由 `home` 服务装载。按反射注册约定,方法签名满足 `func (c *apiComp) X(session comm.IUserSession, req *pb.XReq) (*pb.XResp, *pb.ErrorData)` 即自动注册为 `memory_x`。 +新建 `memory` 模块(`apps/services/modules/memory/`),由 `home` 服务装载(`home/main.go` 的 `lego.Run` 里加一行)。 +按反射注册约定,方法签名满足 `func (c *apiComp) X(session comm.IUserSession, req *pb.XReq) (*pb.XResp, *pb.ErrorData)` +即自动注册为 `memory_x`,客户端路径 `/api/home/memory_x`。接口都要登录,因此不进网关白名单。 + +⚠️ **模块 `Init` 绝不能返回 error(v0.2 补,这条最容易一次搞挂全站)**: +`lego.Run` 里 `service.Init` 一旦返回非 nil 就 `log.Panicf("服务初始化失败")` → home 进程退出; +而 gateway/home/api/mcp/timer **五个服务打在同一个镜像里由 `entrypoint.voitrans.sh` 管**, +脚本发现任一 pid 退出就 `kill -TERM` 掉其余全部并 `exit 1`("FATAL: pid $pid exited, shutting down container")。 +**也就是说 `memory` 模块建表失败会让整站 502。** +`CreateTable` 失败必须只 `Errorln` 不外传。 + +⚠️ 照抄时注意 `allhelp/model.go` 那段是**有 bug 的反例**: + +```go +if err = mysql.CreateTable(A, ...); err != nil { this.module.Errorln(err) } +if err = mysql.CreateTable(B, ...); err != nil { this.module.Errorln(err) } +return // ← 命名返回值 err 仍然是最后一次的结果 +``` + +它的本意是「记日志、继续」,实际是:A 失败 B 成功 → 错误被静默吞掉;B 失败 → err 漏出去 → 全站挂。 +正确写法是用局部变量接住,`return nil`。 | 接口 | 用途 | 关键参数 | |---|---|---| | `memory_list` | 拉取记忆项 | `start_date`/`end_date`/`categories[]`/`states[]` | -| `memory_today` | 当天全部事项(需求 2 弹窗直接用) | `date`(客户端本地日期) | -| `memory_add` | 新增 | 整个 item | +| `memory_today` | 当天全部事项(需求 2 弹窗直接用) | `date`(客户端本地日期)+ `tz` | +| `memory_add` | 新增 | 整个 item(含 `client_key` 幂等键) | | `memory_update` | 修改(自动置 `user_edited=1`) | id + 变更字段 | | `memory_del` | 删除 | `ids[]` | | `memory_complete` | 标记完成 | `ids[]` | -| `memory_upcoming` | 拉未来 N 天待提醒项(客户端排本地通知用) | `days`(默认 7) | -| `memory_getreport` | 取周报/月报 | `period_type`/`period_key` | +| `memory_upcoming` | 拉未来 N 天待提醒项,**重复项已展开成绝对时刻** | `days`(默认 7)+ `tz` | +| `memory_getreport` | 取周报/月报 | `period_type`/`period_key`;不传则取最近一份未确认的 | | `memory_confirmreport` | 用户确认报告(需求 4) | `report_id` | | `memory_stats` | 即时统计(EMAI 问答与页面头部用) | 时间范围 + 分类 | @@ -207,7 +388,15 @@ CREATE TABLE memory_report ( ### 4.1 时区 -`happen_date` 是**用户本地日期**,不是 UTC 日期。客户端请求时带上 `tz`(如 `Asia/Shanghai`)与本地日期字符串,服务端不做时区推断。跨时区旅行时以客户端当前时区为准——这是刻意选择:用户"今天"的定义应该跟着人走。 +`happen_date` 是**用户本地日期**,不是 UTC 日期。客户端请求时带上 `tz`(IANA 名如 `Asia/Shanghai`) +与本地日期字符串,服务端不做时区推断。跨时区旅行时以客户端当前时区为准——这是刻意选择: +用户「今天」的定义应该跟着人走。 + +⚠️ 但**单条记忆项的提醒时刻按它自己的 `tz` 算**(§3.3),不跟着人走。 +两个口径不同不是矛盾:「今天有哪些事」是视图问题,「明早 8 点响」是承诺问题。 + +⚠️ 服务端容器需能解析 IANA 时区。`home` 的 `main.go` 已 `import _ "time/tzdata"`(为 analyze 统计引入), +本模块直接受益,不用另外处理。 --- @@ -220,7 +409,7 @@ CREATE TABLE memory_report ( **必须在客户端做**,因为 App 未打开、甚至无网时也要响。服务端推送做不到(无 APNs/FCM 通道,且国内厂商推送要逐家接)。 ``` -登录/前台恢复 → memory_upcoming(days=7) +登录/前台恢复 → memory_upcoming(days=7, tz) → flutter_local_notifications.zonedSchedule() 逐条排期 → 到点系统通知(带内容) → 用户点击进 App → TtsService 播报 title + detail @@ -228,10 +417,49 @@ CREATE TABLE memory_report ( 要点: - 每次拉取**先取消旧排期再重排**,避免改期后旧通知还在。 -- Android 需 `SCHEDULE_EXACT_ALARM` 权限(Android 13+),iOS 需通知授权。**未授权时降级为「打开 App 才提醒」**,不要阻塞主流程。 +- Android 的 `SCHEDULE_EXACT_ALARM` / `POST_NOTIFICATIONS` / `RECEIVE_BOOT_COMPLETED` + **manifest 里已经声明了**,不用再加;但 Android 13+ 仍需运行时申请 `POST_NOTIFICATIONS`。 + iOS 需通知授权。**未授权时降级为「打开 App 才提醒」**,不要阻塞主流程。 - 排期上限按平台限制(iOS 最多 64 条待处理通知),只排最近 7 天、按时间取前 N 条。 + ⚠️ 一条 `daily` 闹钟展开 7 天就吃掉 7 个名额,**重复项要先按天合并再排**, + 否则三条日常闹钟就把 iOS 的配额占掉一半。 - 通知点击进来才播报,**不在通知里直接播声音**——锁屏状态下强行出声是骚扰。 +⚠️ **权限拿不到的概率被低估了(v0.2 新增,这条可能让需求 3 大面积失效)**: + +1. **通知权限有 48 小时冷却,且不允许补救**。[permission_util.dart](../apps/client/lib/core/utils/permission_util.dart) + 为过审把规则写死了:用户拒绝过一次,**48 小时内一律直接返回 false,不弹系统框、不弹自定义框、 + 不跳设置页**,拒绝时间存 GetStorage、重启 App 依然生效;调用方拿到 false 只能「把功能置灰或退出」, + **注释里明写「不要自己再补一个弹窗」**。 + 而 `Permission.notification` 是**全 App 共用的一个权限**,录音(`background_session_notifier`)、 + 会议录音、音乐播放都在申请它。用户在任何一处拒了,提醒功能就有两天完全静默, + 而且 App **不被允许**告诉他为什么。设计上必须假定「相当一部分用户长期拿不到通知权限」。 + 唯一的例外是「设置 → 权限管理」页里用户自己点(`userInitiated: true`), + 所以拾忆页至少要有一条指向那个页面的静态提示(不是弹窗)。 +2. **Android 14 的精确闹钟很可能拿不到**。manifest 声明的是 `SCHEDULE_EXACT_ALARM`, + Android 14 起它对普通应用**默认不授予**,要用户去系统设置里开。 + 闹钟/日历类应用应该声明的是 `USE_EXACT_ALARM`(默认授予), + 但那个权限 **Google Play 要求应用核心功能就是闹钟/日历**并单独提交说明。 + 拾忆算不算,需要产品先定;不申请就只能用 `inexact` 排期, + **「提前 5 分钟」会变成「提前 5 到 20 分钟之间某个时刻」**,与需求 3 的字面要求不符。 +3. **国内 ROM 会杀掉后台排期**。华为/小米/OPPO/vivo 的省电策略会清掉未加白名单应用的 + AlarmManager 排期,这不是代码能绕过去的。已知的缓解只有引导用户加「自启动/电池优化白名单」, + 而那又会撞上第 1 条的合规冷却。**需求 3 在国内安卓上做不到 100% 送达,这个预期要提前跟产品对齐。** + +⚠️ **三个必须先解决的工程前置(v0.2 新增)**: + +1. **缺 `timezone` 依赖**。`zonedSchedule` 需要 `timezone` 包 + + `tz.initializeTimeZones()` + 一个 `tz.Location`;本机时区名还需要 `flutter_timezone`。 + `pubspec.yaml` 里两个都没有。不加这两个依赖,这条链路一行都写不了。 +2. **通知 ID 要分命名空间**。仓库里已经有**两个各自 new 出来的 `FlutterLocalNotificationsPlugin` 实例** + ([background_session_notifier.dart](../apps/client/lib/core/utils/background_session_notifier.dart)、 + [meeting_record_controller.dart](../apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart)), + 各自管自己的通知 id。提醒排期再来一套,**id 撞车会互相覆盖**, + 而「先取消旧排期」若图省事写成 `cancelAll()`,会顺手干掉正在录音的前台通知, + 表现为录音悄悄失去前台身份、被系统回收。**提醒占一段固定 id 区间,只按 id 逐条 cancel。** +3. **通道(channel)单独建一个**。录音提示是常驻低优先级通道,提醒需要能弹能响, + 共用一个通道会让提醒被降级成静默。 + ### 5.2 每日登录播报(需求 2)→ **客户端** ``` @@ -249,9 +477,24 @@ App 启动/切前台 **`stop()` 必须真的能打断**——`TtsService` 接口已有该方法,且 `speakStream` 是队列式播放,停止时要连带清队列,否则会出现「关了还在念下一句」。 +⚠️ **启动路径上的弹窗已经有两个了(v0.2 新增)**,本弹窗是第三个,必须排在最后: + +| 顺序 | 弹窗 | 位置 | +|---|---|---| +| 1 | 华为/荣耀「每次启动重新确认隐私政策」 | `login_controller._initDeviceInfo()` | +| 2 | 强制更新弹窗(不可关闭,会把 splash 卡住) | splash `_performVersionCheckAsync` | +| 3 | **每日事项弹窗** ← 新增 | 应在 token 校验通过、进入主页之后 | + +排在强更前面会出现「强更窗盖住播报窗、TTS 在背后念」。 +**判据:必须等 `_validateTokenAsync` 通过**——未登录时 `memory_today` 拿不到数据。 + +⚠️ **TTS 会和通话翻译抢音频通道**。用户在通话翻译进行中切回前台时不要触发播报, +先看 `TranslationController` 的活动状态。这条不做,会在通话里突然插一段「今天有 3 件事」。 + ### 5.3 周报/月报生成(需求 4/5)→ **服务端 cron + 异步队列** -照搬 [allhelp/summary.go](../apps/services/modules/allhelp/summary.go) 的成熟模式: +照搬 [echomeet/tasks.go](../apps/services/modules/echomeet/tasks.go) 的成熟模式 +(`home` 服务已在 `InitSys` 里 `cron.OnInit`,直接可用): ``` lego cron(在 memory 模块 Start 中注册) @@ -260,16 +503,37 @@ lego cron(在 memory 模块 Start 中注册) ↓ 逐个 uid 建 memory_report(state=0) 并 LPush Redis 队列 N 个 worker BRPop ↓ SQL 算出 stat_json(确定性统计,不过 LLM) - ↓ LLM 只做一件事:把 stat_json 组织成适合朗读的话 → summary - ↓ 回写 state=2 + ↓ providers.PickLLM("") → Summarizer.Chat:只做一件事,把 stat_json 组织成适合朗读的话 + ↓ 回写 summary + llm_svc_id + state=2 ``` 要点: - **cron 只在一个实例上跑**。`home` 是集群服务,多副本会重复生成。用 Redis `SET NX EX` 抢锁,key 带日期(`memory:report:lock:week:2026-W35`)。 + ⚠️ **锁 key 必须过 `redissys.RKey()` 加应用前缀**(`REDIS_KEY_PREFIX`),队列 key 同理。 + 阿龙测试机上多个应用**共用一个 redis:7 实例**,锁 key 不带前缀会让 A 应用抢到的锁挡住 B 应用, + 表现是「某个应用整周没有报告、且不报任何错」。这比队列串号更隐蔽——队列串号会串出脏数据, + 锁串号只是安静地什么都不做。 - 时间选 **00:30 而非 00:00**——避开整点其他定时任务,也给跨日数据落库留缓冲。 +- ⚠️ **cron 跑的是容器时区,恒为 `Asia/Shanghai`**(v0.2 查证:`Dockerfile.voitrans` 里 + `cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime`,`docker-compose.yml` 又设了 + `TZ: Asia/Shanghai`;`lego/sys/cron` 本身**没有任何时区配置项**,`home.yaml` 里也没有 `cron:` 段)。 + 于是「周一 00:30」对**所有区域、所有用户**都是北京时间—— + **纽约用户的周报会在他本地周日 12:30 生成,漏掉整个周日下午和晚上**, + 而 `happen_date` 又是按用户本地日期存的,两个口径直接对不上。 + 一期取舍:**接受这个偏差并写进说明**(周报口径 = UTC+8 的周),或者按用户 `tz` 分批触发 + (cron 每小时跑一次,只处理「本地时间刚过周一 00:30」的那批用户)。后者更对但复杂度高一档。 +- ⚠️ **「上周」的起始日因地区而异**:`period_key` 用 ISO 周(周一起)是欧洲/中国口径, + 美国、日本部分场景是周日起。41 语言全球发行,这个口径要么明确统一成 ISO 并在 UI 上说明, + 要么跟 `tz` 一起按用户地区算。**不要留着不定义**。 - 用户量大时按 uid 分片入队,不要一次性扫全表。 - **只给「上周期有数据的用户」生成**,空报告没有意义还浪费 LLM 调用。 +- **LLM 走 `providers.PickLLM` 而不是 `doubao.Chat`**(v0.2 修正)。`allhelp/summary.go` 直接 + `import "yunyan/sys/doubao"` 是历史债:绕过了后台编排,海外应用会去调国内豆包, + 后台换模型也换不掉它。新模块不要再复制这个写法。 - 客户端下次打开时 `memory_getreport` 拉取 → 弹窗播报 → 用户确认 → `memory_confirmreport`。**不做「凌晨推送叫醒用户」**。 +- ⚠️ **积压要有上限**:用户一个月不开 App 会攒下 4 份周报 + 1 份月报。 + `memory_getreport` 不传 period 时**只返回最近一份未确认的**,其余在拾忆页留个入口自己翻, + 不要连弹 5 次。 ### 5.4 为什么不用现有 `timer` 服务 @@ -279,20 +543,26 @@ lego cron(在 memory 模块 Start 中注册) ## 六、客户端设计 -### 6.1 页面改名与结构 +### 6.1 页面结构 -`TodoTab` → `MemoryTab`(「记忆」)。i18n key 全量替换,41 个语言文件都要动。 +tab 文案已是「拾忆」(`tabTodo`,但 41 个语言只有 3 个是对的,见 §2.3)。 +底层类名仍是 `TodoTab` / `TodoScopeList` / `CalendarEventCategory`—— +**类名可以不动**(改名只带来 diff 不带来价值),但新加的东西一律用 memory 命名。 ``` -记忆页 +拾忆页 ├── 日历卡(CalendarCard,复用现有几何/手势逻辑) ├── 分类筛选条(全部 / 待办 / 闹钟 / 灵感 / 花销) ← 新增 -└── 事项列表(MemoryScopeList,由 TodoScopeList 改造) +└── 事项列表(TodoScopeList,已有) ├── 日/周/月范围切换(已有) - ├── 头部汇总:3 件待办 · 2 条灵感 · 花销 ¥248 ← 扩展 - └── 分类着色的卡片 + ├── 头部汇总:3 件待办 · 2 条灵感 · 花销 ¥248 ← 由已有的「N 笔花销 / 合计」扩展 + └── 分类着色的卡片(已有 todo / expense 两种,补 alarm / idea) ``` +⚠️ **`CalendarEventCategory` 现在只有 `{todo, expense}`**,且 `todo_scope_list.dart` 里是 +`e.category == expense ? _expenseCard(e) : _todoCard(e)` 的**二选一分支**。 +加 `alarm`/`idea` 要把它改成按分类查表,并对**未知分类兜底为 todo 卡片**(§3.3)。 + ### 6.2 数据层 新增 `MemoryService`(写法参照 `AssistantDirectiveService`): @@ -315,14 +585,35 @@ amount = item.amount // expense 分类 ``` **同时删除 `mockCalendarEvents()`**——真实数据上线后,演示数据混在里面会让人以为系统出错。 +删的时候把 §2.4(3) 那约 40 个 `demo*` i18n key 一并清掉。 + +⚠️ 现在的重复闹钟展开逻辑(`_expandReminder`,窗口「今天前 7 天 ~ 后 60 天」) +在换源后仍然需要——服务端返回的是记忆项原始行,日历要按天摊开。这段逻辑直接留用, +只是输入从 `AssistantReminder` 换成 `MemoryItem`。 ### 6.3 助手提醒的迁移 `AssistantDirectiveService` 保留(它是**指令流水日志**,有独立价值:排查、统计百炼下发了哪些没实现的指令),但: -- `_handleSetClock` 不再写本地 `reminders`,改为调 `memory_add`(`category=alarm`, `source=assistant`) +- `_handleSetClock` 不再只写本地 `reminders`,改为调 `memory_add`(`category=alarm`, `source=assistant`, + `client_key=指令 id`) - 本地 `reminders` 列表在迁移期保留双写,一个版本后移除 -- 老版本本地已有的提醒,首次启动时**一次性上传**并打标记,避免重复 +- 老版本本地已有的提醒,首次启动时**一次性上传**并打标记,避免重复。 + 上传走同一个 `client_key` 幂等键(用本地 reminder 的 id), + 这样即使标记丢了、重装了,重复上传也不会插重 + +### 6.4 花销录入与小票(v0.2 补) + +上传链路已经就绪,不需要新建:客户端 `user_getuploadurl` 拿预签名 PUT URL → dio 直传 OSS。 +要做的只有两件: + +1. **`uploadScenes` 白名单加一项**(如 `MemoryImages`),在 + [api_getuploadurl.go](../apps/services/modules/user/api_getuploadurl.go)。 + ⚠️ 少加这一项的表现是「那条上传链路整条报『不支持的上传用途』」,且只在真机跑到那个场景才发现。 +2. **小票识别复用 echomeet 的多模态通道**——`ChatMessage.Images` 已经打通, + 各 Summarizer(豆包/通义/Azure 用 OpenAI 风格 `image_url` 直传,Gemini 内部转 base64)都支持。 + 一张小票 → LLM 出 `{title, amount, date}` → 落 `category=expense`。 + ⚠️ 金额必须**回显给用户确认再落库**,不要静默入账:LLM 认错一位小数,月报就废了。 --- @@ -360,15 +651,38 @@ amount = item.amount // expense 分类 输入:2026-08-31 …已生成的纪要… prompt:代码内置(不放 echomeet_template) +选路:复用本条记录已定的 rec.LlmSvcId → providers.GetSummarizer(rec.LlmSvcId) 输出:JSON 数组 → 逐条写 memory_item(category=todo, source=meeting, source_id=记录id) ``` +⚠️ **选路口径已变(v0.2 修正)**:v0.1 写的 `GetSummarizer(svcType, language)` 和四套固定服务商 +(ByteDance/Ali/Google/Microsoft)**已经不存在了**——`ResolveServiceType` 在仓库里一处都搜不到。 +现在是后台编排:记录上存 `asr_svc_id` / `mt_svc_id` / `llm_svc_id` 三个选路结果, +总结走 `providers.PickLLM(clientSvcId)` 拿 svcId、`GetSummarizer(svcId)` 拿实现。 +抽取这一路**直接复用 `rec.LlmSvcId`**,与本条记录的总结同模型,不要另选—— +同一条会议两路用不同模型,出问题时无法归因。 + **为什么 prompt 放代码不放模板**:① 模板有 1189 条,每条是对应语言的提示词,改不动也改不齐;② 各模板 `outline` 职责本就不同,塞进去会互相干扰;③ 输出要被程序 parse 成 JSON,格式严格性不能交给后台可随手编辑的文本;④ 抽取输入是已提炼的 `summary`(约千字),成本只是前两路(转写全文可达上万字)的零头,省不出什么。 **为什么必须注入 ``**:现在 `AIProcess` 拼给模型的只有 `` + ``,没有日期。模型不知道今天几号,让它把「下周三前」换算成具体日期就是瞎猜。 +⚠️ **`` 取哪个字段(v0.2 补)**:`DBEchoMeetRecord` 上**没有「会议实际发生日期」这个字段**。 +可用的是 `creationtime`(记录创建时间)和 `starttime`(处理开始时间,不是会议时间)。 +只能用 `creationtime`,并接受它的语义是「上传当天」——用户补录三天前的录音时会算错。 +记录上也没有客户端时区,服务端只能按固定时区(同 analyze,`Asia/Shanghai`)折算成日期。 +**这两点都是已知偏差,不要假装它精确**;真要做准得给 `DBEchoMeetRecord` 加 +`meet_date` + `tz` 两列由客户端上报,那是另一个改动。 + **服务端必须二次校验 `due_date`**:解析不出合法日期、或早于会议当天的,一律丢弃日期只保留 `due_raw`,`happen_date` 落会议当天并置 `date_certain=0`。参考 `SET_clock` 已踩过的坑——实测模型把「下午 3:30」的 time 填成 `03:30`(见 [disambiguateAfternoon](../apps/client/lib/data/models/assistant_directive.dart) 的注释)。 +⚠️ **`date_certain=0` 会把会议当天堆爆(v0.2 补)**:一次会抽出 8 条待办、全部没写截止时间, +就全落在会议当天,日历那一格直接不可读。拾忆页对 `date_certain=0` 的项要 +**单独归到「待定日期」分组**、不与当天确定事项混排。 + +⚠️ **抽取输出的语言**:`summary` 是按用户所选模板语言生成的(41 语言), +抽取 prompt 内置在代码里是中文,但**要显式要求「title/owner 保持与输入相同的语言」**, +否则日语会议的待办会变成中文,混在日语纪要里。 + **失败隔离**:抽取失败只记日志,**不改 `rec.State`**。用户照样看得到纪要,只是没有自动待办。 ### 7.3 重新总结的幂等 @@ -405,12 +719,24 @@ delete from memory_item - **「取消闹钟」和「记一下待办」目前不下发任何指令**,模型却回「已经帮你取消了」——后台没配对应工具。这是需求 1「灵感/花销」能否语音录入的前提。 - `SEND_message` / `MAKE_A_PHONE_CALL_phone_call` 本版已无对应能力,**要去百炼后台把这两个工具摘掉**,否则模型会继续下发无法执行的指令。 +- ⚠️ 还需要一个 **`CANCEL_clock`**:「取消闹钟」现在是纯幻觉。记忆项上了服务端之后, + 取消若只删本地、服务端还留着,下次同步又会长回来——比现在更糟。 解析侧三个已知坑(照现有实现处理,勿改):`arguments` 是字符串形式 JSON 要二次 parse;参数值是中文自然语言(`repeat="周一"`)不是枚举;`finish_reason=command_calls` 是纯指令帧,text 为空且不会有 `RespondingStarted/Ended`,照 text 建气泡会留下永远填不上的空泡。 ### 8.2 MCP 工具(服务端)——用户问,助手查 -服务端 MCP 已经跑在 `/mcp`(streamable HTTP, stateless)与 `/sse`,带 `authFromRequest` 鉴权,工具分 `GLOBAL`/`CHINA`/`OVERSEAS` 三组。 +⚠️⚠️ **前置:MCP 目前没有任何鉴权,本节的工具在补上鉴权之前不得上线。** 见 §2.4(1)。 + +服务端 MCP 跑在 `/mcp`(streamable HTTP, stateless)与 `/sse`,工具分 `GLOBAL`/`CHINA`/`OVERSEAS` 三组。 +`authFromRequest` 把 `Authorization` 头放进 context 但**无人读取、无校验**, +所有用户工具的 `uid` 都是模型填的参数。 + +**必须先做的改造**(三期 #16 的前置): + +1. 从 context 里的 `Authorization` 解出会话 uid(复用 gateway 那套 JWT 校验); +2. 用户类工具**忽略参数里的 uid**,一律用会话 uid;解不出会话 uid 直接返回错误,不降级; +3. 存量三个工具(`get_user_tasks`/`allhelp_task`/`cancel_user_task`)同步改。 **新增工具(`ToolGroup_GLOBAL`)**: @@ -419,7 +745,7 @@ delete from memory_item | `get_memory_items` | 按日期范围/分类查记忆项 | 「我明天有什么事」「上周记了哪些灵感」 | | `get_memory_stats` | 统计(花销合计、完成率) | 「这个月花了多少钱」「上周有几件事没做完」 | | `get_memory_report` | 取周报/月报 | 「上周总结说了什么」 | -| `add_memory_item` | 新增记忆项 | 「帮我记一下:明天下午three点开会」 | +| `add_memory_item` | 新增记忆项 | 「帮我记一下:明天下午三点开会」 | | `complete_memory_item` | 标记完成 | 「把跟进 A 客户那件事标记完成」 | 现有 `get_user_tasks` / `allhelp_task` / `cancel_user_task` 三个工具**改为读写 `memory_item`**(见 §9.1),保持工具名不变,避免百炼后台重新配置。 @@ -454,14 +780,30 @@ delete from memory_item **不要直接删 `DBTask` 表**——EMAI 侧可能有历史会话引用,且迁移脚本需要回滚余地。 -### 9.2 客户端老版本 +### 9.2 账号注销必须登记新表(v0.2 新增,**别漏**) + +[model_user.go](../apps/services/modules/user/model_user.go) 的 `cancel()` 是硬编码表清单, +加两行: + +```go +mysql.Delete(comm.TableMemoryItem, "uid=?", uid) +mysql.Delete(comm.TableMemoryReport, "uid=?", uid) +``` + +同时在 `comm/const.go` 登记 `TableMemoryItem` / `TableMemoryReport` 两个常量。 +**漏了不报错**,只是用户注销后花销与灵感留在库里——发现时已经是合规问题。 + +### 9.3 客户端老版本 老版本客户端不知道 `memory_*` 接口,继续用本地 `GetStorage`。**服务端不做任何兼容适配**——新功能只对新版本开放,老版本行为不变(本地闹钟照常)。 -### 9.3 不向后兼容的点 +### 9.4 不向后兼容的点 -- 记忆页改名后,i18n key 变更需 41 个语言文件同步,**漏一个语言就显示 key 名** +- 拾忆页新增分类后,老客户端拿到 `alarm`/`idea` 会落进未知分支——**这就是 §3.3 要求兜底的原因**, + 一期就要把兜底发出去,否则二期加分类会打崩存量包 - `mockCalendarEvents()` 删除后,无数据用户看到的是空态页——需要设计空态文案与引导 +- i18n:`tabTodo` 现状是 38/41 个语言显示 `Remin`(§2.3),本次要补齐; + 新增的分类名、空态文案同样要 41 份 --- @@ -475,14 +817,14 @@ delete from memory_item │ (summary + 会议日期) │ (SET_clock/ADD_todo/…) └──────────┬─────────────────┘ ↓ - ┌──── memory_item ────┐ ← 手动录入(记忆页 +) + ┌──── memory_item ────┐ ← 手动录入(拾忆页 +)/小票拍照 │ todo/alarm/idea/ │ │ expense… │ └──┬────────┬──────┬───┘ │ │ │ ┌────┘ │ └────────────┐ ↓ ↓ ↓ - 记忆页日历 提醒链路 周期复盘 + 拾忆页日历 提醒链路 周期复盘 (分类展示) ├ 每日登录弹窗+播报 ├ 周一 00:30 cron ├ 提前5分钟本地通知 ├ 1号 00:30 cron └ TTS 播报(可中断) ├ SQL 算 stat_json @@ -490,45 +832,60 @@ delete from memory_item └ 播报 → 用户确认 ↑ │ └────── MCP 工具 ←── EMAI 提问 ─────┘ + (需先补鉴权) ``` --- ## 十一、分期实施 +### 零期:前置修缮(不做完,后面几期会返工) + +| # | 任务 | 端 | +|---|---|---| +| 0.1 | `tabTodo` 补齐 41 语言、改掉 `Remin`(单双引号都要查) | 客户端 | +| 0.2 | `pubspec` 引入 `timezone` + `flutter_timezone` | 客户端 | +| 0.3 | 通知 id 区间与专用 channel 规划(不新建第三个 plugin 实例) | 客户端 | +| 0.4 | `comm/const.go` 登记两张表常量 + `cancel()` 加两行(连同补上 `TableUserIdVerify`) | 后端 | +| 0.5 | `i18n_audit.py` 加「缺失 key」检查并接进 CI(当前 40 个语言里 39 个在漏) | 工具 | +| 0.6 | 定 `USE_EXACT_ALARM` 申不申请 + 与产品对齐「提醒送达率做不到 100%」 | 产品 | +| 0.7 | 定多币种口径(建议一期单币种)与「上周」的起始日 | 产品 | + ### 一期:打通主链路(只读为主) | # | 任务 | 端 | |---|---|---| -| 1 | `memory_item` 表 + `memory` 模块骨架 | 后端 | +| 1 | `memory_item` 表 + `memory` 模块骨架(挂进 `home/main.go`) | 后端 | | 2 | `memory_list` / `memory_today` / `memory_add` / `memory_update` / `memory_complete` | 后端 | -| 3 | 会议抽取第三路 + `` 注入 + 日期校验 | 后端 | +| 3 | 会议抽取第三路 + `` 注入 + 日期校验 + 复用 `rec.LlmSvcId` | 后端 | | 4 | `General-Meeting` zh-CN 模板第四节改造 | 后台配置 | -| 5 | `MemoryService` + `MemoryItem` 模型 | 客户端 | -| 6 | 记忆页改名、分类筛选、`_rebuildEvents` 换源、删 mock | 客户端 | -| 7 | 助手 `SET_clock` 改写服务端 | 客户端 | +| 5 | `MemoryService` + `MemoryItem` 模型(含 `client_key` 幂等) | 客户端 | +| 6 | `CalendarEventCategory` 扩到 4 类 + **未知分类兜底** + 分类筛选 + `_rebuildEvents` 换源 + 删 mock 与 40 个 demo key | 客户端 | +| 7 | 助手 `SET_clock` 改写服务端(带 `client_key`) | 客户端 | -**验收**:开一次会 → 纪要生成 → 待办自动出现在记忆页日历上,标注来源会议与负责人。 +**验收**:开一次会 → 纪要生成 → 待办自动出现在拾忆页日历上,标注来源会议与负责人; +无截止时间的落在「待定日期」分组而不是堆在会议当天。 ### 二期:提醒与播报 | # | 任务 | 端 | |---|---|---| -| 8 | `memory_upcoming` + `remind_at` 计算 | 后端 | -| 9 | 本地通知排期(含权限降级) | 客户端 | -| 10 | 每日登录弹窗 + TTS 播报 + 中断 | 客户端 | -| 11 | 百炼后台补 `ADD_todo`/`ADD_idea`/`ADD_expense`,摘掉电话/短信工具 | 配置 | -| 12 | 花销分类完整支持(录入 + 统计) | 两端 | +| 8 | `memory_upcoming` + 重复项按 `tz` 展开成绝对时刻 | 后端 | +| 9 | 本地通知排期(含权限降级、id 命名空间、重复项按天合并) | 客户端 | +| 10 | 每日登录弹窗 + TTS 播报 + 中断(排在强更/隐私弹窗之后;通话中不播) | 客户端 | +| 11 | 百炼后台补 `ADD_todo`/`ADD_idea`/`ADD_expense`/`CANCEL_clock`,摘掉电话/短信工具 | 配置 | +| 12 | 花销分类完整支持(录入 + 统计 + `MemoryImages` scene + 小票识别回显确认) | 两端 | ### 三期:复盘与问答 | # | 任务 | 端 | |---|---|---| -| 13 | `memory_report` 表 + cron + 分布式锁 + 异步 worker | 后端 | -| 14 | `stat_json` SQL 统计 + LLM 播报文案生成 | 后端 | -| 15 | 报告弹窗 + 播报 + 确认 | 客户端 | -| 16 | 5 个新 MCP 工具 + 旧工具改读新表 | 后端 | +| 13 | `memory_report` 表 + cron + 分布式锁 + 异步 worker(选路走 `PickLLM`) | 后端 | +| 14 | `stat_json` SQL 统计(明细截断)+ LLM 播报文案生成 | 后端 | +| 15 | 报告弹窗 + 播报 + 确认(只弹最近一份未确认的) | 客户端 | +| 16 | **MCP 鉴权改造**(前置)→ 5 个新工具 + 旧工具改读新表 | 后端 | | 17 | `DBTask` 存量迁移脚本 | 后端 | +| 18 | admin 后台排障页:按 uid 查记忆项、看报告生成状态与失败原因 | 后台 | --- @@ -538,22 +895,59 @@ delete from memory_item | 级别 | 风险 | 应对 | |---|---|---| +| 高 | **MCP 无鉴权,uid 由模型自报**——挂上记忆工具等于开放他人生活流水。且 MCP 走独立进程独立端口(7300),**不经过网关那套 token 校验**,又必须对百炼公网可达 | 三期 #16 前置改造,解出会话 uid 并忽略参数 uid | +| 高 | **`memory` 模块 Init 返回 error 会打挂整站**——五服务同容器,entrypoint 见任一 pid 退出就全杀 | 建表失败只记日志;别照抄 `allhelp/model.go` 的命名返回值写法(§4) | +| 高 | **需求 3 在国内安卓上无法保证送达**:通知权限 48 小时合规冷却 + Android 14 精确闹钟默认不授予 + 国内 ROM 杀后台排期 | 三条都不是代码能绕的,需与产品对齐预期(§5.1) | +| 高 | **i18n 管道本身在漏**:40 个语言只有 1 个不缺 key,其余缺 233~388 个;审计工具结构上不检查缺失 key | 先给 `i18n_audit.py` 加缺失检查并接 CI,否则新增 30~50 个 key 会同样漏掉大半(§2.3) | | 高 | **相对时间解析不准**(「下周三前」→ 具体日期) | 注入会议日期 + 服务端二次校验,宁可留空 | | 高 | **重新总结冲掉用户修改** | `user_edited` + `gen_round` 双字段 | | 高 | **cron 多副本重复生成报告** | Redis `SET NX EX` 分布式锁 | -| 中 | 本地通知权限被拒 / 平台限制 | 降级为打开 App 才提醒,不阻塞 | -| 中 | LLM 算错金额 | 统计全部走 SQL,LLM 只组织文案 | +| 高 | **注销漏删新表** | §9.2,与建表同一个 PR 提交 | +| 中 | 端侧指令重试导致重复插入 | `client_key` 幂等键 + 唯一索引 | +| 中 | 老客户端遇到新分类崩页 | 一期就发未知分类兜底 | +| 中 | 本地通知权限被拒 / 平台限制 / iOS 64 条上限 | 降级为打开 App 才提醒;重复项按天合并 | +| 中 | `cancelAll()` 误杀录音前台通知 | 提醒占固定 id 区间,逐条 cancel | +| 中 | LLM 算错金额 | 统计全部走 SQL,LLM 只组织文案;小票识别结果必须回显确认 | | 中 | 多一路 LLM 调用增加成本 | 输入用 `summary` 而非全文;失败不重试 | -| 中 | 41 个语言文件改名遗漏 | 用 `tools/i18n_audit.py` 校验 | +| 中 | 启动弹窗打架(隐私 / 强更 / 每日播报) | 固定顺序,每日弹窗排最后且在登录态之后 | +| 中 | **周报的「周一凌晨」是容器固定的北京时间**,纽约用户的周报在他本地周日中午就生成,漏掉周日下半天 | 接受并写进说明,或按 `tz` 分批触发(§5.3) | +| 中 | **「上周」起始日因地区而异**(ISO 周一起 vs 美国周日起) | 明确统一成 ISO 并在 UI 说明,不要留着不定义 | +| 中 | **多币种花销无法合计**,且客户端硬编码 `¥` | 一期只支持单一货币;月报多币种分行列出,不做汇率换算(§3.3) | +| 中 | **Redis 锁 key 漏加应用前缀**——测试机多应用共用一个 Redis,会安静地让某应用整周没报告 | 锁与队列 key 都过 `redissys.RKey()` | +| 中 | proto 表达不了 DECIMAL/DATE/DATETIME/null,照 DDL 直译会写不出来或写歪 | 按 §3.2 的映射表落地;金额一律 `int64` 分 | +| 中 | 41 个语言文件改名遗漏(现状已有 38 个是坏的) | 单双引号都查;**不要依赖 `i18n_audit.py`**,它抓不到(§2.3) | +| 中 | **GetStorage 全量缓存会越撑越慢**——它是把整个 box 读进内存的 JSON 文件,记忆项一年 3000+ 条 | `MemoryService` 只缓存最近 N 天(建议 90 天),不做全量镜像(§6.2) | +| 中 | **`memory_list` 没有分页参数**——月视图尚可,但「数据导出拉全量」会撑爆 | 接口定义时就把 `page`/`size` 留出来,不要等要用了再加字段(§4) | +| 中 | **一个埋点都没有**,而项目里每个功能都有;`analyze.writeEvent` 是固定 switch,加维度要同时改 console 看板列 | 先定要不要埋(待确认 10);要埋则注意 `ANALYZE_APP_NAME` 必须等于 `app_registry.name`,否则快照被静默拒收、看板恒为 0 且不报错 | | 低 | 统一表半数字段为空 | 数据量级小,可接受 | ### 待确认 -1. **花销录入方式**——纯语音(「打车花了 30」)?手动记账页?是否要拍照识别小票?当前设计只保证数据结构支持,录入 UI 未定。 -2. **灵感与「聊天总结」的关系**——`allhelp` 已有 `DBChatSummary`(每日聊天 AI 总结)。灵感是用户主动说「记一下」,聊天总结是自动提炼,两者要不要合并?建议**先分开**,灵感是显式的、聊天总结是隐式的。 -3. **报告确认的含义**——只是「我看过了」,还是要允许用户在确认时批量处理未完成事项(顺延/放弃)?影响接口设计。 -4. **多设备播报冲突**——同一账号在手机和耳机同时在线,谁播报?建议只在当前活跃前台设备播。 -5. **阿龙测试机 NATS 容器 unhealthy**(已 3 周)——配置变更广播依赖它,且 `stats_global_day` 数据停在 20260731。上线前需修复,否则后台改模板要等 10 分钟 cron 兜底。 +1. **周报/月报的成本与额度**——每人每周一次 LLM 调用,用户量上来就是纯支出,而项目里 + 翻译/会议/AI 对话都各有额度桶(`Tradeintegral`/`Meetintegral`/`Aichatintegral`)。 + 报告生成要不要挂额度或 VIP 门槛?目前设计是**所有人免费生成**,需要确认这是有意的。 +2. **拾忆页要不要走实名闸门**——现在所有功能卡片都按 `IdVerifyGuard.blocked()` 拦。 + 拾忆是底部 tab,整页拦不合适;但语音录入走 EMAI,EMAI 已被拦,等于游客只能手动录。 + 要确认这个不对称是可接受的。 +3. **数据导出**——花销与灵感是用户自己积累的资产,没有导出入口等于数据绑架, + 合规角度也该有。一期不做,但接口设计上 `memory_list` 要能不分页拉全量。 +4. **花销录入方式**——纯语音(「打车花了 30」)?手动记账页?拍小票? + (小票通道 §6.4 已确认技术上现成,剩下是产品决策。) +5. **灵感与「聊天总结」的关系**——`allhelp` 已有 `DBChatSummary`(每日聊天 AI 总结)。灵感是用户主动说「记一下」,聊天总结是自动提炼,两者要不要合并?建议**先分开**,灵感是显式的、聊天总结是隐式的。 +6. **报告确认的含义**——只是「我看过了」,还是要允许用户在确认时批量处理未完成事项(顺延/放弃)?影响接口设计。 +7. **多设备播报冲突**——同一账号在手机和耳机同时在线,谁播报?建议只在当前活跃前台设备播。 +8. **`allhelp/summary.go` 的 doubao 硬编码要不要一并改**——本模块不复制这个写法(§5.3), + 但存量那处仍然绕过编排。是否借这次一起收拾? +9. **`USE_EXACT_ALARM` 要不要申请**——拾忆算不算「核心功能是闹钟/日历」, + 影响 Google Play 提交材料,也决定「提前 5 分钟」是精确还是「5~20 分钟之间」。产品先定。 +10. **记忆中心要不要埋点**——`analyze` 模块的 `writeEvent` 是一张固定的 `switch` + (登录/注册/设备激活/绑定/订单/翻译/会议/AI对话/资源发放),加统计维度要改这个 switch + **并且**改 console 看板的列。目前设计里一个埋点都没有,而项目里每个功能都有。 + 如果要加,注意 `ANALYZE_APP_NAME` 必须与 `app_registry.name` 相等,否则快照被 console + 静默拒收、看板恒为 0 且不报错。**这一条同时列在风险表里**——「不埋」是可以的决策, + 但必须是被决策过的,而不是忘了。 +11. **阿龙测试机 NATS 容器 unhealthy**(v0.1 记为已 3 周)——配置变更广播依赖它。 + **本次未复核,上线前需重新确认**;不通的话后台改模板要等 10 分钟 cron 兜底。 --- @@ -561,10 +955,11 @@ delete from memory_item | 分类 | 色值 | 说明 | |---|---|---| -| `todo` 待办 | `#5B8DEF` 蓝 | | -| `alarm` 闹钟 | `#22C1A6` 青 | 沿用现有助手提醒色 | +| `todo` 待办 | `#5B8DEF` 蓝 | 与现有 mock 的 todo 卡片同色 | +| `alarm` 闹钟 | `#22C1A6` 青 | 沿用现有助手提醒色(`_expandReminder` 里已是这个值) | | `idea` 灵感 | `#F59E0B` 橙 | | | `expense` 花销 | `#EC4899` 粉 | | +| 未知分类 | 中性灰 | 兜底,见 §3.3 | 深色模式下需各自校验对比度,不要直接沿用。 @@ -573,12 +968,18 @@ delete from memory_item | 用途 | 路径 | |---|---| | 会议纪要生成 | `apps/services/modules/echomeet/tasks.go` (`AIProcess`) | +| **LLM 选路(编排)** | `apps/services/modules/echomeet/providers.go` (`PickLLM` / `GetSummarizer`) | | 会议模板读写 | `apps/services/modules/console/api_config.go` | | 已有任务体系 | `apps/services/modules/allhelp/` | -| 异步总结范例 | `apps/services/modules/allhelp/summary.go` | -| MCP 工具 | `apps/services/modules/mcp/tool_*.go` | +| 异步队列范例 | `apps/services/modules/echomeet/tasks.go`(推荐)、`apps/services/modules/allhelp/summary.go`(选路是反例) | +| **账号注销清表** | `apps/services/modules/user/model_user.go` (`cancel`) | +| 上传预签名 URL / scene 白名单 | `apps/services/modules/user/api_getuploadurl.go` | +| MCP 工具与(缺失的)鉴权 | `apps/services/modules/mcp/module.go`、`tool_*.go` | +| 表名常量 | `apps/services/comm/const.go` | | 助手指令解析 | `apps/client/lib/data/models/assistant_directive.dart` | | 助手指令分发 | `apps/client/lib/data/services/assistant_directive_service.dart` | | 日历控制器 | `apps/client/lib/modules/home/controllers/calendar_controller.dart` | -| 待办列表 | `apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart` | +| **日历事件模型 / mock** | `apps/client/lib/modules/home/views/widgets/calendar/calendar_event.dart` | +| 拾忆列表与花销卡片 | `apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart` | +| 本地通知(已有两处实例) | `apps/client/lib/core/utils/background_session_notifier.dart`、`apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart` | | TTS 接口 | `apps/client/lib/data/services/tts_service.dart` |