Skip to content

Architecture interface design

hqy edited this page Aug 1, 2026 · 14 revisions

语音日程系统新版架构文档

1. 方案结论

  1. 客户端不再独立承担全部语音理解和提醒编排。
  2. 客户端进入界面后建立 WebSocket 连接,作为后续实时通信主通道。
  3. 音频通过 WebSocket Binary Frame 流式传输到服务端。
  4. 服务端完成 ASR + LLM 结构化提取后,通过 WebSocket 回传结果。
  5. 客户端用统一表单完成二次确认、地理位置补全、时间补全和提醒参数补全。
  6. 日程最终落到单一 schedules 表中,状态和关联信息一并保存。
  7. 提醒采用两种网络状态策略:在线播放个性化 TTS,离线播放固定提醒音频,前台和后台使用相同的弹窗与震动方式。

这一版的重点不是“完整日历”,而是“日程创建 + 状态驱动提醒 + 语音结构化”闭环。

2. 产品功能描述

2.1 核心目标

MVP 要验证三件事:

  1. 用户能不能快速创建日程。
  2. 系统能不能根据网络状态可靠选择提醒方式。
  3. 语音创建能不能稳定转成可确认、可执行的日程。

2.2 两种提醒情况

情况一:网络可用

触发方式:

  1. 客户端自定义弹窗。
  2. 震动。
  3. 播放云端预生成的个性化 TTS 音频。

适用场景:

  1. 客户端能够连接服务端并接收 WebSocket 消息。
  2. 不区分应用处于前台还是后台,统一使用同一种提醒组合。

情况二:网络不可用

客户端通过已登记的本地触发条件执行:

  1. 客户端自定义弹窗。
  2. 震动。
  3. 播放客户端预置的固定提醒音频。

断网时不访问云端音频或外部 TTS 服务。提醒方式只根据网络是否可用选择,不再根据应用前台或后台状态分流。

2.3 创建日程

日程支持两种入口:

  1. 手动创建。
  2. 语音创建。

二者共用一个表单。

语音创建流程:

  1. 客户端录音。
  2. 客户端通过 WebSocket Binary Frame 流式发送音频。
  3. 服务端返回上传受理结果。
  4. 服务端完成 ASR。
  5. 服务端完成 LLM 结构化提取。
  6. 服务端通过 WS 推送结构化日程草稿。
  7. 客户端弹出统一表单。
  8. 用户补全信息并确认。
  9. 客户端提交日程最终结果。
  10. 如果日程包含时间,服务端做时间冲突检测并返回提示。
  11. 创建成功后,服务端和客户端分别进入自己的提醒准备状态。

日程允许两种主类型:

形态 schedule_type 必要信息 触发条件
时间类 time start_time 进入时间提醒窗口
地点类 location latitude + longitude 用户进入地理围栏

语音创建时,schedule_type 由 LLM 根据用户语义做意图识别后输出。手动创建时,由前端根据用户选择或填写内容确定。 schedule_type 只表示主意图类型,时间和地点同时存在时仍按 time 落库,是否触发地点提醒由字段本身判断。

2.4 地理位置

表单内部支持:

  1. 地理位置选择。
  2. 地理围栏设置。
  3. 默认提醒参数补全。

默认值建议:

  1. 时间提前提醒:15min
  2. 地理围栏半径:100m

2.5 冲突检测

创建日程时如果填写了时间,必须做时间冲突检测。

如果目标时间段已有日程,服务端返回:

  1. 冲突提示。
  2. 冲突项列表。
  3. 是否允许继续创建的建议。

冲突检测是提示,不一定是硬拦截。

3. 架构总览

3.1 客户端

客户端负责:

  1. 界面展示。
  2. WebSocket 长连接。
  3. 音频录制。
  4. 音频上传。
  5. 表单编辑和二次确认。
  6. 本地日程缓存。
  7. 位置信息上报。
  8. 根据网络可用性选择提醒音频来源,不区分应用前台和后台状态。
  9. 展示客户端自定义提醒弹窗并执行震动。
  10. 在线时接收云端个性化 TTS 文件流并播放;离线时播放客户端预置的固定提醒音频。
  11. 在操作系统允许范围内,客户端预先注册自启动能力,用于进程异常终止后恢复客户端运行。

3.2 服务端

服务端负责:

  1. 音频文件接收。
  2. ASR 调用。
  3. LLM 结构化提取。
  4. WebSocket 消息分发。
  5. 日程冲突检测。
  6. 日程状态监控。
  7. 地点与时间窗口判断。
  8. 提醒控制消息下发。
  9. 客户端只根据网络是否可用选择个性化 TTS 或固定提醒音频,前台和后台不分流。
  10. 根据日程类型、标题和提醒提前量,通过 ReminderTemplateRenderer 生成固定格式的提醒文案,不调用 LLM。
  11. 日程创建成功后异步调用 TTS 生成音频;日程每次更新成功后都重新调用 TTS 合成流程。
  12. 每个日程使用唯一音频对象标识,将新生成的音频覆盖写入云端音频存储。
  13. 在线提醒触发时,根据日程 ID 从对象存储读取对应音频并通过 WebSocket 流式下发。

3.3 外部依赖

  1. 第三方 ASR 服务。
  2. 大模型服务。
  3. 第三方地图 SDK。
  4. Android 客户端震动、音频播放和后台运行能力。
  5. 外部 TTS 服务。
  6. 云端音频存储。

4. 模块拆分

4.1 客户端模块

4.1.1 界面模块

职责:

  1. 显示日程列表。
  2. 显示创建/编辑表单。
  3. 显示语音解析结果。
  4. 显示冲突提示。

4.1.2 语音录制模块

职责:

  1. 录音。
  2. 音频格式转换。
  3. 发起上传。

4.1.3 WebSocket 模块

职责:

  1. 建立和维护连接。
  2. 接收结构化草稿。
  3. 接收提醒控制消息。
  4. 上传位置信息。
  5. 上传日程确认结果。

4.1.4 本地存储模块

职责:

  1. 缓存日程。
  2. 缓存解析结果。
  3. 缓存提醒状态。
  4. 缓存网络状态和已登记的本地提醒触发条件。

4.1.5 提醒执行模块

职责:

  1. 统一展示客户端自定义弹窗。
  2. 执行震动。
  3. 网络可用时播放云端预生成的个性化 TTS 音频。
  4. 网络不可用时播放客户端预置的固定提醒音频。
  5. 提醒方式不根据应用前台或后台状态分流。

4.1.6 地图与位置模块

职责:

  1. 地点搜索。
  2. 地点确认。
  3. 地理围栏设置。
  4. 实时位置上报。

4.2 服务端模块

4.2.1 音频接入模块

职责:

  1. 接收音频文件。
  2. 校验格式和大小。
  3. 生成处理任务 ID。

4.2.2 语音解析模块

职责:

  1. 调用 ASR。
  2. 调用 LLM。
  3. 识别 schedule_type
  4. 生成结构化草稿。

4.2.3 WebSocket 网关

职责:

  1. 建立客户端会话。
  2. 维护设备在线状态。
  3. 分发解析结果和提醒控制消息。
  4. 接收位置上报。

4.2.4 日程服务模块

职责:

  1. 创建日程。
  2. 编辑日程。
  3. 查询日程。
  4. 冲突检测。

4.2.5 监控与调度模块

职责:

  1. 监听日程时间。
  2. 监听地理位置。
  3. 判断时间、空间以及组合触发条件。
  4. 进入提醒窗口后判断设备在线状态。
  5. 根据设备状态决定是否触发软件提醒。

4.2.6 地点判定模块

职责:

  1. 根据上报位置和日程坐标计算距离。
  2. 判断是否进入地理围栏。

4.2.7 TTS 音频下发模块

职责:

  1. 根据日程类型使用固定模板生成提醒文案,不调用 LLM。
  2. 时间类模板为 您有一个日程,{相对时间描述},{title}。;相对时间根据 time_remind_offset_minutes 生成,不按照日程创建时刻计算。
  3. 地点类模板为 您已到达目标地点附近,别忘了{title}。
  4. 日程创建成功后立即异步生成音频;日程每次更新成功后都重新调用 TTS 合成流程,其中 titleschedule_typetime_remind_offset_minutes 是影响提醒内容的字段。
  5. 每个日程使用 reminder-audio/{schedule_id}.{audio_format} 作为音频对象标识,audio_format 必须与实际存储文件格式一致。
  6. 日程更新后,旧音频在新 TTS 任务开始时立即失效;新生成的完整音频通过原子写入成为当前音频,每个日程只保留一个音频文件。
  7. 异步任务写入前必须重新校验日程 updated_at;任务对应的版本不是最新版本时,丢弃生成结果,禁止覆盖当前音频。
  8. 不向 schedules 或其他业务表写入 TTS 文案、文件路径和生成状态字段。
  9. 在线提醒触发时,根据 schedule_id 读取并下发对应音频;音频不存在时仍下发 reminder.control,但不发送音频流。

5. 数据库设计

5.1 表名:schedules

MVP 阶段只保留一张核心业务表。

用途:

  1. 存储日程本体。
  2. 存储日程状态。
  3. 存储地点与提醒关联信息。
  4. 存储地理围栏布防状态。

5.2 推荐字段

字段 类型 说明
id text 主键,日程 ID
user_id text 默认用户 ID
source_mode text manual / voice
schedule_type text time / location
status text scheduled / done / deleted
title text 标题
notes text 备注
start_time text 开始时间,持久化为 UTC ISO-8601,可以为空
end_time text 结束时间,持久化为 UTC ISO-8601,可以为空
timezone text 原始 IANA 时区,用于前端本地化展示,可为空
location_name text 地点名称
location_address text 地点地址
latitude real 纬度
longitude real 经度
geofence_radius_meters integer 地理围栏半径,默认 100m
geofence_armed integer 地理围栏是否已布防,默认由服务端根据创建时用户位置计算
time_remind_offset_minutes integer 时间提前提醒,默认 15min
time_triggered_at text 时间提醒触发时间,可为空
geo_triggered_at text 地点提醒触发时间,可为空
system_schedule_ref_id text 服务端内部提醒事件绑定 ID,可为空
system_alarm_ref_id text 预留字段
created_at text 创建时间
updated_at text 更新时间

5.3 设计原则

  1. 核心字段尽量扁平化。
  2. 语音解析草稿只通过 WS 传递,不落库。
  3. 时间提醒和地理提醒可以同时存在。
  4. 只保留一张主表,MVP 不拆分额外业务表。
  5. status 只表达日程本体是否还有效,不表达监听中、已触发、已过期等过程状态。
  6. MVP 不保存用户自定义的重要程度,提醒方式只根据网络状态选择,提醒触发由时间/空间窗口决定。
  7. 冲突检测结果只在接口响应中返回,不写入 schedules 表。
  8. start_time 和地点信息都允许为空,但二者不能同时为空。
  9. geofence_armed 用于避免“用户在目标地点创建日程后立刻触发位置提醒”。
  10. 最近一次位置只在会话内或内存中计算,不落库。
  11. schedule_type 是前端表单必填项控制和后端校验的依据,不再使用全天字段区分业务类型。
  12. LLM 草稿时间使用 YYYY-MM-DDTHH:mm 本地时间格式,由程序注入 IANA 时区。
  13. 最终提交的无偏移时间必须同时提供 timezone;带偏移时间可直接提交。
  14. 服务端校验时间后统一转换为固定 UTC ISO-8601 格式持久化。
  15. TTS 音频使用日程数据和固定模板派生,不在 schedules 表中增加音频路径、文案或生成状态字段。
  16. 每个日程只保留一个 TTS 文件,日程更新后使用新生成的完整音频覆盖原文件。

6. 状态机

6.1 日程状态

建议状态流转:

scheduled -> done
scheduled -> deleted

状态说明:

状态 含义
scheduled 已创建,等待提醒或正在监听
done 用户已确认完成
deleted 用户删除

不进入 status 的过程信息:

信息 处理方式
草稿待确认 语音解析结果通过 WS 推给前端,用户确认前不创建正式日程
时间监听中 根据 start_timetime_remind_offset_minutes 和当前时间动态判断
地理监听中 根据经纬度、围栏半径、最近位置和 geofence_armed 动态判断
已触发提醒 写入 time_triggered_atgeo_triggered_at
已过期 根据 start_timeend_time 动态判断
创建冲突 只在接口响应中返回 conflicts,不持久化

7. 接口文档

7.0 WebSocket 消息约定

业务接口统一使用 WebSocket。JSON Text Frame 承载控制消息、命令、查询、结果和错误;Binary Frame 承载音频数据。

客户端请求信封:

{
  "type": "schedule.upsert.command",
  "request_id": "req_schedule_001",
  "payload": {}
}

服务端成功响应:

{
  "type": "schedule.upsert.result",
  "request_id": "req_schedule_001",
  "ok": true,
  "payload": {}
}

服务端失败响应:

{
  "type": "schedule.upsert.error",
  "request_id": "req_schedule_001",
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求参数不合法",
    "details": {
      "field": "start_time"
    }
  }
}

字段说明:

字段 类型 说明
type string 消息类型
request_id string 请求关联和幂等 ID
ok boolean 服务端是否成功处理
payload object/null 业务数据
error.code string 程序可识别的错误码
error.message string 面向前端展示或日志记录的错误说明
error.details object/null 具体错误上下文,可为空

WebSocket 业务失败不依赖 HTTP 状态码,通过 *.errorok=falseerror 对象表达。

7.1 WebSocket 音频流

作用

  1. 建立单次语音流。
  2. 使用 Binary Frame 持续发送音频分片。
  3. 触发后续 ASR + LLM 流程。

开始音频流

客户端发送 JSON Text Frame:

{
  "type": "voice.stream.start",
  "request_id": "req_audio_001",
  "payload": {
    "audio_format": "pcm_s16le",
    "sample_rate_hz": 16000,
    "channels": 1
  }
}

服务端响应:

{
  "type": "voice.stream.started",
  "request_id": "req_audio_001",
  "ok": true,
  "payload": {
    "stream_id": "stream_audio_001",
    "job_id": "job_audio_001"
  }
}

发送音频数据

voice.stream.started 成功后,客户端通过同一条 WebSocket 连接发送 Binary Frame。

同一设备连接同一时刻只允许一个活动音频流,因此 Binary Frame 自动归属于当前活动的 stream_id,帧内不重复携带流 ID。服务端不发送逐分片 ACK,客户端通过 WebSocket 传输层背压控制发送速度。

结束音频流

{
  "type": "voice.stream.end",
  "request_id": "req_audio_001",
  "payload": {
    "stream_id": "stream_audio_001"
  }
}

服务端受理:

{
  "type": "voice.stream.ended",
  "request_id": "req_audio_001",
  "ok": true,
  "payload": {
    "stream_id": "stream_audio_001",
    "job_id": "job_audio_001",
    "status": "processing"
  }
}

失败响应

音频格式不支持:

{
  "type": "voice.stream.error",
  "request_id": "req_audio_001",
  "ok": false,
  "error": {
    "code": "UNSUPPORTED_AUDIO_FORMAT",
    "message": "音频格式不支持",
    "details": {
      "audio_format": "aac",
      "supported_formats": ["pcm_s16le"]
    }
  }
}

音频流过大或超过时长限制:

{
  "type": "voice.stream.error",
  "request_id": "req_audio_001",
  "ok": false,
  "error": {
    "code": "AUDIO_STREAM_LIMIT_EXCEEDED",
    "message": "音频流超过限制",
    "details": {
      "max_duration_ms": 120000
    }
  }
}

其他错误码:

错误码 含义
UNSUPPORTED_AUDIO_CONFIG 采样率或声道数不受支持
VOICE_STREAM_ALREADY_ACTIVE 当前设备已有活动音频流
VOICE_STREAM_NOT_ACTIVE 结束流时不存在活动音频流
VOICE_STREAM_ID_MISMATCH 结束消息中的流 ID 与活动流不一致
EMPTY_AUDIO_CHUNK Binary Frame 不包含音频数据
EMPTY_AUDIO_STREAM 音频流结束前未收到有效音频数据
UNEXPECTED_BINARY_FRAME 尚未开始音频流便发送 Binary Frame

说明:

  1. 不再提供 HTTP 音频上传接口。
  2. JSON Text Frame 只传控制信息,音频内容只通过 Binary Frame 发送。
  3. 真正的结构化结果仍通过 voice.parse.result 返回。
  4. MVP 只接收 pcm_s16le16000Hz、单声道音频,最长 120000ms
  5. voice.stream.ended 必须先于对应的 voice.parse.result 发送。

7.2 WebSocket 日程创建/更新

消息类型

schedule.upsert.command

作用

  1. 手动创建日程。
  2. 语音表单确认后提交日程。
  3. 写入最终状态。
  4. 执行时间冲突检测。

客户端消息

{
  "type": "schedule.upsert.command",
  "request_id": "req_schedule_001",
  "payload": {
    "schedule_id": null,
    "source_mode": "voice",
    "schedule_type": "time",
    "title": "开会",
    "notes": null,
    "start_time": "2026-07-31T15:00",
    "end_time": null,
    "timezone": "Asia/Shanghai",
    "location_name": "陆家嘴",
    "location_address": null,
    "latitude": 31.2451,
    "longitude": 121.5067,
    "geofence_radius_meters": 100,
    "geofence_armed": true,
    "time_remind_offset_minutes": 15
  }
}

请求字段保持原日程接口定义:

字段 类型 必填 说明
request_id string 幂等 ID,位于消息信封
schedule_id string/null 编辑时传
source_mode string manual / voice
schedule_type string time / location
title string 标题
notes string/null 备注
start_time string/null ISO-8601 开始时间;无 UTC 偏移时必须同时提供 timezone
end_time string/null ISO-8601 结束时间;无 UTC 偏移时必须同时提供 timezone
timezone string/null IANA 时区,例如 Asia/Shanghai
location_name string/null 地点名称
location_address string/null 地点地址
latitude number/null 纬度
longitude number/null 经度
geofence_radius_meters integer/null 地理围栏半径,默认 100
geofence_armed boolean/null 地理围栏是否已布防,不传则服务端计算
time_remind_offset_minutes integer/null 时间提醒提前量,默认 15

成功响应

{
  "type": "schedule.upsert.result",
  "request_id": "req_schedule_001",
  "ok": true,
  "payload": {
    "schedule_id": "schedule_001",
    "schedule_type": "time",
    "status": "scheduled",
    "conflicts": [],
    "geofence_armed": true
  }
}

冲突响应

{
  "type": "schedule.upsert.result",
  "request_id": "req_schedule_001",
  "ok": true,
  "payload": {
    "schedule_id": "schedule_001",
    "schedule_type": "time",
    "status": "scheduled",
    "conflicts": [
      {
        "schedule_id": "schedule_older",
        "title": "已有日程",
        "start_time": "2026-07-28T15:00:00+08:00",
        "end_time": "2026-07-28T16:00:00+08:00"
      }
    ],
    "geofence_armed": true
  }
}

失败响应

{
  "type": "schedule.upsert.error",
  "request_id": "req_schedule_001",
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求参数不合法",
    "details": {
      "field": "schedule_type",
      "reason": "schedule_type 为 time 时 start_time 必填;为 location 时 latitude 和 longitude 必填"
    }
  }
}

规则:

  1. 手动创建和语音确认都使用 schedule.upsert.command
  2. 时间冲突只给提示,不默认阻断。
  3. schedule_type=time 时,前端表单要求填写时间,地点可选。
  4. schedule_type=location 时,前端表单要求填写地点,时间可选。
  5. 用户同时填写时间和地点时,schedule_type 仍按 time 处理。
  6. 只有 start_time 存在时才做时间冲突检测。
  7. 只有经纬度存在时才做地理围栏监听。
  8. 如果 geofence_armed 不传,服务端根据最近一次位置上报与目标地点距离计算默认值。
  9. 服务端拒绝非法日期、无时区上下文的本地时间以及早于 start_timeend_time
  10. 服务端在冲突检测和持久化前统一将时间转换为 UTC。

7.3 WebSocket 日程列表查询

消息类型

schedule.list.query

作用

  1. 获取当前用户的日程数据。
  2. 支持前端进入页面后初始化列表。
  3. 支持前端恢复本地状态和服务端状态对齐。

客户端消息

{
  "type": "schedule.list.query",
  "request_id": "req_schedule_list_001",
  "payload": {
    "status": null,
    "include_deleted": false
  }
}

服务端响应

{
  "type": "schedule.list.result",
  "request_id": "req_schedule_list_001",
  "ok": true,
  "payload": {
    "schedules": [
      {
        "id": "schedule_001",
        "user_id": "default_user",
        "source_mode": "voice",
        "schedule_type": "time",
        "status": "scheduled",
        "title": "开会",
        "notes": null,
        "start_time": "2026-07-29T07:00:00+00:00",
        "end_time": null,
        "timezone": "Asia/Shanghai",
        "location_name": "陆家嘴",
        "location_address": null,
        "latitude": 31.2451,
        "longitude": 121.5067,
        "geofence_radius_meters": 100,
        "geofence_armed": true,
        "time_remind_offset_minutes": 15,
        "time_triggered_at": null,
        "geo_triggered_at": null,
        "created_at": "2026-07-28T12:00:00+08:00",
        "updated_at": "2026-07-28T12:00:00+08:00"
      }
    ]
  }
}

失败响应

{
  "type": "schedule.list.error",
  "request_id": "req_schedule_list_001",
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求参数不合法",
    "details": {
      "field": "status"
    }
  }
}

规则:

  1. 用户身份由 WebSocket 会话上下文确定,客户端不传 user_id
  2. 默认只返回 scheduleddone
  3. include_deleted=true 时才返回 deleted 数据。
  4. 返回结果按规范化后的 UTC start_time asc nulls last, created_at desc 排序。

7.4 WebSocket 连接

地址

ws://<host>/ws?device_id=xxx

作用

  1. 保持设备在线。
  2. 回传语音结构化结果。
  3. 下发提醒控制消息。
  4. 接收位置信息和确认事件。

连接建立

客户端进入界面后先发:

{
  "type": "session.hello",
  "device_id": "android_abc123",
  "app_version": "1.0.0"
}

服务端返回:

{
  "type": "session.ready",
  "device_id": "android_abc123",
  "server_time": "2026-07-28T12:00:00Z"
}

连接失败或鉴权失败时,服务端返回错误消息后关闭连接:

{
  "type": "session.error",
  "ok": false,
  "error": {
    "code": "INVALID_DEVICE_ID",
    "message": "设备 ID 不合法",
    "details": null
  }
}

7.5 语音结构化结果推送

服务端消息

{
  "type": "voice.parse.result",
  "request_id": "req_audio_001",
  "job_id": "job_audio_001",
  "status": "ready_for_confirmation",
  "draft": {
    "schedule_type": "time",
    "title": "开会",
    "start_time": "2026-07-29T15:00:00+08:00",
    "end_time": null,
    "timezone": "Asia/Shanghai",
    "location_name": "陆家嘴",
    "geofence_radius_meters": 100,
    "time_remind_offset_minutes": 15
  },
  "missing_fields": ["location_address"],
  "ambiguous_fields": [],
  "needs_confirmation": true
}

失败消息

ASR 或 LLM 处理失败时,服务端通过 WS 推送失败结果:

{
  "type": "voice.parse.result",
  "request_id": "req_audio_001",
  "job_id": "job_audio_001",
  "status": "failed",
  "error": {
    "code": "VOICE_PARSE_FAILED",
    "message": "语音解析失败,请重新录音或手动创建",
    "details": {
      "stage": "asr"
    }
  }
}

前端处理

  1. 弹出统一表单。
  2. 默认填充结构化字段。
  3. 允许用户补全地点、时间和提醒参数。

7.6 位置信息上报

客户端消息

{
  "type": "location.report",
  "schedule_scope": "current",
  "latitude": 31.2451,
  "longitude": 121.5067,
  "accuracy": 18,
  "timestamp": "2026-07-28T12:01:00+08:00"
}

服务端响应

{
  "type": "location.report.ack",
  "ok": true
}

失败响应:

{
  "type": "location.report.ack",
  "ok": false,
  "error": {
    "code": "INVALID_LOCATION",
    "message": "位置信息不合法",
    "details": {
      "field": "latitude"
    }
  }
}

说明

  1. 服务端根据当前位置计算日程距离。
  2. 如果日程包含地点且命中围栏,判断当前是否满足提醒条件。
  3. 若日程还包含时间,再结合时间窗口决定是否下发提醒控制消息。

7.7 提醒控制消息

服务端只下发“该提醒了”。客户端不区分应用前台和后台:网络可用时统一执行弹窗、震动和个性化 TTS;网络不可用时由本地触发条件执行弹窗、震动和固定提醒音频。

服务端消息

{
  "type": "reminder.control",
  "schedule_id": "schedule_001",
  "reason": "time_window_reached",
  "action": "show"
}

客户端执行成功时回传:

{
  "type": "reminder.control.ack",
  "schedule_id": "schedule_001",
  "ok": true
}

客户端执行失败时回传:

{
  "type": "reminder.control.ack",
  "schedule_id": "schedule_001",
  "ok": false,
  "error": {
    "code": "REMINDER_DISPLAY_FAILED",
    "message": "提醒展示失败"
  }
}

7.8 日程删除消息

作用:

  1. 由客户端主动告诉服务端,用户已经在应用内确认该日程完成。
  2. 关闭后续监听和所有提醒。

客户端消息

{
  "type": "schedule.confirmed",
  "schedule_id": "schedule_001",
  "confirmed": true,
  "timestamp": "2026-07-28T12:05:00+08:00"
}

服务端处理

  1. 取消监听。
  2. 终止后续提醒。

服务端响应

{
  "type": "schedule.confirmed.ack",
  "schedule_id": "schedule_001",
  "ok": true
}

失败响应:

{
  "type": "schedule.confirmed.ack",
  "schedule_id": "schedule_001",
  "ok": false,
  "error": {
    "code": "SCHEDULE_CONFIRM_FAILED",
    "message": "日程确认失败",
    "details": {
      "reason": "schedule_not_found"
    }
  }
}

7.9 提醒音频下发接口

作用:

  1. 日程触发提醒时,由服务端向在线客户端下发云端预生成的 TTS 音频。
  2. 音频通过 WebSocket Binary Frame 发送。
  3. 客户端完成播放后向服务端返回播放结果。

音频预生成

  1. 日程创建成功后,服务端立即提交异步 TTS 生成任务,不等待音频生成完成再返回日程创建结果。
  2. 时间类日程使用 您有一个日程,{相对时间描述},{title}。 模板。
  3. 服务端直接将 time_remind_offset_minutes 格式化为相对时间描述。例如字段值为 15 时,文案为 您有一个日程,十五分钟后,项目评审会议。
  4. start_time 只用于计算提醒触发时刻,不参与 TTS 文案计算;相对时间也不按照音频生成时刻与 start_time 的实际差值计算。
  5. 地点类日程使用 您已到达目标地点附近,别忘了{title}。 模板。
  6. 时间和地点同时存在且 schedule_type=time 时,使用时间类模板。
  7. 日程每次更新成功后,服务端都必须提交新的异步 TTS 生成任务;任务确认自身对应当前 updated_at 后,先使旧音频失效,再调用 TTS。
  8. 日程更新与 TTS 生成解耦;TTS 生成失败不影响日程创建或更新,也不得恢复或继续发送旧版本音频。

音频存储与覆盖

  1. 每个日程使用 reminder-audio/{schedule_id}.{audio_format} 作为音频对象标识;audio_format 来源于实际文件后缀,不通过音频内容猜测。
  2. TTS 生成完成后,将完整音频原子写入当前对象,不保留历史版本;同一日程不得同时保留多个格式的音频文件。
  3. 异步生成任务必须携带任务创建时的日程 updated_at
  4. 覆盖写入前,服务端重新读取日程;只有任务携带的 updated_at 与当前值一致时才允许写入,否则直接丢弃该过期任务的生成结果。
  5. 文件必须在生成完整后再执行原子覆盖,客户端不得读取到未完成的音频内容。
  6. 日程被删除时,同时删除其对应的 TTS 音频文件。
  7. 日程更新开始生成新音频时,必须先删除旧音频;生成失败时保持无音频状态,禁止继续使用旧内容。

服务端消息

提醒触发时,服务端先下发 reminder.control,再发送音频流开始消息:

{
  "type": "reminder.audio.start",
  "schedule_id": "schedule_001",
  "stream_id": "stream_audio_001",
  "audio_format": "mp3"
}

随后,服务端通过同一条 WebSocket 连接发送 Binary Frame。

音频发送完成后,服务端发送:

{
  "type": "reminder.audio.end",
  "schedule_id": "schedule_001",
  "stream_id": "stream_audio_001"
}

如果提醒触发时音频不存在、尚未生成完成或生成失败,服务端仍发送 reminder.control,但跳过 reminder.audio.start、Binary Frame 和 reminder.audio.end。客户端继续执行普通提醒,不将其视为设备离线。

客户端处理

  1. 接收服务端下发的音频。
  2. 音频接收完成后播放,并返回播放结果。
  3. 音频接收或播放失败时,返回明确的失败响应。

客户端响应

接收成功:

{
  "type": "reminder.audio.ack",
  "schedule_id": "schedule_001",
  "stream_id": "stream_audio_001",
  "ok": true
}

接收失败:

{
  "type": "reminder.audio.ack",
  "schedule_id": "schedule_001",
  "stream_id": "stream_audio_001",
  "ok": false,
  "error": {
    "code": "REMINDER_AUDIO_PLAY_FAILED",
    "message": "提醒音频接收或播放失败"
  }
}

8. 监听与提醒策略

8.1 时间监听

规则:

  1. 只有 start_time 存在的日程才参与时间监听。
  2. 时间到达前进入监测窗口。
  3. 默认提前 15min
  4. 进入窗口后判断网络是否可用,不读取应用前台或后台状态。
  5. 网络可用且 WS 在线时,服务端直接下发 reminder.control
  6. 服务端在 reminder.control 后通过提醒音频下发接口发送该日程对应的 TTS 音频;音频不可用时只发送控制消息。
  7. 网络不可用时,客户端通过已登记的本地触发条件执行弹窗、震动和固定提醒音频。

8.2 空间监听

规则:

  1. 只有存在经纬度的日程才参与空间监听。
  2. 默认围栏半径 100m
  3. 客户端持续上传位置,服务端判定是否进入围栏。
  4. 创建日程时,如果用户当前位置已经在目标围栏内,服务端将 geofence_armed=false
  5. 当用户离开目标围栏后,服务端将 geofence_armed=true
  6. 只有 geofence_armed=true 且用户再次进入围栏时,才允许触发地点提醒。
  7. 如果创建时无法取得用户当前位置,服务端默认 geofence_armed=true,避免错过后续进入提醒。
  8. 地点提醒触发且网络可用、WS 在线时,服务端在 reminder.control 后下发地点类日程的预生成 TTS 音频;音频不可用时只发送控制消息。
  9. 网络不可用时,客户端通过已登记的本地地理触发条件执行弹窗、震动和固定提醒音频。

8.3 触发规则

schedule_type 日程形态 触发规则
time 只有时间 时间窗口到达后触发
location 只有地点 geofence_armed=true 且用户进入地理围栏后触发

如果日程同时包含时间和地点,则 schedule_type 仍为 time,但提醒执行时同时参考时间窗口和地理围栏条件。

8.4 监听取消

满足以下任一条件时取消监听:

  1. 用户通过确认接口标记已完成。
  2. 有时间的日程时间已过。
  3. 日程被删除。

9. 冲突检测

规则

  1. 只有 start_time 存在时才检查时间区间重叠。
  2. 如果已有日程落在同一时间段,返回冲突提示。
  3. 冲突结果包含已有日程的标题、时间和 ID。
  4. 只有地点、没有时间的日程不做时间冲突检测。

交互建议

  1. 提示用户“当前时段已有日程”。
  2. 用户可继续保存,也可修改时间。
  3. 冲突不是强制失败,但必须明确提示。

Clone this wiki locally