# 记忆中心(拾忆)设计与开发文档 > 版本:v0.3  日期:2026-09-05(v0.2 复核 09-04,v0.1 草案 08-31) > 范围:`apps/services`(后端)、`apps/client`(Flutter)、`apps/admin`(后台)、百炼 Agent 配置 > 状态:**代码已落地**(一~三期的可自动化部分),剩余为需要人工决策/外部系统操作的项,见 §零 --- ## 零、实施状态(v0.3,2026-09-05) 一晚上把不需要决策的部分全部落地了。`go build` / `go vet` / `dart analyze` / `flutter build apk --debug` 全通过,新增 40 个测试。 ### 已完成 | 分期 | 项 | 落点 | |---|---|---| | 零期 | `tabTodo` 补齐 41 语言(此前 38 个是坏的) | `core/translations/language/*.dart` | | 零期 | `i18n_audit.py` 补「缺失 key」检查 | 实测报出 7243 处历史缺失 | | 零期 | 引入 `timezone` + `flutter_timezone` | `pubspec.yaml` | | 零期 | 表常量 + 注销清理清单(含补上遗漏的 `useridverify`) | `comm/const.go`、`user/model_user.go` | | 一期 | `memory_item` / `memory_report` 表 + `memory` 模块 + 11 个接口 | `modules/memory/`、`proto/memory/` | | 一期 | 会议纪要 → 待办抽取第三路 | `modules/memory/meeting_extract.go`、`echomeet/tasks.go` | | 一期 | `MemoryService` + `MemoryItem` 模型 | `data/services/`、`data/models/` | | 一期 | 拾忆页换源、分类筛选、删 mock 与 44 个 demo key | `calendar_controller.dart` 等 | | 一期 | **写操作 UI 全套**(编辑层/勾完成/新增/下拉刷新/空态) | `memory_edit_sheet.dart`、`todo_scope_list.dart` | | 一期 | `SET_clock` 双写服务端(幂等键用指令 id) | `assistant_directive_service.dart` | | 二期 | `memory_upcoming` + 重复项按 tz 展开 | `modules/memory/remind.go` | | 二期 | 本地通知排期(id 命名空间/专用通道/权限降级) | `memory_reminder_scheduler.dart` | | 二期 | 每日弹窗 + TTS 播报 + 中断 | `memory_daily_sheet.dart` | | 三期 | `memory_report` + cron + 分布式锁 + worker + 超时补偿 | `modules/memory/report.go` | | 三期 | `stat_json` SQL 统计(明细截断) | `modules/memory/stats.go` | | 三期 | 报告弹窗 + 播报 + 确认 | `memory_report_sheet.dart` | | 三期 | **MCP 会话鉴权** + 3 个只读工具 | `modules/mcp/auth.go`、`tool_memory.go` | | 三期 | 存量 `DBTask` 迁移(幂等、失败不阻断) | `modules/memory/migrate_task.go` | ### 实施中做的三个决策(原文档列为待确认,选了默认值,可推翻) 1. **成本闸门选「活跃度阈值」而非额度**(待确认 1):`report_min_items: 3`, 上周期不足 3 条记录不生成报告。理由见该条——既有额度全是「用户主动发起」语义, cron 主动生成扣用户额度说不通。 2. **多币种一期只做「分行不换算」**(待确认 4 相关):`stat_json.expenses` 按币种 分行,客户端与 MCP 都不做汇率换算。 3. **「上周」固定 ISO 周(周一起)**:`comm.MemoryWeekKey`,不做地区适配。 一个应用里「上周」必须只有一种含义。 ### 仍需人工/外部操作(无法自动化) 见 §十三。 --- ## 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.6 | | 5 | `flutter_local_notifications` 只用于录音提示 | 属实,但已有**两个各自独立的 plugin 实例**,且**缺 `timezone` 依赖** | §5.1 | | 6 | 「代办」页要改名 | zh-CN/HK/TW 已改「拾忆/拾憶」,但**只有 11/41 个语言文件有 `tabTodo` 这个 key** | §2.3 | 新增三条既有缺陷(详见 §2.4):**MCP 完全没有鉴权**、**账号注销漏表**、**i18n 改名没做完**。 **第二轮全局复核(服务端 → 客户端逐层)又推翻/补充了 5 处**: | # | 原设计 | 复核结论 | 章节 | |---|---|---|---| | 7 | 「拾忆页加分类筛选、换源、删 mock」 | 页面是**纯展示页**:无下拉刷新、无新增按钮、卡片无 onTap,**4 个写接口全无调用点**,`user_edited` 机制空转 | §6.3 | | 8 | 每日弹窗「排在启动第三个」 | 不能 await 在 splash——`6a64c3d0` 刚把 `_initMeetingTemplate` 从启动串行链摘出去,方向相反 | §5.2 | | 9 | 「MCP 三个旧工具改读 memory_item」 | MCP 是独立进程、**从不 RpcCall**、只裸查 MySQL,会变成第二份查询实现;且 mcp.yaml **没有 Redis** | §8.2 | | 10 | cron 风险 = 多副本重复 | compose **无 replicas,当前单副本**;真实风险是**漏跑无补偿**,`state=0` 永远不动且不报错 | §5.3 | | 11 | 「报告生成要不要挂额度」 | 挂不了——既有额度全是「用户主动发起」语义,cron 主动生成扣额度不成立 | §12 待确认 1 | --- ## 一、背景与目标 App 的第二个 tab 已由「代办」更名为「**拾忆**」(`tabTodo`),但页面内部还是原样:数据来自 12 条写死的 演示数据(`mockCalendarEvents()`)加上 EMAI 助手落下来的闹钟,全部存在本地 `GetStorage`, 服务端没有任何对应存储。 本次要把它做实——用户一天里发生的各类事情的统一入口,并围绕它形成「录入 → 提醒 → 复盘 → 问答」的闭环。 ### 六项目标需求 | # | 需求 | 关键约束 | |---|---|---| | 1 | 拾忆页展示多类事项:闹钟、灵感记忆、花销、待办,**分类可扩展** | 一天多件事,按分类组织 | | 2 | 每日首次登录,自动弹窗展示当天事项并**语音播报**,播报可随时关闭 | 播报可中断 | | 3 | 待办/闹钟**提前 5 分钟提醒**,语音播报内容 | App 未打开时也要触发 | | 4 | 每周一凌晨生成上周总结:未完成事项、花销合计、灵感汇总,播报后**需用户确认** | 服务端定时生成 | | 5 | 每月 1 号生成上月总结:完成/未完成情况、开销、灵感汇总 | 同上 | | 6 | 上述数据可在 EMAI 助手里随时提问,助手能答 | 走 MCP 工具 | ### 设计原则 1. **统一模型**:一张表承载所有分类,新增分类不改表结构、不加接口。 2. **服务端为真相源**:本地只做缓存与离线兜底,换设备数据要在。 3. **失败隔离**:AI 抽取、总结生成失败不影响主流程,宁可少一条记忆,不可让会议纪要或登录卡住。 4. **不重复造轮子**:`allhelp` 已有任务体系与 MCP 工具,在其上演进。 5. **选路走编排,不写死服务商**(v0.2 新增):本模块所有 LLM 调用一律经 echomeet 的 `providers` 选路, 不再出现第二处硬编码 `sys/doubao`。理由见 §7.2。 --- ## 二、现状盘点(本设计的地基) ### 2.1 已有且可直接复用 | 能力 | 位置 | 说明 | |---|---|---| | **用户任务表 `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 已能查/建/取消任务。⚠️ **无鉴权**,见 §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` | 目前只用于录音后台提示。⚠️ 见 §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 | | 没有分类概念,无法承载灵感/花销(客户端只有 todo/expense 两类,且只是 mock) | 1 | | **花销后端完全空白**,客户端的花销卡片喂的是 mock 数据 | 1/4/5 | | 没有周报/月报表与生成任务 | 4/5 | | 客户端待办数据不落服务端(`synced` 恒为 false) | 全部 | | 会议纪要里的待办是自然语言,无结构化字段 | 1 | | 本地通知从未用于事项提醒;**`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()` 手写了要清哪几张表: ```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 存储分工(迁移讨论已确认) | 数据 | 库 | |---|---| | 用户级业务数据(任务、记忆项、报告) | **MySQL 业务库**(per-app 一份) | | 跨应用共享(会议模板 `echomeet_template`、产品、授权码) | **PostgreSQL 公共库** | | 缓存与队列 | Redis | > 阿龙测试机 8.133.166.29 上 PostgreSQL 是自建 `postgres:16` 容器(库 `starpivot_console`),与第三方无关。 --- ## 三、核心模型:记忆项(MemoryItem) ### 3.1 为什么不直接扩展 `DBTask` `DBTask` 的字段是为「提醒任务」设计的:`task_name` + `trigger_time` + `cron_expr`。要塞进花销金额、灵感正文、会议来源、负责人,只能全丢进 `extra` JSON,那样查询、统计、排序全部失效——月度「一共花了多少钱」将无法用 SQL 聚合。 **决策:新建 `memory_item` 表作为唯一真相源**,`DBTask` 作为历史链路保留并迁移(见 §9.1)。 ### 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 等 client_key VARCHAR(64) DEFAULT '', -- 幂等键(见 3.3) title VARCHAR(200) NOT NULL, -- 主文案(所有分类通用) detail TEXT, -- 补充说明 / 灵感正文 happen_date DATE NOT NULL, -- 归属日期(日历定位用,永不为空) 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_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=不提醒 next_remind_at DATETIME NULL, -- 下一次提醒时刻(重复项由服务端滚动推进,见 3.3) user_edited TINYINT DEFAULT 0, -- 用户改过 → 重新生成时不覆盖 gen_round INT DEFAULT 0, -- 第几轮 AI 生成 extra VARCHAR(2000) DEFAULT '', create_time BIGINT, update_time BIGINT, finish_time BIGINT, INDEX idx_uid_date (uid, happen_date), INDEX idx_uid_cat (uid, category, happen_date), 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` 标注「下周三前」,用户可改期。 **`category` 是字符串不是枚举**——新增分类(如 `health`、`travel`)只加常量、不改表、不迁移。代价是无法在 DB 层约束取值,靠服务端白名单校验。 ⚠️ 相应地,**客户端必须对不认识的 `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` 的项,保留用户勾过改过的。不做这个,用户点一次「重新生成」就会丢掉自己的修改。 ### 3.4 分类与字段的对应关系 | 分类 | 必填 | 主要用到 | 不用 | |---|---|---|---| | `todo` 待办 | title, happen_date | owner, due_raw, state, remind_* | amount, repeat_* | | `alarm` 闹钟 | title, happen_date, happen_time | repeat_rule, repeat_raw, weekday, remind_* | amount, owner | | `idea` 灵感 | title, happen_date | detail | amount, remind_*, repeat_* | | `expense` 花销 | title, happen_date, amount | currency, detail | remind_*, repeat_*, owner | 统一表的代价是每行有约一半字段为空。以本项目的数据量级(单用户日均个位数条目)完全可以接受,换来的是新增分类零成本。 ### 3.5 周期报告表 `memory_report` ```sql CREATE TABLE memory_report ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, uid VARCHAR(50) NOT NULL, period_type VARCHAR(8) NOT NULL, -- week/month period_key VARCHAR(16) NOT NULL, -- 2026-W35 / 2026-08 period_start DATE, period_end DATE, stat_json TEXT, -- 结构化统计(见下) summary TEXT, -- AI 生成的播报文案 llm_svc_id VARCHAR(64), -- 实际用了哪个总结服务(排障用,同 echomeet 的口径) state TINYINT DEFAULT 0, -- 0待生成 1生成中 2完成 3失败 error_msg VARCHAR(500), confirmed TINYINT DEFAULT 0, -- 用户是否已确认(需求4) confirm_time BIGINT, create_time BIGINT, update_time BIGINT, UNIQUE KEY uk_uid_period (uid, period_type, period_key) ); ``` `stat_json` 是**服务端用 SQL 算出来的确定性统计**,不经过 LLM: ```json { "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` 服务装载(`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` 不外传。 ⚠️ **路由已实测确认**:gateway 的 `/api/:param1/:param2` 里 **param1 就是服务名** (`this.module.Service().RpcCall(c, param1, comm.Rpc_GatewayHttpRoute, ...)`), param2 进 `args.MsgName`。所以模块挂进 `home` 之后 `/api/home/memory_list` 直接命中, 不需要在网关加任何映射。客户端在 `api.dart` 里照现有写法加静态方法即可 (全是 `NWMethod.post` + `/api/home/xxx`)。 ⚠️ **ErrorCode 要新开一段**(v0.2 补,文档原先没提)。`errorcode.proto` 的约定是分段编号 + 中文注释: 3001-3006 支付、4001 会议、5001-5005 实名、5101-5103 翻译。 memory 建议占 **5201-** 段(如 `MemoryCategoryInvalid` / `MemoryItemNotFound` / `MemoryReportNotReady`)。别复用 `ReqParameterError` 一把梭——客户端要靠码分流 (比如「报告还没生成好」应该是转圈重试,不是弹错)。 ⚠️ 照抄时注意 `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`(客户端本地日期)+ `tz` | | `memory_add` | 新增 | 整个 item(含 `client_key` 幂等键) | | `memory_update` | 修改(自动置 `user_edited=1`) | id + 变更字段 | | `memory_del` | 删除 | `ids[]` | | `memory_complete` | 标记完成 | `ids[]` | | `memory_upcoming` | 拉未来 N 天待提醒项,**重复项已展开成绝对时刻** | `days`(默认 7)+ `tz` | | `memory_getreport` | 取周报/月报 | `period_type`/`period_key`;不传则取最近一份未确认的 | | `memory_confirmreport` | 用户确认报告(需求 4) | `report_id` | | `memory_stats` | 即时统计(EMAI 问答与页面头部用) | 时间范围 + 分类 | **`memory_today` 单独开一个接口**而不是复用 `memory_list`:登录弹窗是启动路径上的强依赖,要能一次拿全(含当天报告待确认标记),且便于单独做缓存和降级。 ### 4.1 时区 `happen_date` 是**用户本地日期**,不是 UTC 日期。客户端请求时带上 `tz`(IANA 名如 `Asia/Shanghai`) 与本地日期字符串,服务端不做时区推断。跨时区旅行时以客户端当前时区为准——这是刻意选择: 用户「今天」的定义应该跟着人走。 ⚠️ 但**单条记忆项的提醒时刻按它自己的 `tz` 算**(§3.3),不跟着人走。 两个口径不同不是矛盾:「今天有哪些事」是视图问题,「明早 8 点响」是承诺问题。 ⚠️ 服务端容器需能解析 IANA 时区。`home` 的 `main.go` 已 `import _ "time/tzdata"`(为 analyze 统计引入), 本模块直接受益,不用另外处理。 --- ## 五、定时与触发 这是需求 2/3/4/5 的核心,也是现在完全空白的部分。三类触发分别放在不同的地方,**不要试图用一套机制全包**。 ### 5.1 提前 5 分钟提醒(需求 3)→ **客户端本地调度** **必须在客户端做**,因为 App 未打开、甚至无网时也要响。服务端推送做不到(无 APNs/FCM 通道,且国内厂商推送要逐家接)。 ``` 登录/前台恢复 → memory_upcoming(days=7, tz) → flutter_local_notifications.zonedSchedule() 逐条排期 → 到点系统通知(带内容) → 用户点击进 App → TtsService 播报 title + detail ``` 要点: - 每次拉取**先取消旧排期再重排**,避免改期后旧通知还在。 - 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)→ **客户端** ``` App 启动/切前台 → 判断「今天是否已弹过」(本地存 last_daily_popup_date) → 否 → memory_today → 弹窗(列表 + 播报按钮,默认自动开始播报) → TtsService.startspeak → speakStream 逐条 → flushStream → 用户点关闭/返回 → TtsService.stop() ``` 播报文案在客户端本地拼(不调 LLM),格式如: > 今天有 3 件事。上午 9 点,项目周会。下午 2 点,跟进 A 客户,负责人王总。另外有一条灵感记录:做一个会议纪要转待办的功能。 **`stop()` 必须真的能打断**——`TtsService` 接口已有该方法,且 `speakStream` 是队列式播放,停止时要连带清队列,否则会出现「关了还在念下一句」。 ⚠️⚠️ **`memory_today` 绝不能 await 在 splash 里**(v0.2 全局复核新增,方向与项目刚做的优化相反)。 仓库 `6a64c3d0`「把两处网络请求移出启动与设备页的关键路径」刚刚确立了相反的方向: `_performParallelTasks` **名字叫并行、实际完全串行**,冷启动要串等 网络检查 → 版本检查 → `getLoginToken` → `getAppConfig` 四次网络往返 (原本还有第五次 `_initMeetingTemplate`,理由是「启动时没有任何地方要用它」,**已改成 `unawaited`**)。 其中版本检查与 token 校验之间的串行是**刻意的**(强更弹窗必须先出、把 splash 卡住),不能动。 在这条链上再挂一次 `memory_today` 就是把刚摘掉的成本原样加回来,而且这次摘不掉—— 弹窗要用它的数据。**正确做法是不挂**: ``` splash 照常在 token 校验后放行导航 → 进主页 主页挂载后异步拉 memory_today(unawaited)→ 拿到再弹窗播报 ``` 弹窗晚一两秒出现是可接受的;启动多等一次往返不可接受 (同一份取舍在设备页那处已经做过一次:「首次连接的新耳机会晚一两秒才出现在列表里, 这是『页面立刻可见』的必然结果」)。 ⚠️ **顺序上仍然排在第三**,只是「排队」不等于「阻塞启动」: | 顺序 | 弹窗 | 位置 | |---|---|---| | 1 | 华为/荣耀「每次启动重新确认隐私政策」 | `login_controller._initDeviceInfo()` | | 2 | 强制更新弹窗(不可关闭,会把 splash 卡住) | splash `_performVersionCheckAsync` | | 3 | **每日事项弹窗** ← 新增 | 进入主页之后异步拉取、拿到再弹,**不阻塞导航** | 排在强更前面会出现「强更窗盖住播报窗、TTS 在背后念」。 **判据:必须等 `_validateTokenAsync` 通过**——未登录时 `memory_today` 拿不到数据。 ⚠️ **TTS 会和通话翻译抢音频通道**。用户在通话翻译进行中切回前台时不要触发播报, 先看 `TranslationController` 的活动状态。这条不做,会在通话里突然插一段「今天有 3 件事」。 具体一点:原生 `AzureTtsHelper.swift` 按 `currentRecognitionMode` 分支, `phone_call` 那一支**「被改坏过三次」**(见 `80c6ea1b` 的提交说明与代码注释: 外放/听筒/A2DP 路由三次绕回同一个坑,现在按有没有耳机分两支, 且分支口径必须与采集侧 `MicrophoneCapture` 的 voice-processing 条件一致)。 播报走的是 `normal` 支、不碰那段代码,**但它们共用同一个 `AVAudioSession`**—— 通话翻译进行中触发播报,等于在对方刚调好的会话上再设一次。 **判据只有一个:通话翻译活动期间一律不播报**,不要试图去协调两边的会话配置。 ### 5.3 周报/月报生成(需求 4/5)→ **服务端 cron + 异步队列** 照搬 [echomeet/tasks.go](../apps/services/modules/echomeet/tasks.go) 的成熟模式 (`home` 服务已在 `InitSys` 里 `cron.OnInit`,直接可用): ``` lego cron(在 memory 模块 Start 中注册) 周一 00:30 → 扫描上周有数据的 uid 1 号 00:30 → 扫描上月有数据的 uid ↓ 逐个 uid 建 memory_report(state=0) 并 LPush Redis 队列 N 个 worker BRPop ↓ SQL 算出 stat_json(确定性统计,不过 LLM) ↓ 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`。**不做「凌晨推送叫醒用户」**。 ⚠️ **真正的风险是漏跑,不是重复(v0.2 全局复核修正)**: `docker-compose.yml` 里**没有 `replicas` / `deploy:` 段,当前就是单副本**, 所以「多副本重复生成」是前瞻性风险(锁还是要加,部署形态会变),但不是现在会发生的事。 **现在就会发生的是漏跑**:00:30 那次跑到一半容器重启,cron 到点才触发、**不补跑**, 于是 `memory_report` 那行永远停在 `state=0`,用户那一周永远没有报告,**且不报任何错**。 **必须有补偿**,照 echomeet 的思路做客户端驱动的兜底(它的 `PollTranscribe` 就是这么解决 「转写回调丢了」的;`allhelp/summary.go` 没做,那是它的缺陷不是范例): ``` memory_getreport 被调用时: state=0 且 create_time 超过 N 分钟 → 重新 LPush 入队 state=1 且 update_time 超过 N 分钟 → 判定 worker 已死,重置 state=0 再入队 ``` ⚠️ `timer` 模块的 `timer_uselog`(`cron.AddFunc("1 1 0 * * ?")`)**零防重、零补偿**, 靠 Redis `Del` 巧合幂等,日志还用 `fmt.Println`。**不是可照抄的范例。** - ⚠️ **积压要有上限**:用户一个月不开 App 会攒下 4 份周报 + 1 份月报。 `memory_getreport` 不传 period 时**只返回最近一份未确认的**,其余在拾忆页留个入口自己翻, 不要连弹 5 次。 ### 5.4 为什么不用现有 `timer` 服务 `timer` 是独立的集群服务,目前只有 `timer_uselog` 一个模块,且与 `memory` 数据不在同一进程,跨服务调用反而增加复杂度。**周报/月报 cron 直接放 `memory` 模块内**(`lego/sys/cron`),与 echomeet 的做法一致。 --- ## 六、客户端设计 ### 6.1 页面结构 tab 文案已是「拾忆」(`tabTodo`,但 41 个语言只有 3 个是对的,见 §2.3)。 底层类名仍是 `TodoTab` / `TodoScopeList` / `CalendarEventCategory`—— **类名可以不动**(改名只带来 diff 不带来价值),但新加的东西一律用 memory 命名。 ``` 拾忆页 ├── 日历卡(CalendarCard,复用现有几何/手势逻辑) ├── 分类筛选条(全部 / 待办 / 闹钟 / 灵感 / 花销) ← 新增 └── 事项列表(TodoScopeList,已有) ├── 日/周/月范围切换(已有) ├── 头部汇总: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`): - 服务端为准,本地 `GetStorage` 做缓存与离线兜底 - `RxList`,页面 `Obx` 响应 - 启动拉取 + 下拉刷新 + 写操作后局部刷新 - 冲突策略:**服务端返回覆盖本地**(本地不做离线编辑队列,一期不支持离线改) `CalendarController._rebuildEvents` 改为**从 `MemoryService` 单一来源**投影成 `CalendarEvent`: ```dart title = item.title description = [item.owner, item.dueRaw].where((e)=>e.isNotEmpty).join(' · ') date = item.happenDate startTime = item.happenTime color = _colorOf(item.category) // 分类配色 taskStatus = item.state == 1 ? 2 : 0 amount = item.amount // expense 分类 ``` **同时删除 `mockCalendarEvents()`**——真实数据上线后,演示数据混在里面会让人以为系统出错。 删的时候把 §2.4(3) 那约 40 个 `demo*` i18n key 一并清掉。 ⚠️ 现在的重复闹钟展开逻辑(`_expandReminder`,窗口「今天前 7 天 ~ 后 60 天」) 在换源后仍然需要——服务端返回的是记忆项原始行,日历要按天摊开。这段逻辑直接留用, 只是输入从 `AssistantReminder` 换成 `MemoryItem`。 ### 6.3 ⚠️ 拾忆页现在是**纯展示页**,写操作 UI 一个都没有(v0.2 全局复核新增) 这是本设计**最大的工作量遗漏**。逐个查证过(`todo_tab.dart` / `todo_scope_list.dart` / `calendar_card.dart`): | 文档里假定存在的 | 实际 | |---|---| | §6.2「下拉刷新」 | 全页 **没有 `RefreshIndicator`,没有 `onRefresh`** | | 闭环全景「手动录入(拾忆页 +)」 | **没有 `FloatingActionButton`,没有 `Icons.add`**,页面没有任何新增入口 | | §4 的 `memory_complete`(勾完成) | 卡片**没有 `onTap`**——全页唯一的 `onTap` 是范围切换 Tab(`controller.setScope`) | | §4 的 `memory_update`(改期)、`memory_del` | 同上,没有编辑/删除入口 | | §3.3 `user_edited` 的整个设计前提「用户勾过改过」 | 用户**当前无法勾也无法改** | 也就是说:**10 个服务端接口里有 4 个写接口在客户端没有任何调用点**, 而 `user_edited` / `gen_round` 这套「保护用户修改」的机制在没有编辑 UI 之前**完全是空转的**。 一期必须补的交互(分期表 #6 原来只写了「分类筛选、换源、删 mock」,漏了这一整块): 1. 卡片 `onTap` → 详情/编辑弹层(改标题、改期、改金额、删除) 2. 卡片左侧 checkbox 或左滑 → `memory_complete` 3. 页面右下 `+` → 新增(按分类分流:待办/闹钟/灵感/花销四种表单) 4. `RefreshIndicator` 包住列表 → 重新 `memory_list` 5. 空态页(删掉 mock 之后无数据用户看到的第一屏) ⚠️ 另外两处小问题顺手一起收:`TodoTab` 的 `slogan: 'sloganHome'.tr` 还是**首页**的标语; `ensureCalendarController()` 定义在 `todo_tab.dart` 但**全仓无人调用**(两个 binding 都用 `lazyPut`),是死代码。 ### 6.4 与网络层/生命周期的对接(v0.2 补,两条都别踩) **(1) 业务错误会自动弹红条,启动路径上的接口必须先加抑制名单。** [auth_interceptor.dart](../apps/client/lib/data/services/network/auth_interceptor.dart) 的 `onResponse`: ``` code == 0 → response.data = entity.data ?? {} 然后 resolve code != 0 → response.data = null,next(response),并 Get.snackbar(红色, entity.message) ``` 抑制名单 `_shouldSuppressBusinessErrorSnackbar` 里现在**只有** `user_getinfo` 和 `user_binddevice`。 `memory_today` / `memory_upcoming` 是**启动与前台恢复时自动调**的,一旦返回业务错误, 用户什么都没点就先吃一条红条(消息还是后端的英文内部状态词)。**这两个接口要加进名单**, 页面改用行内空态/错误态表达。 (`NoLogin` 类消息已被 `_isNotLoggedInMessage` 按内容兜底挡掉,这部分不用管。) ⚠️ **`code==0` 且 data 为空时拿到的是 `{}` 不是 `null`**。所以在 MemoryService 里 `resp == null` 只可能是业务错误,`{}` 是「成功但没数据」——两者含义相反,别合并判断 (同 `BesDeviceAuth` 踩过的那个坑)。 **(2) 前台恢复钩子已经有了,别再挂第四个 `WidgetsBindingObserver`。** [app_lifecycle_manager.dart](../apps/client/lib/core/utils/app_lifecycle_manager.dart) 是 `GetxService with WidgetsBindingObserver`,在 `initial_binding.dart` 里 `permanent: true` 注册, 暴露 `AppLifecycleState` 的 Rx。§5.1 的「前台恢复重排通知」和 §5.2 的「切前台判断今天弹过没」 一律订阅它。仓库里已经有 `emai_controller` / `translation_controller` / `permissions_controller` 三个各自实现的 observer 了,再加一个没有意义。 ### 6.5 助手提醒的迁移 `AssistantDirectiveService` 保留(它是**指令流水日志**,有独立价值:排查、统计百炼下发了哪些没实现的指令),但: - `_handleSetClock` 不再只写本地 `reminders`,改为调 `memory_add`(`category=alarm`, `source=assistant`, `client_key=指令 id`) - 本地 `reminders` 列表在迁移期保留双写,一个版本后移除 - 老版本本地已有的提醒,首次启动时**一次性上传**并打标记,避免重复。 上传走同一个 `client_key` 幂等键(用本地 reminder 的 id), 这样即使标记丢了、重装了,重复上传也不会插重 ### 6.6 花销录入与小票(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 认错一位小数,月报就废了。 --- ## 七、会议总结 → 记忆项 ### 7.1 模板提示词改造(`echomeet_template`,PostgreSQL 公共库) `General-Meeting` 的 `template` 第四节现在只有一句「梳理会议中明确的后续执行任务,清晰标注任务核心要求」,**没要求负责人和截止时间**,抽取那一路再强也补不出原文没提炼的信息。 改为: ```markdown ## 四、待办事项 逐条梳理会议中明确的后续执行任务,每条固定包含「任务内容、负责人、截止时间」三要素,按以下格式输出: 1. 任务内容。负责人:[张三];截止时间:本周五前。 2. 任务内容。负责人:未明确;截止时间:未明确。 - 三要素中任何一项会议原文未明确的,写「未明确」,禁止推测、补全或编造; - 截止时间原样保留原文表述(如「下周三前」「月底」),不要自行换算成具体日期; - 只收录会议中明确要求执行的任务;仅在讨论中提及、未拍板的设想不计入; - 同一件事被多次提及只输出一条,取最终确定的版本; - 若全程没有任何明确的执行任务,本节只输出一句「本次会议无明确待办事项。」 ``` `负责人:` / `截止时间:` 是给后续抽取用的**固定锚点**,人读着也自然。「未明确」比留空好——空缺分不清是模型漏了还是原文没有。 **只改 `template` 不改 `outline`**:`outline` 产出的 `overview` 是用户在「概览」Tab 直接看的正文([overview_tab.dart:27](../apps/client/lib/modules/meeting/views/tabs/overview_tab.dart) 按 `\n` 拆行渲染),改它会破坏已发布客户端的展示。 **铺开策略**:先只改 `zh-CN`(id=227)验证效果,确认后再翻译到其余 40 种语言。29 组模板 × 41 语言 = 1189 条,不要一上来就全铺。 ### 7.2 抽取那一路(代码内置 prompt,不放模板) 在 [`AIProcess`](../apps/services/modules/echomeet/tasks.go) 写完 `summary`/`overview`、状态置 `Completed` **之后**,起独立 goroutine: ``` 输入: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 重新总结的幂等 用户点「重新生成」时: ``` delete from memory_item where source='meeting' and source_id=? and user_edited=0 and gen_round < 新轮次 ``` `user_edited=1` 的一律保留。 --- ## 八、EMAI / 百炼多模态打通 百炼在两个层面参与,**职责必须分清**: ### 8.1 端侧指令(`tool_calls`)——用户说,助手做 百炼在 `RespondingContent` 帧的 `payload.output.extra_info.tool_calls` 下发端侧函数调用,客户端 [`AssistantDirective.parseFromOutput`](../apps/client/lib/data/models/assistant_directive.dart) 解析后分发。 现有 `SET_clock`(参数 `time`/`date`/`content`/`repeat`)落地成闹钟。**本次要在百炼后台补三个工具**: | 工具 | 参数 | 落地 | |---|---|---| | `SET_clock`(已有) | time, date, content, repeat | `category=alarm` | | `ADD_todo`(新增) | content, date, time, owner | `category=todo` | | `ADD_idea`(新增) | content | `category=idea` | | `ADD_expense`(新增) | content, amount, date | `category=expense` | 已知的百炼侧问题(CLAUDE.md 已记录,本次一并解决): - **「取消闹钟」和「记一下待办」目前不下发任何指令**,模型却回「已经帮你取消了」——后台没配对应工具。这是需求 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 目前没有任何鉴权,本节的工具在补上鉴权之前不得上线。** 见 §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`)同步改。 ⚠️ **还有一个架构问题:MCP 工具会让查询逻辑变成两份(v0.2 全局复核新增)。** `mcp` 是**独立进程独立服务**,它的工具直接裸查库—— `mysql.Table(comm.TableAllhelpTask).Where("uid = ?", uid)...`(见 `tool_get_user_tasks.go`), **全模块搜不到一次 `RpcCall`**,从不向 home 转发。 所以「5 个新工具」= memory 的过滤条件、分类白名单、权限规则在两个进程各写一遍, 改了 home 那边 MCP 不会跟着变,**漂移了也不报错**(EMAI 只是答得不对)。 两条出路,**一期就要选**: - **A(推荐)**:MCP 工具经 RPCX 调 home 的 `memory` 模块。`mcp` 本就是 rpcx 集群服务, gateway 的 `RpcCall(c, "home", ...)` 就是现成写法,只是 mcp 模块从没这么用过。 - **B**:把查询逻辑抽进一个共享包(`comm` 或 `modules/memory/query`),两边都调同一份。 ⚠️ 另外 **`mcp.yaml` 只配了 mysql,没有 redis、没有 postgres** (注释原文:「mcp 仅用业务库读写 allhelp_task / user 表」)。 任何基于 Redis 的限流、缓存、幂等在 MCP 侧**都不可用**——选 B 时尤其要注意, 选 A 则天然规避。 **新增工具(`ToolGroup_GLOBAL`)**: | 工具 | 描述 | 回答什么问题 | |---|---|---| | `get_memory_items` | 按日期范围/分类查记忆项 | 「我明天有什么事」「上周记了哪些灵感」 | | `get_memory_stats` | 统计(花销合计、完成率) | 「这个月花了多少钱」「上周有几件事没做完」 | | `get_memory_report` | 取周报/月报 | 「上周总结说了什么」 | | `add_memory_item` | 新增记忆项 | 「帮我记一下:明天下午三点开会」 | | `complete_memory_item` | 标记完成 | 「把跟进 A 客户那件事标记完成」 | 现有 `get_user_tasks` / `allhelp_task` / `cancel_user_task` 三个工具**改为读写 `memory_item`**(见 §9.1),保持工具名不变,避免百炼后台重新配置。 ### 8.3 两条通路的分工 ``` ┌──────────────────────────┐ 用户说「记一下…」 → │ 百炼 Agent │ 用户问「我这月花了…」 │ │ └──┬──────────────────┬────┘ │ tool_calls │ MCP 调用 ↓ (端侧执行) ↓ (服务端执行) 客户端 Directive 服务端 memory 模块 │ │ └────→ memory_item ←┘ ``` **记录走端侧、查询走 MCP** 是刻意的:端侧指令随对话即时下发、延迟低、离线也能先落本地;查询需要跨设备的全量数据,必须服务端出。 --- ## 九、迁移与兼容 ### 9.1 `DBTask` 的处置 | 阶段 | 动作 | |---|---| | 一期 | `memory_item` 上线;`DBTask` 停止新增写入;MCP 三个旧工具改读 `memory_item` | | 二期 | 一次性脚本迁移存量 `DBTask` → `memory_item`(`task_type` 映射 `repeat_rule`,`status` 映射 `state`,`category` 一律 `todo`) | | 三期 | `allhelp` 的 task 接口标记废弃,保留只读 | **不要直接删 `DBTask` 表**——EMAI 侧可能有历史会话引用,且迁移脚本需要回滚余地。 ### 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.4 不向后兼容的点 - 拾忆页新增分类后,老客户端拿到 `alarm`/`idea` 会落进未知分支——**这就是 §3.3 要求兜底的原因**, 一期就要把兜底发出去,否则二期加分类会打崩存量包 - `mockCalendarEvents()` 删除后,无数据用户看到的是空态页——需要设计空态文案与引导 - i18n:`tabTodo` 现状是 38/41 个语言显示 `Remin`(§2.3),本次要补齐; 新增的分类名、空态文案同样要 41 份 --- ## 十、闭环全景 ``` ┌─────────── 录入 ───────────┐ │ │ 会议录音 → echomeet 转写总结 EMAI 对话 → tool_calls │ ↓ AIProcess 第三路抽取 │ ↓ AssistantDirective │ (summary + 会议日期) │ (SET_clock/ADD_todo/…) └──────────┬─────────────────┘ ↓ ┌──── memory_item ────┐ ← 手动录入(拾忆页 +)/小票拍照 │ todo/alarm/idea/ │ │ expense… │ └──┬────────┬──────┬───┘ │ │ │ ┌────┘ │ └────────────┐ ↓ ↓ ↓ 拾忆页日历 提醒链路 周期复盘 (分类展示) ├ 每日登录弹窗+播报 ├ 周一 00:30 cron ├ 提前5分钟本地通知 ├ 1号 00:30 cron └ TTS 播报(可中断) ├ SQL 算 stat_json ├ LLM 组织播报文案 └ 播报 → 用户确认 ↑ │ └────── 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` 模块骨架(挂进 `home/main.go`) | 后端 | | 2 | `memory_list` / `memory_today` / `memory_add` / `memory_update` / `memory_complete` | 后端 | | 3 | 会议抽取第三路 + `` 注入 + 日期校验 + 复用 `rec.LlmSvcId` | 后端 | | 4 | `General-Meeting` zh-CN 模板第四节改造 | 后台配置 | | 5 | `MemoryService` + `MemoryItem` 模型(含 `client_key` 幂等) | 客户端 | | 6 | `CalendarEventCategory` 扩到 4 类 + **未知分类兜底** + 分类筛选 + `_rebuildEvents` 换源 + 删 mock 与 40 个 demo key | 客户端 | | 6b | **写操作 UI 全套**:卡片 onTap 编辑层、勾完成、`+` 新增(四种分类表单)、`RefreshIndicator`、空态页(§6.3,原分期表漏了这一整块) | 客户端 | | 6c | `memory_today`/`memory_upcoming` 加进红条抑制名单;前台恢复复用 `AppLifecycleManager`(§6.4) | 客户端 | | 6d | 选定 MCP 取数方式(RPCX 调 home / 抽共享包),一期定下免得三期返工(§8.2) | 后端 | | 7 | 助手 `SET_clock` 改写服务端(带 `client_key`) | 客户端 | **验收**:开一次会 → 纪要生成 → 待办自动出现在拾忆页日历上,标注来源会议与负责人; 无截止时间的落在「待定日期」分组而不是堆在会议当天。 ### 二期:提醒与播报 | # | 任务 | 端 | |---|---|---| | 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(选路走 `PickLLM`) | 后端 | | 14 | `stat_json` SQL 统计(明细截断)+ LLM 播报文案生成 | 后端 | | 15 | 报告弹窗 + 播报 + 确认(只弹最近一份未确认的) | 客户端 | | 16 | **MCP 鉴权改造**(前置)→ 5 个新工具 + 旧工具改读新表 | 后端 | | 17 | `DBTask` 存量迁移脚本 | 后端 | | 18 | admin 后台排障页:按 uid 查记忆项、看报告生成状态与失败原因 | 后台 | --- ## 十二、风险与待确认 ### 风险 | 级别 | 风险 | 应对 | |---|---|---| | 高 | **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` 双字段 | | 高 | **拾忆页没有任何写操作 UI**——4 个写接口无调用点,`user_edited` 机制空转 | 一期补齐编辑/勾完成/新增/下拉刷新/空态五件(§6.3),分期表 #6 已扩写 | | 高 | **报告漏跑无补偿**:容器重启后 cron 不补跑,`state=0` 永远不动、不报错 | `memory_getreport` 里做超时重新入队(§5.3);当前单副本,重复反而不是现实风险 | | 高 | **MCP 工具会造成第二份查询实现**,两边漂移不报错 | 一期选定走 RPCX 调 home(推荐)或抽共享包(§8.2) | | 中 | cron 多副本重复生成报告(**当前单副本,前瞻性风险**) | Redis `SET NX EX` 分布式锁 | | 高 | **注销漏删新表** | §9.2,与建表同一个 PR 提交 | | 中 | 端侧指令重试导致重复插入 | `client_key` 幂等键 + 唯一索引 | | 中 | 老客户端遇到新分类崩页 | 一期就发未知分类兜底 | | 中 | 本地通知权限被拒 / 平台限制 / iOS 64 条上限 | 降级为打开 App 才提醒;重复项按天合并 | | 中 | `cancelAll()` 误杀录音前台通知 | 提醒占固定 id 区间,逐条 cancel | | 中 | LLM 算错金额 | 统计全部走 SQL,LLM 只组织文案;小票识别结果必须回显确认 | | 中 | 多一路 LLM 调用增加成本 | 输入用 `summary` 而非全文;失败不重试 | | 高 | **每日弹窗把网络往返加回启动关键路径**——与 `6a64c3d0` 刚做的优化方向相反 | `memory_today` 进主页后 `unawaited` 拉取,拿到再弹,不阻塞导航(§5.2) | | 中 | 启动弹窗打架(隐私 / 强更 / 每日播报) | 固定顺序,每日弹窗排最后且在登录态之后 | | 中 | **启动路径接口报错会自动弹红色 snackbar**(AuthInterceptor 默认行为) | `memory_today`/`memory_upcoming` 加进 `_shouldSuppressBusinessErrorSnackbar`(§6.4) | | 中 | **ErrorCode 复用 `ReqParameterError` 会让客户端无法分流**(如「报告还没生成好」该重试而非弹错) | 新开 5201- 段(§4) | | 中 | MCP 侧没有 Redis,限流/缓存/幂等都用不了 | 走 RPCX 调 home 可天然规避(§8.2) | | 低 | `IsSign` 时间戳防重放(`ts <= lastts` 即拒)与拾忆页并发请求冲突 | 当前所有 yaml 都没配 `IsSign`(默认 false),不影响;若将来开启需改成串行或每请求独立 ts | | 中 | **周报的「周一凌晨」是容器固定的北京时间**,纽约用户的周报在他本地周日中午就生成,漏掉周日下半天 | 接受并写进说明,或按 `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. **周报/月报的成本控制——但「挂额度」这条路走不通**(v0.2 复核后修正)。 项目里既有的额度扣减**全是「用户主动发起」语义**: `echomeet/api_starttask.go` 先 `if user.Meetintegral < seconds` 再 `-=`,失败时 `tasks.go` 里 `+=` 退还;`user/api_usages.go` 的 `Aichatintegral -= 1` 同理。 而周报是 **cron 主动生成的,用户什么都没做**——扣他额度语义上不成立, 而且失败退还、余额不足时"报告不生成"要怎么告诉用户,都没有合理的交互位置。 **更合理的三个选项**:① 不挂,当作平台成本(当前设计); ② 按 VIP 等级决定生成频率(免费用户只有月报、VIP 才有周报); ③ 只给「上周期有 N 条以上记录」的活跃用户生成(§5.3 已有「只给有数据的用户生成」,把阈值调高即可)。 **③ 成本最可控且不需要新概念,建议默认选它。** 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 兜底。 --- ## 附录 A:分类配色建议 | 分类 | 色值 | 说明 | |---|---|---| | `todo` 待办 | `#5B8DEF` 蓝 | 与现有 mock 的 todo 卡片同色 | | `alarm` 闹钟 | `#22C1A6` 青 | 沿用现有助手提醒色(`_expandReminder` 里已是这个值) | | `idea` 灵感 | `#F59E0B` 橙 | | | `expense` 花销 | `#EC4899` 粉 | | | 未知分类 | 中性灰 | 兜底,见 §3.3 | 深色模式下需各自校验对比度,不要直接沿用。 ## 附录 B:关键文件索引 | 用途 | 路径 | |---|---| | 会议纪要生成 | `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/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` | | **日历事件模型 / 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` | --- ## 十三、剩余待办:需要人工或外部系统操作(v0.3) 代码已就绪,但这些做不了,也不该由代码替你决定。 ### A. 外部系统操作(必须有人去点) | # | 事项 | 位置 | 不做的后果 | |---|---|---|---| | A1 | **百炼后台补 `ADD_todo`/`ADD_idea`/`ADD_expense`/`CANCEL_clock` 四个工具** | 百炼控制台 | 语音只能定闹钟,说「记一笔花销」模型不下发任何指令却回「记好了」 | | A2 | **百炼后台摘掉 `SEND_message` / `MAKE_A_PHONE_CALL_phone_call`** | 同上 | 模型继续下发本版无法执行的指令 | | A3 | **`General-Meeting` zh-CN 模板第四节改造**(§7.1 的提示词) | 后台 → 会议模板(`meettemplates.vue`,已能按语言编辑) | 抽取那一路拿不到「负责人/截止时间」锚点,抽出来的待办大多只有任务内容 | | A4 | **`.env` 补 `GATEWAY_TOKEN_KEY` 到 mcp 服务** | `deploy/app/env/` | MCP 的用户数据类工具**一律拒绝服务**(这是刻意的失败方向,见 `auth.go`) | ### B. 需要产品决策 | # | 决策 | 影响 | |---|---|---| | B1 | **`USE_EXACT_ALARM` 申不申请** | 不申请则「提前 5 分钟」实际是「提前 5~20 分钟之间」。申请需 Google Play 认可「应用核心功能是闹钟/日历」并单独提交说明。代码已做退化处理,两条路都能跑 | | B2 | **提醒送达率做不到 100%,预期要对齐** | 通知权限 48 小时合规冷却 + Android 14 精确闹钟 + 国内 ROM 杀后台,三条都不是代码能绕的 | | B3 | **拾忆页要不要走实名闸门** | 现在没拦。语音录入走 EMAI 已被拦,等于游客只能手动录——这个不对称是否可接受 | | B4 | **要不要给记忆中心埋点** | `analyze.writeEvent` 是固定 switch,加维度要同时改 console 看板列。目前一个埋点都没有 | | B5 | **数据导出** | 花销与灵感是用户资产,没有导出入口等于数据绑架。`memory_list` 已留分页参数 | ### C. 已知偏差(写在代码注释里,不是 bug) | # | 偏差 | 说明 | |---|---|---| | C1 | 会议归属日期用 `creationtime`(上传时间) | `DBEchoMeetRecord` 上没有「会议实际发生日期」字段,也没有客户端时区。补录历史录音会算错。要做准得给表加 `meet_date` + `tz` 两列由客户端上报 | | C2 | 周报 cron 是容器固定的北京时间 | 纽约用户的周报在他本地周日中午生成,漏掉周日下半天。要做准得按用户 `tz` 分批触发(cron 每小时跑,只处理「本地刚过周一 00:30」的那批) | | C3 | 迁移来的 DBTask 丢失时区 | `trigger_time` 只有偏移没有 IANA 名,`tz` 留空按容器时区处理 | | C4 | i18n 新增 key 的 38 个语种是英文占位 | 已显式写入而不是留空——留空的话 GetX 静默回退英文,审计工具永远发现不了。现在 `i18n_audit.py` 会把它们列为「值等于英文基线」待翻译 | ### D. 未做(超出本次范围) - 花销小票 OCR(§6.6):上传链路已就绪,缺 `uploadScenes` 加 `MemoryImages` 与识别调用点 - admin 后台排障页(分期表 #18) - `allhelp/summary.go` 的 doubao 硬编码(§12 待确认 8):新模块没复制这个写法,但存量那处仍绕过编排