01-提醒域建模-t_reminder字段与五种提醒类型
聊陪伴机器人,大家第一反应都是"能聊天吗"“会不会讲故事”。但真把一台机器人放进有老人的家里,最先被用起来、也最离不开的功能,往往不是聊天,而是提醒。
道理很简单:聊天解决的是"寂寞",提醒解决的是"忘事"。而忘事这件事,轻则忘喝水忘复诊,重则忘吃药。所以在我这个「AI 伙伴(AI-Partner)」项目里,提醒域(t_reminder)是第一批就被认真设计的数据模型。
这一篇我们把这张表掰开揉碎:它的每个字段为什么存在、五种提醒类型怎么分、"时间"为什么需要两个字段、状态为什么也拆成了两个。看完你大概会发现:一个设计得好的表,本身就是一份需求文档。
一、先想清楚:陪伴机器人的"提醒"和手机闹钟有什么不一样
如果只是想"到点响一声",手机闹钟就够了,用不着机器人。所以提醒功能的设计起点,必须先回答一个问题:它凭什么比闹钟好?
差别有三层:
| 维度 | 手机闹钟 | 陪伴机器人提醒 |
|---|---|---|
| 触发方式 | 手动设置时间 | 用自然语言说一句"明天八点提醒我吃药" |
| 表达方式 | 滴滴滴 | 用你的称呼、你的语气播报出来:“张阿姨,该吃降压药啦” |
| 上下文 | 无 | 知道你是老人还是孩子、知道你昨天情绪不好、知道你血压偏高 |
| 失败处理 | 无(响过就算完) | 理论上可做未接提醒、家属通知(本项目暂未实现) |
第一层是"入口"的差异,第二层是"人设"的差异,第三层才是真正的产品护城河。而这三层,最终都要落回同一张表。
二、t_reminder的全字段拆解
先上表结构。下面是这个实体的真实字段定义(项目源码entity/Reminder.java):
| 字段 | Java 类型 | 列定义 / 注解 | 默认值 | 说明 |
|---|---|---|---|---|
id | Long | @Id @GeneratedValue(IDENTITY) | 自增 | 主键 |
userId | Long | @Column(nullable=false) | 必填 | 归属用户,t_user.id |
deviceId | String | @Column(length=64) | 可空 | 计划播报的设备编码 |
type | String | @Column(length=32) | TYPE_CUSTOM | 提醒类型,见下节 |
title | String | @Column(nullable=false,length=128) | 必填 | 提醒标题 |
content | String | @Column(length=512) | 可空 | 提醒正文 |
remindTime | LocalDateTime | @Column(nullable=false) | 必填 | 绝对触发时间 |
cron | String | @Column(length=64) | 可空 | 重复表达式 |
status | String | @Column(length=16) | STATUS_PENDING | 业务状态 |
pushed | Boolean | — | false | 是否已推送 |
createdAt | LocalDateTime | 生命周期回调 | — | 创建时间 |
updatedAt | LocalDateTime | 生命周期回调 | — | 更新时间 |
配套的索引:
-- 建表脚本索引(项目源码)INDEXidx_reminder_user(user_id),INDEXidx_reminder_time_status(remind_time,status)两个索引分别服务于两类查询:按人查提醒列表和按时扫到期提醒。这个分工非常清晰,说明设计者一开始就想清楚了"这张表会被谁怎么读"。
顺带说一句:
t_reminder里没有外键。整个项目的 8 张表都没建外键,表间关系全靠user_id/device_id这类字段做逻辑关联。这不是偷懒,而是一种取舍——我们后面在数据库设计那篇会专门聊它的代价。
三、五种提醒类型:medication/schedule/water/birthday/custom
类型枚举是这么定的(项目源码):
publicstaticfinalStringTYPE_MEDICATION="medication";// 吃药publicstaticfinalStringTYPE_SCHEDULE="schedule";// 日程publicstaticfinalStringTYPE_WATER="water";// 喝水publicstaticfinalStringTYPE_BIRTHDAY="birthday";// 生日publicstaticfinalStringTYPE_CUSTOM="custom";// 自定义为什么不干脆全用custom加一个自由文本?因为类型不只是个标签,它决定了后面三件事:
| 类型 | 用户会怎么说 | 典型标题 | 建议的默认重复策略 | 风险等级 |
|---|---|---|---|---|
medication | “提醒我早上七点吃药” | “吃降压药” | 每天 / 按医嘱周期 | 高(漏服有健康风险) |
schedule | “周三下午三点去复查” | “去医院复查” | 单次 | 中 |
water | “每小时提醒我喝水” | “喝点水吧” | 每天多次 | 低 |
birthday | “8 月 12 号是我孙子生日” | “孙子生日” | 每年 | 低(但漏了很尴尬) |
custom | “十点提醒我关火” | 用户原话 | 单次 | 视内容而定 |
这张表最右边的"风险等级"是我在做这个项目时逐渐意识到的东西:提醒是有安全边界的。药吃错时间、复诊忘掉,可能直接影响健康;而喝水提醒漏一次,真的没关系。风险高的类型,工程上就应该有更强的保障(多重触达、送达确认、失败重试),而现实是——这三种保障,本项目目前一个都没实现。
所以这里的第一个诚实结论是:类型枚举已经为分级保障留好了位置,但分级逻辑还没写。后文会给出补法。
四、Agent 是怎么决定该存哪个类型的
提醒不是用户在表单里点出来的,而是从一句自然语言里"长"出来的。工具方法的签名长这样(项目源码agent/tools/ReminderTool.java):
@Tool("为用户创建一条提醒(吃药/日程/喝水/生日/自定义),返回创建结果和提醒 ID")publicStringcreateReminder(Stringtitle,Stringcontent,StringremindTimeStr,Stringtype,StringrepeatCron,StringdeviceCode,@ToolMemoryIdLonguserId){try{LongreminderId=reminderService.createFromAgent(userId,title,content,remindTimeStr,type,repeatCron,deviceCode);return"提醒已创建,ID="+reminderId+":"+title+"("+remindTimeStr+")";}catch(Exceptione){return"提醒创建失败:"+e.getMessage();}}注意@Tool描述里那句"(吃药/日程/喝水/生日/自定义)“——这不是给人看的注释,是给大模型看的选项说明。工具描述的措辞直接决定模型传什么type进来。你写"提醒类型”,它就乱猜;你把候选值列出来,它命中率立刻上来。
另外一个细节值得单独拎出来讲:createReminder是六个工具类、十三个@Tool方法里唯一带 try-catch 的一个。为什么偏偏是它?
因为在所有工具里,创建提醒是最容易失败又最需要给用户反馈的一个:时间解析可能失败(“下个月底”)、必填字段可能缺、数据库可能抽风。其它工具挂了顶多这一轮对话少记一条情绪,提醒创建挂了,老人以为"机器人记下了",其实啥都没存——这是会造成实际后果的。
工程经验在这里可以总结成一句:异常处理要按"失败后果"分配,而不是按代码美观分配。
五、remindTime和cron并存:一个必须讲清楚的设计意图
remindTime是nullable=false,cron是可空字符串。这个组合的意图是很清楚的:
remindTime= 下一次(或唯一一次)该响的时间,绝对时间,扫描器直接比大小;cron= 如果这条提醒要重复,重复规则是什么。
听起来合理,但实际运行起来的真相是(这一点必须如实说):
ReminderScheduler的到期扫描方法findDue,只用了remindTime和pushed两个条件,从未读取cron。
也就是说:用户说"每天 21 点提醒我泡脚",系统会把cron老老实实存下来,然后这条提醒在当天 21 点响一次,之后……就没有之后了。重复提醒在当前版本是不生效的。
这个 bug 挺典型的,它教给我们一个数据建模的道理:
存下来的字段,如果没有任何代码路径读它,那它不是设计,是占位。字段和消费方必须成对出现,否则三个月后接手的人会以为"这块功能是好的"。
正确的重复提醒建模,常见有三种做法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A. 单一推进字段 | 只留next_trigger_time,响过后按cron算下一次并回写 | 表最简单、扫描逻辑不变 | 无法表达"跳过某次" |
| B. 母版 + 实例 | t_reminder存规则,t_reminder_instance存每次具体触发 | 支持改期、跳过、送达确认 | 实例表会膨胀,需要清理 |
| C. 纯 cron 表 | 只存cron,由调度框架(Quartz 等)托管 | 复用成熟调度能力 | 与业务状态耦合较松 |
本项目当前的字段布局其实已经站在方案 A 的门口了——remindTime就是那个next_trigger_time,只是缺了"响过之后按 cron 回写"这最后一步。补法是:在dispatchDueReminders里,推送成功后判断cron是否非空,非空则算出下一次时间回写remindTime并把pushed复位为false。
六、status和pushed:为什么状态要存两个
初看会觉得冗余:既然有status,为啥还要一个pushed布尔值?
publicstaticfinalStringSTATUS_PENDING="pending";// 待触发publicstaticfinalStringSTATUS_DONE="done";// 已完成publicstaticfinalStringSTATUS_CANCELLED="cancelled";// 已取消因为这两个字段回答的是两个不同的问题:
| 字段 | 回答的问题 | 关注方 |
|---|---|---|
status | 这条提醒在业务上还有效吗? | 用户、Agent(查待办列表时用) |
pushed | 这条提醒投递出去了吗? | 调度器(扫描到期时用) |
举个具体场景你就明白为什么要分开:用户取消了提醒(status=cancelled),但这条记录在数据库里还是存在的;调度器扫描时如果不看status只看remindTime,就会给一条已取消的提醒发播报指令——那就尴尬了,老人会说"我都取消了你还提醒我"。
反过来,如果只有status没有pushed,那你没法区分"还没到点"和"到点了但推送失败了"。而"推送失败"恰恰是提醒功能里最需要处理的情况。
不过现状要说清楚:当前调度扫描的查询条件是status + remindTime<=now + pushed=false,逻辑是对的;但推送失败时只打了一行log.error,没有失败次数字段、没有下次重试时间、没有死信表。所以pushed现在实际只有"否 → 是"单向流转,缺少失败态的出口。
一个更完整的字段设计会是:
pushed boolean 是否已送达 push_attempts int 尝试次数(示意,当前未实现) next_retry_at datetime 下次重试时间(示意,当前未实现) last_error varchar 最后一次失败原因(示意,当前未实现)七、deviceId为空的时候,到底在谁的嘴里说话
t_reminder.deviceId是可空的。为什么可空?因为用户说"提醒我吃药"的时候,心里根本没想"让哪台设备说"。
那到点的时候听谁的?现状是这样的:调度器会去找该用户状态为在线的设备,取到设备编码后把指令发到server/<设备编码>/cmd。设备在线状态的判断依据是t_device.status = STATUS_ONLINE。
这里有两个坑,都是真实存在的:
- 状态位不如时间差可靠。
t_device里明明存了lastHeartbeatAt字段,但代码里从头到尾没有读过它。设备拔网线不会主动告警"我离线了",它的状态就会永远停在online。于是机器人会一脸认真地对着空气说话,日志上一切正常。正确做法是"心跳超时即离线":now - lastHeartbeatAt > 阈值就判离线。这一条我们在设备域那篇会详细展开。 - 一人多设备时存在"在哪个房间说话"的问题。如果用户在客厅和卧室各放了一台,只靠"找一台在线设备"是碰运气的。产品上合理的做法是:优先选最近一次检测到用户活动的那台,其次是用户最后交互的那台。
八、索引与查询:一次到期扫描的完整 SQL 视角
提醒域有两条主要读取路径,对应的派生查询方法(项目源码repository/ReminderRepository.java):
| 方法 | 用途 | 触发的索引 |
|---|---|---|
findByUserIdAndStatusOrderByRemindTimeAsc(userId, status) | 用户查看待触发提醒(Agent 的listReminders工具也走这里) | idx_reminder_user(userId) |
findByStatusAndRemindTimeLessThanEqualAndPushedFalse(status, now) | 调度器扫描到期提醒 | 期望走idx_reminder_time_status(remindTime, status) |
第二条查询值得掰扯一下。索引建的是(remind_time, status),而查询条件是:
WHEREstatus=?ANDremind_time<=?ANDpushed=falseORDERBYremind_time从最左前缀原则看,remind_time打头是有意义的——因为查询里对remind_time用的是范围条件,正好可以吃到排序。而如果把status放最前((status, remind_time)),等值条件先命中、再走范围,理论上过滤更精准,但会失去remind_time的天然有序性,排序就得额外做。
这两种建法都对,取决于数据分布:如果status的取值区分度很低(比如 99% 的数据都是pending),那status放前面几乎没用,(remind_time, status)反而更好。索引顺序不是背口诀,是看数据分布。真实项目里可以两条都建、看执行计划再砍。
至于pushed没有进索引,是因为它区分度太低(布尔值),单独进索引用处不大。真要让这条查询跑到极致,可以做成部分索引/复合索引(status, remind_time, pushed),或者干脆把"已推送"的记录归档到历史表,让热表永远保持小体量——后者对定时扫描类是更彻底的解法。
九、从一句话到一条记录:全链路字段映射
把前面所有字段串起来,看一次真实交互:
用户:“明天早上八点提醒我吃降压药。”
| 环节 | 产出 |
|---|---|
| 大模型判断 | 这是要创建提醒 → 调用ReminderTool.createReminder |
| 模型填参 | title="吃降压药"、content(可为空或补充说明)、remindTimeStr="明天早上八点"、type="medication"、repeatCron=null、deviceCode=null |
NaturalTimeParser | remindTimeStr→ 绝对时间(如次日的 08:00) |
ReminderService.createFromAgent | 落库:userId(由@ToolMemoryId注入)、remindTime、type、status=pending、pushed=false |
| 返回给模型 | "提醒已创建,ID=1024:吃降压药(明天早上八点)" |
| 模型生成回复 | “好嘞,明天早上八点我会提醒您吃降压药,记得别空腹~” |
注意最后两行:工具返回的是给模型看的事实(ID + 标题),模型再翻译成给用户听的话。这个"两次翻译"的结构是多用户 Agent 的关键——用户永远不该看到ID=1024这种东西,而模型需要它来做后续的取消、修改。
十、这张表还能立刻改进的五件事
最后给一份可以直接照着做的清单,也是本项目提醒域目前真实的短板:
- 让
cron真正生效:推送成功后按cron计算下一次remindTime并复位pushed。 - 失败要有出口:加
push_attempts/next_retry_at/last_error三个字段,用退避策略重试,超过 N 次落死信表。 - 送达确认:提醒播报后要求设备回一条
ack,超时未确认则升级触达方式(重复播报、家属通知)。 - 按类型分级保障:
medication类走强保障(多次播报 + 未确认升级),water类走轻保障(响一次即可)。类型枚举已经备好,缺的是分支。 - 归档冷热分离:把
done且超过 30 天的提醒移到历史表,主表只留待触发与近期数据,让每分钟一次的扫描始终跑在小表上。
十一、小结
t_reminder这张表只有 11 个业务字段,但它把陪伴机器人提醒功能的骨架都撑起来了:类型(说什么)、时间(什么时候说)、设备(在哪说)、状态(还说吗)、投递(说到了吗)。
也给所有做类似功能的同学提个醒:数据表设计完之后,一定要回头检查每个字段的读取方在哪。像cron这种"存了没人读"的字段,在 Demo 阶段看不出问题,等接了真实用户、被问一句"为什么每天提醒只响一次",才会浮出水面。早发现早补,成本最低。
提醒功能只做了一半的严肃性在于:它会给人"已经记住了"的错觉。对老人来说,这种错觉比没有提醒更危险。所以宁可少承诺几个功能,也要把提醒的送达闭环做扎实。任何健康相关提醒都只是辅助手段,用药与治疗请务必遵照医生意见,并让家属共同把关。