# 记忆中心(拾忆)设计与开发文档
> 版本: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):新模块没复制这个写法,但存量那处仍绕过编排