76 KiB
记忆中心(拾忆)设计与开发文档
版本:v0.2 日期:2026-09-04(v0.1 为 2026-08-31) 范围:
apps/services(后端)、apps/client(Flutter)、apps/admin(后台)、百炼 Agent 配置 状态:设计评审中,未开工(代码侧目前只有 tab 文案改名,见 §2.3)
v0.2 修订说明
v0.1 之后主仓有 4 次提交(翻译延迟优化、设备表合表、收拢积压改动、上传改预签名 URL), 本次逐条复核了 v0.1 的「现状盘点」,有 6 处已经不成立,另发现 3 个既有缺陷会直接绊到本设计:
| # | v0.1 的说法 | 实际 | 影响章节 |
|---|---|---|---|
| 1 | echomeet 按 ServiceType 四套固定服务商,总结用 GetSummarizer(svcType, lang) |
已改为后台编排:PickLLM() / GetSummarizer(svcId),ResolveServiceType 已不存在 |
§7.2 |
| 2 | 会议总结只吃文本 | 已支持多模态图片(imageurls),小票识别不用另造轮子 |
§12 待确认 1 |
| 3 | 客户端只有花销 i18n 文案 | CalendarEventCategory{todo,expense} + 花销卡片 + 范围内合计已经写好了 |
§6.1 |
| 4 | 客户端上传对象存储用腾讯 COS、密钥明文下发 | 已改服务端签发预签名 URL,scene 是白名单 |
§6.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 工具 |
设计原则
- 统一模型:一张表承载所有分类,新增分类不改表结构、不加接口。
- 服务端为真相源:本地只做缓存与离线兜底,换设备数据要在。
- 失败隔离:AI 抽取、总结生成失败不影响主流程,宁可少一条记忆,不可让会议纪要或登录卡住。
- 不重复造轮子:
allhelp已有任务体系与 MCP 工具,在其上演进。 - 选路走编排,不写死服务商(v0.2 新增):本模块所有 LLM 调用一律经 echomeet 的
providers选路, 不再出现第二处硬编码sys/doubao。理由见 §7.2。
二、现状盘点(本设计的地基)
2.1 已有且可直接复用
| 能力 | 位置 | 说明 |
|---|---|---|
用户任务表 DBTask |
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、tool_allhelp_task.go、tool_cancel_user_task.go |
EMAI 已能查/建/取消任务。⚠️ 无鉴权,见 §2.4 |
| 异步 AI 流水线(两套范例) | allhelp/summary.go(Redis 队列 + N worker) echomeet/tasks.go(Redis 队列 + ants 池 + 每秒 cron 调度) |
周报/月报照搬。选路照 echomeet,不要照 allhelp(后者硬编码 doubao) |
每日聊天总结表 DBChatSummary |
同上 | (uid, summary_date) 唯一,覆盖重算 |
| LLM 选路(编排) | echomeet/providers.go PickLLM / GetSummarizer(svcId) |
后台配置驱动、按应用/区域分层、NATS 热重载。memory 与 echomeet 同在 home 进程,可直接复用 |
| 多模态图片入 LLM | AIProcess 的 ChatMessage.Images → 各 Summarizer |
花销小票 OCR 可直接走这条 |
| TTS 播报 | 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 | SET_clock 已落地成提醒 |
| 日历分类与花销卡片 | calendar_event.dart、todo_scope_list.dart | CalendarEventCategory{todo,expense}、amount 字段、_expenseCard、expensesForScope 合计条都已存在 |
| 会议纪要生成 | echomeet/tasks.go AIProcess |
summary 已含「四、待办事项」章节 |
| 服务端签发上传 URL | 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 的 authFromRequest 只是把
Authorization 头塞进 context:
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 的 cancel() 手写了要清哪几张表:
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,那是历史遗留,不要照抄。)
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
的 '¥${...}'),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
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:
{
"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 的反例:
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_COMPLETEDmanifest 里已经声明了,不用再加;但 Android 13+ 仍需运行时申请POST_NOTIFICATIONS。 iOS 需通知授权。未授权时降级为「打开 App 才提醒」,不要阻塞主流程。 - 排期上限按平台限制(iOS 最多 64 条待处理通知),只排最近 7 天、按时间取前 N 条。
⚠️ 一条
daily闹钟展开 7 天就吃掉 7 个名额,重复项要先按天合并再排, 否则三条日常闹钟就把 iOS 的配额占掉一半。 - 通知点击进来才播报,不在通知里直接播声音——锁屏状态下强行出声是骚扰。
⚠️ 权限拿不到的概率被低估了(v0.2 新增,这条可能让需求 3 大面积失效):
- 通知权限有 48 小时冷却,且不允许补救。permission_util.dart
为过审把规则写死了:用户拒绝过一次,48 小时内一律直接返回 false,不弹系统框、不弹自定义框、
不跳设置页,拒绝时间存 GetStorage、重启 App 依然生效;调用方拿到 false 只能「把功能置灰或退出」,
注释里明写「不要自己再补一个弹窗」。
而
Permission.notification是全 App 共用的一个权限,录音(background_session_notifier)、 会议录音、音乐播放都在申请它。用户在任何一处拒了,提醒功能就有两天完全静默, 而且 App 不被允许告诉他为什么。设计上必须假定「相当一部分用户长期拿不到通知权限」。 唯一的例外是「设置 → 权限管理」页里用户自己点(userInitiated: true), 所以拾忆页至少要有一条指向那个页面的静态提示(不是弹窗)。 - Android 14 的精确闹钟很可能拿不到。manifest 声明的是
SCHEDULE_EXACT_ALARM, Android 14 起它对普通应用默认不授予,要用户去系统设置里开。 闹钟/日历类应用应该声明的是USE_EXACT_ALARM(默认授予), 但那个权限 Google Play 要求应用核心功能就是闹钟/日历并单独提交说明。 拾忆算不算,需要产品先定;不申请就只能用inexact排期, 「提前 5 分钟」会变成「提前 5 到 20 分钟之间某个时刻」,与需求 3 的字面要求不符。 - 国内 ROM 会杀掉后台排期。华为/小米/OPPO/vivo 的省电策略会清掉未加白名单应用的 AlarmManager 排期,这不是代码能绕过去的。已知的缓解只有引导用户加「自启动/电池优化白名单」, 而那又会撞上第 1 条的合规冷却。需求 3 在国内安卓上做不到 100% 送达,这个预期要提前跟产品对齐。
⚠️ 三个必须先解决的工程前置(v0.2 新增):
- 缺
timezone依赖。zonedSchedule需要timezone包 +tz.initializeTimeZones()+ 一个tz.Location;本机时区名还需要flutter_timezone。pubspec.yaml里两个都没有。不加这两个依赖,这条链路一行都写不了。 - 通知 ID 要分命名空间。仓库里已经有两个各自 new 出来的
FlutterLocalNotificationsPlugin实例 (background_session_notifier.dart、 meeting_record_controller.dart), 各自管自己的通知 id。提醒排期再来一套,id 撞车会互相覆盖, 而「先取消旧排期」若图省事写成cancelAll(),会顺手干掉正在录音的前台通知, 表现为录音悄悄失去前台身份、被系统回收。提醒占一段固定 id 区间,只按 id 逐条 cancel。 - 通道(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 的成熟模式
(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是集群服务,多副本会重复生成。用 RedisSET 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<MemoryItem>,页面Obx响应- 启动拉取 + 下拉刷新 + 写操作后局部刷新
- 冲突策略:服务端返回覆盖本地(本地不做离线编辑队列,一期不支持离线改)
CalendarController._rebuildEvents 改为从 MemoryService 单一来源投影成 CalendarEvent:
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」,漏了这一整块):
- 卡片
onTap→ 详情/编辑弹层(改标题、改期、改金额、删除) - 卡片左侧 checkbox 或左滑 →
memory_complete - 页面右下
+→ 新增(按分类分流:待办/闹钟/灵感/花销四种表单) RefreshIndicator包住列表 → 重新memory_list- 空态页(删掉 mock 之后无数据用户看到的第一屏)
⚠️ 另外两处小问题顺手一起收:TodoTab 的 slogan: 'sloganHome'.tr 还是首页的标语;
ensureCalendarController() 定义在 todo_tab.dart 但全仓无人调用(两个 binding 都用 lazyPut),是死代码。
6.4 与网络层/生命周期的对接(v0.2 补,两条都别踩)
(1) 业务错误会自动弹红条,启动路径上的接口必须先加抑制名单。
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 是
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。
要做的只有两件:
uploadScenes白名单加一项(如MemoryImages),在 api_getuploadurl.go。 ⚠️ 少加这一项的表现是「那条上传链路整条报『不支持的上传用途』」,且只在真机跑到那个场景才发现。- 小票识别复用 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 第四节现在只有一句「梳理会议中明确的后续执行任务,清晰标注任务核心要求」,没要求负责人和截止时间,抽取那一路再强也补不出原文没提炼的信息。
改为:
## 四、待办事项
逐条梳理会议中明确的后续执行任务,每条固定包含「任务内容、负责人、截止时间」三要素,按以下格式输出:
1. 任务内容。负责人:[张三];截止时间:本周五前。
2. 任务内容。负责人:未明确;截止时间:未明确。
- 三要素中任何一项会议原文未明确的,写「未明确」,禁止推测、补全或编造;
- 截止时间原样保留原文表述(如「下周三前」「月底」),不要自行换算成具体日期;
- 只收录会议中明确要求执行的任务;仅在讨论中提及、未拍板的设想不计入;
- 同一件事被多次提及只输出一条,取最终确定的版本;
- 若全程没有任何明确的执行任务,本节只输出一句「本次会议无明确待办事项。」
负责人: / 截止时间: 是给后续抽取用的固定锚点,人读着也自然。「未明确」比留空好——空缺分不清是模型漏了还是原文没有。
只改 template 不改 outline:outline 产出的 overview 是用户在「概览」Tab 直接看的正文(overview_tab.dart:27 按 \n 拆行渲染),改它会破坏已发布客户端的展示。
铺开策略:先只改 zh-CN(id=227)验证效果,确认后再翻译到其余 40 种语言。29 组模板 × 41 语言 = 1189 条,不要一上来就全铺。
7.2 抽取那一路(代码内置 prompt,不放模板)
在 AIProcess 写完 summary/overview、状态置 Completed 之后,起独立 goroutine:
输入:<meeting_date>2026-08-31</meeting_date>
<summary>…已生成的纪要…</summary>
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(约千字),成本只是前两路(转写全文可达上万字)的零头,省不出什么。
为什么必须注入 <meeting_date>:现在 AIProcess 拼给模型的只有 <remark> + <transcript>,没有日期。模型不知道今天几号,让它把「下周三前」换算成具体日期就是瞎猜。
⚠️ <meeting_date> 取哪个字段(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 的注释)。
⚠️ 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 解析后分发。
现有 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 的前置):
- 从 context 里的
Authorization解出会话 uid(复用 gateway 那套 JWT 校验); - 用户类工具忽略参数里的 uid,一律用会话 uid;解不出会话 uid 直接返回错误,不降级;
- 存量三个工具(
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 的 cancel() 是硬编码表清单,
加两行:
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 | 会议抽取第三路 + <meeting_date> 注入 + 日期校验 + 复用 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 且不报错 |
| 低 | 统一表半数字段为空 | 数据量级小,可接受 |
待确认
- 周报/月报的成本控制——但「挂额度」这条路走不通(v0.2 复核后修正)。
项目里既有的额度扣减全是「用户主动发起」语义:
echomeet/api_starttask.go先if user.Meetintegral < seconds再-=,失败时tasks.go里+=退还;user/api_usages.go的Aichatintegral -= 1同理。 而周报是 cron 主动生成的,用户什么都没做——扣他额度语义上不成立, 而且失败退还、余额不足时"报告不生成"要怎么告诉用户,都没有合理的交互位置。 更合理的三个选项:① 不挂,当作平台成本(当前设计); ② 按 VIP 等级决定生成频率(免费用户只有月报、VIP 才有周报); ③ 只给「上周期有 N 条以上记录」的活跃用户生成(§5.3 已有「只给有数据的用户生成」,把阈值调高即可)。 ③ 成本最可控且不需要新概念,建议默认选它。 - 拾忆页要不要走实名闸门——现在所有功能卡片都按
IdVerifyGuard.blocked()拦。 拾忆是底部 tab,整页拦不合适;但语音录入走 EMAI,EMAI 已被拦,等于游客只能手动录。 要确认这个不对称是可接受的。 - 数据导出——花销与灵感是用户自己积累的资产,没有导出入口等于数据绑架,
合规角度也该有。一期不做,但接口设计上
memory_list要能不分页拉全量。 - 花销录入方式——纯语音(「打车花了 30」)?手动记账页?拍小票? (小票通道 §6.4 已确认技术上现成,剩下是产品决策。)
- 灵感与「聊天总结」的关系——
allhelp已有DBChatSummary(每日聊天 AI 总结)。灵感是用户主动说「记一下」,聊天总结是自动提炼,两者要不要合并?建议先分开,灵感是显式的、聊天总结是隐式的。 - 报告确认的含义——只是「我看过了」,还是要允许用户在确认时批量处理未完成事项(顺延/放弃)?影响接口设计。
- 多设备播报冲突——同一账号在手机和耳机同时在线,谁播报?建议只在当前活跃前台设备播。
allhelp/summary.go的 doubao 硬编码要不要一并改——本模块不复制这个写法(§5.3), 但存量那处仍然绕过编排。是否借这次一起收拾?USE_EXACT_ALARM要不要申请——拾忆算不算「核心功能是闹钟/日历」, 影响 Google Play 提交材料,也决定「提前 5 分钟」是精确还是「5~20 分钟之间」。产品先定。- 记忆中心要不要埋点——
analyze模块的writeEvent是一张固定的switch(登录/注册/设备激活/绑定/订单/翻译/会议/AI对话/资源发放),加统计维度要改这个 switch 并且改 console 看板的列。目前设计里一个埋点都没有,而项目里每个功能都有。 如果要加,注意ANALYZE_APP_NAME必须与app_registry.name相等,否则快照被 console 静默拒收、看板恒为 0 且不报错。这一条同时列在风险表里——「不埋」是可以的决策, 但必须是被决策过的,而不是忘了。 - 阿龙测试机 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 |