外观
联系人聊天功能(IM 模块)详细方案
专用文档:本文档为联系人/聊天功能唯一详细设计文档,各端功能说明仅做索引引用与概要,实现细节一律以本文档为准(跨端契约 §6.7、表结构 §4、接口 §6、时序 §7)。
对标微信的通讯录 + 即时聊天能力。本文档从微信真实聊天场景出发逐场景拆解,给出可直接落地的完整设计: 好友关系、会话、多类型消息(文字/图片/语音/视频/文件)、聊天记录处理、已读/撤回/转发/引用、实时推送、离线补拉、多设备、通知体系。 设计目标:零耦合、可扩展 —— 独立模块独立建表,不修改现有表;消息/会话类型注册表驱动扩展。 开发前提:无存量用户、无旧版本兼容负担 —— 不写数据库迁移脚本(直接建表/改表),不兼容旧版 App,不留历史数据迁移逻辑。
1. 概述
1.1 与微信的功能对照(完整)
| 微信功能 | 本方案实现 | 状态 |
|---|---|---|
| 通讯录(好友列表) | 联系人列表 + 备注名 + 标签分组 | 一期 |
| 添加好友:搜索手机号/用户ID | POST /api/contacts/requests | 一期 |
| 添加好友:扫码添加 | 用户名片码 user:{id},扫码路由新增分支 → 资料页 → 添加 | 一期 |
| 验证消息 + 通过/拒绝 | contact_requests(PENDING/ACCEPTED/REJECTED) | 一期 |
| 隐私设置:加好友是否需验证 | system_settings: chat.require_verification(默认 true) | 一期 |
| 黑名单(单向,对方无感知) | contact_blocks | 一期 |
| 删除好友(单向视角) | 只删自己一侧关系行 | 一期 |
| 会话列表(置顶/免打扰/未读角标/摘要/时间) | conversations + members 偏好 + 冗余摘要 | 一期 |
| 草稿(会话列表"草稿: xxx"优先展示) | members.draft 字段 | 一期 |
| 单聊(懒创建,发消息才建会话) | ChatService 懒建 | 一期 |
| 文件传输助手(自己和自己聊天) | SINGLE 会话 peerUserId == 自己,允许 | 一期 |
| 文字消息 | TEXT | 一期 |
| 图片消息(多图 9 张、缩略图) | IMAGE + OSS 图片处理缩略图 | 一期 |
| 语音消息(≤60s、未播红点、转文字预留) | VOICE + durationMs + transcript 预留 | 一期 |
| 视频消息(封面 + 时长) | VIDEO | 一期 |
| 文件消息(名称/大小/下载状态) | FILE | 一期 |
| 位置消息 | LOCATION | 扩展 |
| 名片/业务卡片 | CARD(cardType 扩展) | 扩展 |
| 消息状态:发送中/失败重发/已读 | 客户端 SENDING/FAILED + 服务端 SENT/READ | 一期 |
| 已读回执(单聊"已读") | 会话级已读游标 + WS read.receipt | 一期 |
| 消息撤回(2 分钟内,双方可见"撤回") | RECALLED 状态,content 留痕审计 | 一期 |
| 删除单条消息(本地删除) | 客户端 Room 删除,服务端不动 | 一期 |
| 清空聊天记录(单方) | members.last_cleared_message_id 游标清空 | 一期 |
| 删除会话(记录保留,新消息复活) | members.is_deleted | 一期 |
| 引用回复 | messages.reply_to_id | 一期 |
| 逐条/合并转发 | POST /api/chats/messages/forward + forward-merged | 一期+二期✅ |
| 聊天记录本地搜索 | Room FTS4(复用现有 FTS 经验) | 一期 |
| 聊天记录云端漫游/迁移 | 服务端全量存储 + 增量 sync(比微信更强) | 一期 |
| 聊天记录导出 | GET /api/chats/export(返回 JSON,客户端保存) | 二期✅ |
| 管理后台:聊天设置 | ChatSettings 设置页:文件/媒体限制、联系人上限、行为开关 | 一期 |
| 管理后台:聊天统计 | 会话/消息总量、今日消息、活跃会话只读卡片 | 一期 |
| 管理后台:审计/封禁 | 聊天记录查询、禁言(chat:audit / chat:ban) | 三期 |
| 扫一扫加好友(我的二维码) | 名片码 user:{随机码} + 扫码路由新增分支;可刷新重置(旧码作废) | 一期 |
| 隐私:手机号搜索/验证/名片码开关 | 个人聊天设置 user_chat_settings(NULL=跟随全局) | 一期 |
| 标为未读 / 回到底部 / 免打扰红点 | 本地行为 + 成员偏好渲染(免打扰=红点不带数字) | 一期 |
| 消息内容识别(链接/手机号) | 客户端 AnnotatedString 识别;链接应用内 WebView 打开 | 一期 |
| 流量节省 / 聊天存储管理 | 本地设置:移动网络不自动下载原图视频;按会话清理缓存 | 一期 |
| 文件传输助手 | 自己与自己聊天(A==A 会话),手机↔平板传文件 | 一期 |
| 多设备同时在线 + 已读同步 | /ws/chat 多连接 + 服务端已读游标 | 一期 |
| 离线消息 | 在线 WS 推送 + 断线重连 afterId 增量补拉 | 一期 |
| 离线系统通知(App 被杀) | WS 兜底轮询(WorkManager 周期 sync);不做厂商推送 | 一期兜底 |
| 免打扰时段/勿扰模式 | 会话级 muted + 全局勿扰时段(chat.dnd_start/end) | 一期+二期✅ |
| 群聊 | 完整实现:建群/邀请/踢人/转让/解散/公告/禁言/群码 | 二期✅ |
| 语音/视频通话 | 预留(走 WebRTC 独立通道,评估后实施) | 远期 |
| 红包/表情/收藏/朋友圈 | 不做(超出 IM 边界) | — |
1.2 设计原则
- 零耦合:全部新表(
contact_*/conversation_*/messages),不触碰现有表;独立com.codenote.chat包;只复用公共设施(JWT、OSS、用户体系、扫码路由)。 - 类型驱动:消息体统一
msg_type + content(JSON),语义全在 content;新类型只注册 Handler。 - 懒会话:单聊会话不预建,首条消息时自动创建(微信同款)。
- 幂等:每条消息带
client_msg_id(UUID),唯一键去重,断网重发安全。 - 游标同步:消息 id 全局自增即会话内单调序号,承担四重职责:历史分页游标、增量补拉游标、已读游标、清空游标。
- 存储与传输解耦:媒体一律 OSS 直传(STS),消息表只存 URL + 元数据。
- 删除语义分级(对齐微信):撤回=双方可见标记;删消息=本地;清空=服务端游标;删会话=成员级标记。互不混淆。
1.3 复用现有设施
| 现有设施 | 复用方式 |
|---|---|
JWT 认证 + JwtAuthFilter | 所有聊天接口登录态校验 |
WebSocketAuthInterceptor | 抽取公共 WsAuthInterceptor,聊天与通知共用 |
FileService / StsService / FileController | 媒体上传,消息只引用 URL |
QrCodeService / 扫码路由 | 用户名片码 user:{id} 扫码添加好友 |
users(昵称/头像/signature) | 联系人展示、消息发送者信息 |
NotificationService | 好友申请/通过的低频站内通知(可选) |
DataCleanupService | 聊天记录保留期定时清理(任务模式复用) |
| Android FTS4(现有扫码/二维码搜索经验) | 聊天记录本地全文搜索 |
system_settings | 全局开关:陌生人会话/验证开关/保留期/配额 |
2. 场景模拟(微信聊天全流程推演)
每个场景 = 用户视角对话剧本 + 系统行为 + 提炼出的设计点。设计点已全部落入 §4-§10。
2.1 场景 A:添加好友 → 开始聊天
小蓝(A)通讯录 → "添加朋友" → 搜索手机号 138xxxx → 结果:小绿(B,昵称+头像)
→ 点"添加到通讯录" → 验证消息"我是小蓝" → 发送
[系统行为]
A 侧:申请记录状态 PENDING,提示"已发送,等待验证"
B 侧:WS 推送 contact.request + 站内通知;通讯录"新的朋友"红点 +1
B 打开申请列表 → 看到申请卡片(验证消息+来源)→ 点"通过"
[系统行为]
事务:contact_requests → ACCEPTED;contacts 双向各插一行
B 侧:通讯录出现小蓝;自动进入与小蓝的会话?—— 微信不自动建会话(懒会话),但可插入系统消息「你已添加了小蓝,现在可以开始聊天了」(仅 B 视角,或双方)
A 侧:WS 推送 contact.accepted;通讯录出现小绿;同样系统消息
B 若点"拒绝" → A 收到"对方拒绝了你的添加请求",申请置 REJECTED(A 可再次申请,重新建 PENDING)设计点:
- 申请幂等:同 (requester, target) 已有 PENDING 时直接返回原记录,不重复
- 重复申请被拒后再申请 → 允许重新创建(先失效旧 REJECTED 记录)
- 来源追溯:申请带
source(SEARCH/QRCODE/QRCODE_SCAN),列表展示"通过手机号搜索""通过二维码扫描" - 通过后系统消息
SYSTEM(sender_id=0),会话若不存在则创建(此时才建会话) - 全局红点:
GET /api/contacts/requests/incoming?status=PENDING数量 = "新的朋友"角标
2.2 场景 B:多类型消息(文字/语音/图片/视频/文件)
小蓝进入与小绿的会话 → 输入"晚上一起吃饭?" → 发送
→ 气泡右侧灰底(本人),左侧白色(对方)
→ 长按麦克风 → 说"七点老地方" → 松开发送 → 气泡显示 [3″] 波形 + 未播红点
→ 点 "+" → 选照片 3 张 → 发送 → 九宫格网格(≤9 张)
→ 点 "+" → 选视频 → 发送 → 封面 + 播放按钮
→ 点 "+" → 选文件 → 发送 → [图标] 文件名.pdf + 2.0MB + 下载按钮
小绿:点击语音 → 播放 → 红点消失;点图片 → 大图预览(加载缩略图,点"查看原图")
点视频 → 全屏播放;点文件 → 下载 → 打开设计点:
- 发送顺序:本地先落库(SENDING)→ 上传媒体(OSS)→ 发消息 API → 回填 id → SENT。先传后发,避免消息发出但文件 404
- 多图:一次请求可带
attachments: [{...}],服务端拆分为多条消息(保持顺序)返回数组;客户端渲染网格 - 语音:≤60s;未播状态存本地(
MessageEntity.isVoicePlayed),服务端不管 - 图片加载分级:消息存原图 URL,客户端展示用 OSS 图片处理缩略图
?x-oss-process=image/resize,w_400,点击查看原图 - 视频:客户端发送前生成封面图(首帧截图),上传封面 + 视频两个对象
- 文件:
mime用于图标和打开方式;下载进度本地管理;OSS 直传 URL 有效期(STS 900s)——下载走 OSS 公开读(现有 bucket 即公开读,无需额外处理)
2.3 场景 C:消息状态与已读
小蓝发送"在吗?" → 气泡旁转圈 0.3s → 变为无标记(已送达)
小绿打开会话 → 小蓝看到消息下方"已读"
小蓝飞行模式发"明天开会" → 气泡旁红色感叹号 → 点感叹号 → 重发成功
小蓝在小绿回复前锁屏 → 小绿回"好的" → 小蓝解锁打开 App → 未读 +1设计点:
- 消息状态机(客户端):
SENDING → SENT(服务端确认)/SENDING → FAILED(超时/网络错,可重发) - 重发 = 复用同一
client_msg_id再次 POST,服务端幂等返回原消息,本地状态复位 - 已读:会话级游标(单聊够用);进入会话即上报
lastReadMessageId;对方在线收到read.receipt显示"已读" - 未读角标:会话列表接口返回
unreadCount;全局未读 = 各会话之和(首页角标,Android 用 Room 聚合) - 发送超时阈值:10s 无响应转 FAILED(弱网可配)
2.4 场景 D:撤回 / 引用 / 转发
小蓝发"明天见!" → 发现发错 → 长按 → 撤回
→ 双方气泡变灰字「你撤回了一条消息」/「对方撤回了一条消息」(2 分钟内可撤)
小绿长按"在吗?" → 引用 → 输入"在的" → 发送
→ 新消息上方显示引用块(浅色条 + 被引用消息摘要),点击跳到原文
小蓝想把聊天记录转给同事小红 → 长按多选 5 条 → 逐条转发 → 选小红 → 发送设计点:
- 撤回时限 2 分钟(微信同款);仅发送者本人;撤回后服务端
status=RECALLED+content保留(合规留痕,客户端不展示) - 引用:
messages.reply_to_id;转发时引用关系随消息体保留(被引用消息若是图片等,摘要取summary()) - 逐条转发:
POST /forward {targetUserId, messageIds:[...]}→ 服务端把消息复制到目标会话(保留 msgType/content,新 clientMsgId,sender 为转发者)→ 按序推送 - 合并转发(二期):生成一条
CARD {cardType:"CHAT_RECORD"}卡片消息,content 内嵌 N 条消息摘要 - 多选模式为纯客户端交互,服务端只收批量接口
2.5 场景 E:离线 / 多设备
小蓝手机锁屏(App 后台)→ 小绿发消息
→ 手机通知栏出现「小绿:晚上一起吃饭?」(App 内长连接兜底轮询拉取)
小蓝打开 App → 长连接重连 → 补拉断线期间消息 → 会话列表未读 +1 → 进会话已读
小蓝用平板登录 → 手机/平板同时在线
→ 手机上已读的消息,平板自动同步已读(服务端游标唯一)
小蓝平板卸载重装 → 重新登录 → sync 全量补拉 → 聊天记录都在(服务端全量存储)设计点:
- WS 多连接:
/ws/chat一用户多设备多 session(CopyOnWriteArrayList),事件广播到该用户全部连接 - 断线重连:指数退避(1s/2s/4s/.../30s 封顶);重连成功先
GET /api/chats/sync?afterId={本地最大id}补拉 - 消息接收双通道互备:WS 实时 + sync 拉取;Room 以
client_msg_id/id去重 - 多设备已读:已读游标在服务端,任何设备上报即全员同步
- 漫游:服务端全量存储(§8),换设备/重装不丢记录
2.6 场景 F:关系变更(删好友 / 拉黑 / 清空 / 删会话)
小蓝删除小绿 → 通讯录里小绿消失
小绿给小蓝发消息 → 小绿看到红色感叹号 +「对方开启了朋友验证,你还不是他朋友」
小蓝拉黑小绿 → 小绿再发 → 「消息已发出,但被对方拒收了」
小蓝取消拉黑 → 恢复聊天
小蓝清空与小绿的聊天记录 → 会话还在,记录清空;小绿侧记录不受影响(单方)
小蓝删除会话 → 会话从列表消失;小绿再发消息 → 会话复活(记录还在)设计点:
- 删除好友:只删
contacts自己一行;会话保留但冻结(不能发消息,历史仍可看?微信:删除好友后聊天记录还在,但无法再发消息。我们同:会话可看历史,发消息被拒) - 错误码区分:
4011 FRIEND_VERIFICATION_REQUIRED(被删/非好友,提示"对方开启了朋友验证")/4012 BLOCKED_BY_PEER(被拉黑,提示"消息已发出,但被对方拒收了") - 拉黑生效:拦发消息(双向)、拦好友申请、WS 不推消息;历史记录保留
- 清空 = 服务端游标
last_cleared_message_id(该用户视角 < 游标的消息不再返回,新消息正常) - 删会话 =
is_deleted=1;收到新消息自动复位 0(复活),历史通过 sync 正常拉回
2.7 场景 G:群聊(二期已实施)
小蓝建群「周末爬山」→ 邀请小绿、小红 → 群成员 3 人,群主小蓝
小红发"几点出发?" → 全员可见
小蓝 @小绿 "你开车吗?" → 小绿收到 @ 提醒(会话免打扰时仍提醒)
新成员小黄扫码入群 → 只能看到入群后的消息(微信行为)
小绿退群 → 群内出现系统消息「小绿退出群聊」;群主可解散群设计点(已全部落地):
conversations.type=GROUP+conversation_members通用;可见性按members.joined_at过滤(新成员看不到历史,微信同款)- @ 提醒:消息 content 可带
{"atUserIds":[42]},推送时对 @ 用户单独标记(免打扰时仍提醒) - 群公告/群名/群头像:conversations 已有列;群管理(邀请/踢人/退群/解散)走独立 GroupController
- 系统消息
SYSTEM承载:XX 加入群聊 / XX 退出群聊 / 群公告更新
2.8 场景 H:扫一扫加好友(我的二维码)
小蓝在「我的」→「我的二维码」→ 展示名片页(头像+昵称+二维码+“扫一扫,添加我为好友”)
小红:扫码 → 识别前缀 user:{随机码} → 服务端反查用户
→ 展示小蓝资料(非好友视角:头像/昵称/签名)→ 点「添加到通讯录」
→ 验证消息 → 发送申请(source=二维码扫描)
小蓝:申请列表看到「通过二维码扫描」来源 → 通过 → 双方建好友
小蓝重置二维码 → 旧码立即作废(防止旧名片被滥用)设计点:
- 名片码用随机串
chat_qr_code(16 位随机),不用自增 id(防枚举骚扰);内容格式user:{chat_qr_code},扫码路由新增user:前缀分支(与现有 activity:/check-in:/pass:/org-join: 并列,扫码相机业务流程需同步) - 「我的二维码」入口在用户中心;页面含刷新按钮(POST 重置)+ 分享到外部 App
- 非好友资料页复用 ContactProfileScreen(好友/非好友双视角)
2.9 场景 I:消息日常互动细节
小绿发「看这个 https://xxx.com」→ 小蓝点击链接 → 应用内 WebView 打开(复用协议页 WebView 能力)
小蓝长按文本 → 菜单:复制 / 转发 / 引用 / 多选;长按自己 2 分钟内的消息 → 额外「撤回」
小蓝上翻历史 → 底部出现「回到底部」悬浮按钮 → 点击滚回最新
小蓝在会话列表长按 → 「标为未读」→ 角标 +1(本地行为)
小蓝播放语音 → 靠近听筒时自动切换听筒模式(距离传感器,可选实现)设计点:
- 链接识别:客户端 AnnotatedString 正则识别 URL/手机号;URL 点击走现有 WebView 容器,手机号长按出拨号菜单
- 长按菜单按消息状态/类型动态渲染(自己+2min 内才有撤回;RECALLED 无菜单)
- 「回到底部」:ChatScreen 监听列表滚动位置(距底部 > 2 屏时显示悬浮按钮)
- 「标为未读」:纯本地(unreadCount+1),不发请求
- 听筒切换:可选(ProximitySensor + AudioManager 切扬声器/听筒),二期
2.10 场景 J:设置与清理
小蓝打开「聊天设置」:新消息通知开关 / 移动网络下不自动下载原图与视频(流量节省)
小蓝打开「存储管理」→ 按会话显示占用(图片/视频/文件)→ 一键清理缓存文件
小蓝退出登录 → 本地聊天缓存清理 → WS 断开(保证账号数据隔离)设计点:
- 本地设置(SharedPreferences):流量节省开关影响 Coil/下载策略(仅 WiFi 下加载原图/视频)
- 存储管理页:Room 聚合各会话媒体文件大小(本地文件系统统计),清理只删本地缓存(OSS 可重下)
- 退出登录/切换账号:清空 chat 三表 + 断开 WS + 取消通知(防串号,安全项)
2.11 场景 K:文件传输助手
小蓝手机拍的照片 → 打开聊天 → 选择自己 → 发送图片
→ 会话列表出现「文件传输助手」(自己)
小蓝平板登录同一账号 → 打开文件传输助手 → 下载图片 → 照片同步到手设计点:
- SINGLE 会话 A==A(uniq_key=
{A}:{A}),发送时 peerUserId==自己 放行(§4.4) - 展示名固定「文件传输助手」(客户端本地文案,服务端 name=null)
- 链路与好友聊天完全一致(上传/推送/sync),零特判逻辑
2.12 场景 L:隐私设置
小蓝关闭「可通过手机号搜索到我」→ 别人搜手机号无结果
小蓝重置我的二维码 → 旧码作废
小蓝关闭「加我为好友需要验证」→ 别人申请直接成为好友(无需确认)设计点:
- 个人聊天设置(user_chat_settings,§4.8):phone_searchable / require_verification / qr_code_enabled 三开关,NULL=跟随全局(chat.* 设置),个人可覆盖
- 搜索接口按 phone_searchable 过滤;名片码展示按 qr_code_enabled(关闭时「我的二维码」页提示已关闭)
3. 架构设计
3.1 模块边界
┌─────────────────────────────────────────────┐
│ com.codenote.chat │
│ (独立包,与二维码/活动/资产等模块无依赖) │
│ │
│ controller/ ContactController │
│ ChatController │
│ MessageController │
│ service/ ContactService(好友关系) │
│ ChatService(会话+消息编排) │
│ ChatSyncService(增量补拉) │
│ MessageTypeRegistry(类型注册表)│
│ handler/ MessageTypeHandler(接口) │
│ TextHandler / ImageHandler / │
│ VoiceHandler / VideoHandler / │
│ FileHandler / LocationHandler /│
│ CardHandler / SystemHandler │
│ repository/ 6 个 Repository │
│ entity/ ContactRequest / Contact / │
│ ContactBlock / Conversation / │
│ ConversationMember / Message │
│ ws/ ChatWsHandler(多设备长连接) │
│ dto/ ChatDto(请求/响应) │
└─────────────────────────────────────────────┘
│ 只依赖公共设施(不依赖业务模块)
▼
JwtService / FileService / StsService / users / NotificationService3.2 实时通道
⚠️ 现有
/ws/notifications是单连接模型(ConcurrentHashMap<Long, WebSocketSession>,后连顶掉先连),不满足多设备聊天。聊天独立建/ws/chat:
kotlin
@Component
class ChatWsHandler : TextWebSocketHandler() {
private val sessions: MutableMap<Long, CopyOnWriteArrayList<WebSocketSession>> = ConcurrentHashMap()
fun pushToUsers(userIds: List<Long>, payload: String) {
userIds.distinct().forEach { uid ->
sessions[uid]?.forEach { s ->
if (s.isOpen) synchronized(s) { s.sendMessage(TextMessage(payload)) }
}
}
}
}- 握手:复用公共
WsAuthInterceptor(从现有拦截器抽取),路径/ws/chat - 连接后客户端发
{"type":"auth","deviceId":"..."};服务端回{"type":"ack"} - 心跳:客户端 30s ping;服务端 60s 无活动断开;断线指数退避重连(1s→30s)
- NPM 已配
/ws/反代升级(系统设计 4.2 节),无新增配置 - 推送失败兜底:WS 发不出去(连接已死)不重试,依赖客户端重连后的 sync 补拉(游标保证不丢不重)
3.3 WS 事件协议(下行信封)
json
{ "type": "message.new", "data": { ...MessageVO } }| type | data | 说明 |
|---|---|---|
message.new | MessageVO | 新消息(推给会话全部成员) |
message.recalled | {messageId, conversationId, senderId} | 撤回 |
read.receipt | {conversationId, userId, lastReadMessageId} | 已读回执 |
conversation.update | ConversationVO | 会话变更(置顶/免打扰/草稿同步/复活) |
contact.request | ContactRequestVO | 收到好友申请 |
contact.accepted | {contactId, user} | 申请通过 |
contact.rejected | {requesterId} | 申请被拒 |
typing | {conversationId, userId} | 输入状态(✅ 已实施) |
group.event | {conversationId, text} | 群事件系统消息(✅ 已实施) |
3.4 状态机
消息状态(客户端视角):
超时/网络错(10s) 用户点重发
SENDING ────────────────────→ FAILED ──────────→ SENDING
│ │
└────────── 服务端确认 ──→ SENT ──→ READ(已读回执)服务端消息状态: SENT → READ / RECALLED(撤回后不可再读)
好友申请: PENDING → ACCEPTED / REJECTED(被拒后可重新申请,旧记录作废)
会话成员(对单用户视角): 正常 ⇄ is_deleted(删会话)/ is_muted(免打扰)/ is_pinned(置顶)
4. 数据库表结构(新增 7 表)
追加到
V1__init_schema.sql(项目规则:改库直接改 V1,禁止 Flyway 迁移脚本)。 业务表不建跨模块外键(对齐现有风格),用户/会话存在性由 Service 校验。
4.1 contact_requests(好友申请)
sql
CREATE TABLE contact_requests (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
requester_id BIGINT NOT NULL COMMENT '申请人',
target_id BIGINT NOT NULL COMMENT '接收人',
message VARCHAR(200) DEFAULT NULL COMMENT '验证消息',
source VARCHAR(20) NOT NULL DEFAULT 'SEARCH' COMMENT '来源:SEARCH搜索/QRCODE扫码/QRCODE_SCAN名片',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/ACCEPTED/REJECTED',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_requester_target (requester_id, target_id),
INDEX idx_target_status (target_id, status)
) COMMENT='好友申请表';- PENDING 唯一:同对存在 PENDING 直接复用;被拒后重新申请 → 旧记录置 REJECTED,新建 PENDING(事务)
- 通过后:contact_requests 置 ACCEPTED + contacts 双向插入,同一事务
- 申请人被对方删除后,关系行没了但申请记录保留(历史留痕)
4.2 contacts(好友关系,双向各一行)
sql
CREATE TABLE contacts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '持有者',
contact_id BIGINT NOT NULL COMMENT '好友',
remark VARCHAR(50) DEFAULT NULL COMMENT '备注名(仅持有者视角)',
tags VARCHAR(200) DEFAULT NULL COMMENT '标签,逗号分隔,如"同事,家人"',
created_at BIGINT NOT NULL,
UNIQUE KEY uk_user_contact (user_id, contact_id),
INDEX idx_contact (contact_id)
) COMMENT='好友关系表';- 加好友:事务内双方各插一行(A→B、B→A)
- 删好友(微信语义):只删自己那一行。A 删 B 后 A 列表无 B、B 列表仍有 A;B 发消息被拒(
4011) - 发消息校验:要求双向关系行都在(
chat.allow_stranger开关可放开,默认 false) - 备注/标签纯本地视角,不同用户互不可见(微信同款)
4.3 contact_blocks(黑名单,单向)
sql
CREATE TABLE contact_blocks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '拉黑者',
blocked_id BIGINT NOT NULL COMMENT '被拉黑者',
created_at BIGINT NOT NULL,
UNIQUE KEY uk_user_blocked (user_id, blocked_id)
) COMMENT='黑名单表';- 拉黑生效:拦好友申请、拦消息发送(双向拦截:我拉黑你,你发给我被拒;我也不会收到你发的)、WS 不推送
- 被拉黑方无感知(微信行为);取消拉黑即时恢复
- 拉黑不删除好友关系(微信:拉黑后仍在通讯录)
4.4 conversations(会话)
sql
CREATE TABLE conversations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(20) NOT NULL DEFAULT 'SINGLE' COMMENT 'SINGLE单聊/GROUP群聊',
uniq_key VARCHAR(64) DEFAULT NULL COMMENT '单聊唯一键 {minUserId}:{maxUserId}(文件传输助手=自己:自己),GROUP 为 null',
UNIQUE KEY uk_uniq_key (uniq_key),
name VARCHAR(100) DEFAULT NULL COMMENT '群名(单聊为 null,展示用对方昵称)',
avatar_url VARCHAR(500) DEFAULT NULL COMMENT '群头像',
owner_id BIGINT DEFAULT NULL COMMENT '群主(GROUP 才有)',
last_message_id BIGINT DEFAULT 0 COMMENT '最后一条消息 id(冗余,列表排序/摘要)',
last_message_at BIGINT DEFAULT 0 COMMENT '最后消息时间戳',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_last_message_at (last_message_at)
) COMMENT='会话表';last_message_id/at冗余:发消息事务内更新,会话列表免 join 大表直接取摘要- 单聊唯一性:
uniq_key = {minUserId}:{maxUserId}(如1:42),唯一索引 + INSERT 冲突捕获回查,并发安全(§7.8) - 文件传输助手:SINGLE 会话 A==A(uniq_key=
{A}:{A},peer 是自己),发消息时peerUserId == 自己放行
4.5 conversation_members(会话成员)
sql
CREATE TABLE conversation_members (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
last_read_message_id BIGINT DEFAULT 0 COMMENT '已读游标',
last_cleared_message_id BIGINT DEFAULT 0 COMMENT '清空游标:本用户视角 < 该值 的消息不返回',
draft TEXT DEFAULT NULL COMMENT '草稿(会话列表"草稿: xxx"优先展示)',
is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶',
is_muted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '免打扰',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '删除会话(保留消息,收到新消息自动复活)',
joined_at BIGINT NOT NULL,
UNIQUE KEY uk_conversation_user (conversation_id, user_id),
INDEX idx_user (user_id)
) COMMENT='会话成员表';- 未读数 = 会话当前最大消息 id −
last_read_message_id(id 会话内单调,安全相减;游标式天然自愈,不做冗余计数器) - 清空聊天记录 =
last_cleared_message_id置为当前最大消息 id;sync/历史查询过滤< 游标的消息(单方行为,对方无感) - 置顶/免打扰/删除 = 成员级偏好,只影响持有者视角
- 草稿:客户端本地 + 服务端双存(换设备同步);会话列表排序时草稿优先于最后消息展示
- 群聊列(✅ 已加):
group_role(OWNER/ADMIN/MEMBER)、group_nickname(群内昵称)、mute_until(禁言截止);conversations 加announcement(群公告)
4.6 messages(消息)
sql
CREATE TABLE messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
msg_type VARCHAR(20) NOT NULL COMMENT 'TEXT/IMAGE/VOICE/VIDEO/FILE/LOCATION/CARD/SYSTEM',
content TEXT NOT NULL COMMENT '类型化内容 JSON(见 §5.1)',
reply_to_id BIGINT DEFAULT NULL COMMENT '引用的消息 id(转发时随消息保留)',
forward_from_id BIGINT DEFAULT NULL COMMENT '转发来源消息 id(null=原创)',
client_msg_id VARCHAR(64) DEFAULT NULL COMMENT '客户端幂等 ID(UUID)',
status VARCHAR(20) NOT NULL DEFAULT 'SENT' COMMENT 'SENT/READ/RECALLED',
recalled_at BIGINT DEFAULT NULL,
created_at BIGINT NOT NULL,
UNIQUE KEY uk_client_msg (client_msg_id),
INDEX idx_conversation_id (conversation_id, id),
INDEX idx_sender (sender_id)
) COMMENT='消息表';content为 JSON,语义类型化(§5.1);msg_type决定解析器,二者不可分割- 撤回:
status=RECALLED+recalled_at;content 保留(合规留痕,客户端按 status 不渲染原文) - 转发:复制一条新消息(新 id、新 client_msg_id),
forward_from_id记录来源;引用块在转发时保留reply_to_id(若被引用消息在原会话仍可见) - 系统消息
SYSTEM:sender_id=0,不可撤回不可转发
4.7 全局开关与限制(system_settings 新增 chat.* key)
全部由管理后台「聊天设置」页维护(§9),App 端经 ChatConfigService 读取(Caffeine 60s 缓存,管理端更新即失效)。
| key | 默认 | 说明 |
|---|---|---|
chat.allow_stranger | false | 陌生人可私聊(关闭=必须双向好友) |
chat.require_verification | true | 加好友需验证(false=直接通过,双方自动建好友) |
chat.retention_days | 0(永久) | 服务端消息保留天数,0=永久;到期由 DataCleanupService 定时清理 |
chat.recall_seconds | 120 | 撤回时限(秒,微信为 2 分钟) |
chat.max_send_rate | 20/10s | 发消息限流(防刷屏) |
chat.max_draft_length | 2000 | 草稿最大字符 |
chat.dnd_start / chat.dnd_end | 空 | 全局勿扰时段(✅ 已实施,HH:mm,管理后台可配) |
chat.image_max_mb | 20 | 图片最大 MB |
chat.voice_max_seconds | 60 | 语音最长秒数 |
chat.video_max_mb | 100 | 视频最大 MB |
chat.file_max_mb | 50 | 文件最大 MB |
chat.message_text_max | 5000 | 文本最大字符 |
chat.media_daily_quota_mb | 200 | 每用户每日媒体上传总量(MB),防滥用 |
chat.max_contacts | 500 | 每用户好友上限 |
chat.max_contact_requests_daily | 20 | 每日加好友申请上限 |
chat.contact_remark_max | 50 | 好友备注最大长度 |
chat.contact_tags_max | 10 | 好友标签最大数量 |
chat.max_pinned_conversations | 50 | 置顶会话上限 |
chat.max_group_members | 500 | 群人数上限(✅ 已生效) |
chat.max_groups_per_user | 50 | 每人建群上限(✅ 已生效) |
chat.phone_searchable | true | 手机号可被搜索(全局默认,个人可覆盖) |
4.8 user_chat_settings(个人聊天隐私设置,第 7 张新表)
微信的「隐私」设置是个人级的;全局开关只做默认值,个人可覆盖。三开关 NULL=跟随全局。
sql
CREATE TABLE user_chat_settings (
user_id BIGINT PRIMARY KEY,
phone_searchable TINYINT(1) DEFAULT NULL COMMENT '手机号可被搜索:NULL=跟随全局 chat.phone_searchable,0=关闭',
require_verification TINYINT(1) DEFAULT NULL COMMENT '加好友需验证:NULL=跟随全局 chat.require_verification,0=直接通过',
qr_code_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '我的二维码开关(0=名片码不展示不可扫)',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
) COMMENT='用户聊天隐私设置';users 表补充列:chat_qr_code VARCHAR(32) DEFAULT NULL — 我的名片随机码(16 位随机串,首次进入「我的二维码」时生成;重置 = 重新生成,旧码作废)。
- 搜索用户接口:
phone_searchable=0(或全局关闭)时手机号搜索无结果(不暴露存在性) - 名片码内容
user:{chat_qr_code};扫码后服务端按随机码反查 user_id(防自增 id 枚举骚扰) - 注销账户时:user_chat_settings 行删除 + 用户参与会话/消息按隐私策略清理(§8.4)
5. 消息类型体系
5.1 类型枚举与 content JSON 契约
| msg_type | content JSON | 摘要(会话列表) | 说明 |
|---|---|---|---|
TEXT | {"text":"你好"} | 原文(截断 50 字) | ≤5000 字符 |
IMAGE | {"url":"...","width":1080,"height":1920,"size":102400} | [图片] | 原图 OSS URL;缩略图由展示端按 ?x-oss-process=image/resize,w_400 生成 |
VOICE | {"url":"...","durationMs":3200,"size":20480,"transcript":null} | [语音] 3″ | ≤60s;transcript 预留语音转文字(三期 ASR 回填) |
VIDEO | {"url":"...","coverUrl":"...","durationMs":15230,"width":1280,"height":720,"size":5242880} | [视频] | 封面+时长;封面为发送端首帧截图上传 |
FILE | {"name":"文档.pdf","url":"...","size":2048000,"mime":"application/pdf"} | [文件] 文档.pdf | 通用文件 |
LOCATION | {"name":"广州塔","address":"海珠区阅江西路","latitude":23.106,"longitude":113.325} | [位置] 广州塔 | 扩展类型 |
CARD | {"cardType":"USER",...} 或 {"cardType":"CHAT_RECORD","messages":[{...摘要}]} | [名片] 昵称 / [聊天记录] | ✅ CHAT_RECORD 合并转发已实现 |
SYSTEM | {"text":"你已添加了 XX,现在可以开始聊天了"} | 原文灰字 | sender_id=0,不可撤回/转发 |
5.2 处理器注册表(参照 FieldValidatorRegistry 模式)
kotlin
interface MessageTypeHandler {
fun type(): String
fun validate(content: String) // 必填字段/长度/URL 白名单/配额
fun summary(content: String): String // 会话列表摘要
fun view(content: String): Map<String, Any?> // 规范化视图(补默认值)
}
@Component
class MessageTypeRegistry(handlers: List<MessageTypeHandler>) {
private val registry = handlers.associateBy { it.type() }
fun handlerOf(type: String): MessageTypeHandler =
registry[type] ?: throw BusinessException("未知消息类型: $type")
}新增一种消息类型的全部成本:① 写 XxxHandler 实现 4 个方法 ② Spring 自动收集。不改表、不改 Controller、不改推送链路。 未知类型在发送时直接 400(注册表兜底)。
5.3 媒体上传链路(复用现有 OSS)
Android: 选图/录音/选视频/选文件
1. POST /api/file/sts-token(现有)→ STS 临时凭证(900s)
2. 直传 OSS(现有 FileManager 方法)→ 拿 URL + 端上生成元数据(宽高/时长/大小)
3. POST /api/chats/messages(msgType=IMAGE/...,content 带 url)
4. 服务端 validate:URL 命中 OSS 域名白名单 + 配额校验 → 落库推送- 先传后发:媒体文件必须已上传成功才发消息,杜绝"消息发出文件 404"
- 上传失败 → 消息保持 SENDING/FAILED,不产生半截消息
- 服务端只校验 URL 与配额,不代理文件内容(现有 FileController 职责不变)
6. 后端 API
Controller:
ContactController(/api/contacts)、ChatController(/api/chats)。全部要求登录;统一ApiResponse<T>;异常由 GlobalExceptionHandler 处理。
6.1 联系人
| 方法 | 端点 | 描述 |
|---|---|---|
| POST | /api/contacts/requests | 发申请 {userId | phone, message?, source?};chat.require_verification=false 时直接建好友 |
| GET | /api/contacts/requests/incoming | 收到的申请(PENDING 优先,分页) |
| GET | /api/contacts/requests/outgoing | 发出的申请 |
| GET | /api/contacts/requests/pending-count | PENDING 数量("新的朋友"红点) |
| POST | /api/contacts/requests/{id}/accept | 通过(事务:置 ACCEPTED + 双向建好友 + 系统消息 + 推送) |
| POST | /api/contacts/requests/{id}/reject | 拒绝(推送 contact.rejected) |
| GET | /api/contacts | 联系人列表(keyword 过滤;含备注/标签/头像/昵称/signature) |
| GET | /api/contacts/{contactId} | 联系人详情(含是否拉黑/是否有会话) |
| PUT | /api/contacts/{contactId}/remark | 备注名(≤50,空串清空) |
| PUT | /api/contacts/{contactId}/tags | 标签 {tags:["同事","家人"]}(≤10 个) |
| DELETE | /api/contacts/{contactId} | 删除好友(只删自己一侧) |
| POST | /api/contacts/{contactId}/block | 拉黑 |
| POST | /api/contacts/{contactId}/unblock | 取消拉黑 |
| GET | /api/contacts/search | 搜索用户(昵称/手机号,返回非好友标记 + 是否已申请;手机号按 phone_searchable 过滤) |
| GET | /api/contacts/my-qrcode | 我的名片码(首次自动生成 chat_qr_code;返回 qrContent user:{码} + 渲染图片 URL) |
| GET | /api/contacts/by-qr-code/{code} | 名片码反查用户(扫码后调用;返回 UserBriefVO + 是否好友/是否已申请;无效码 404) |
| POST | /api/contacts/my-qrcode/refresh | 重置名片码(重新生成,旧码作废) |
| GET | /api/contacts/settings | 我的聊天隐私设置(三开关,NULL=跟随全局) |
| PUT | /api/contacts/settings | 更新隐私设置 {phoneSearchable?, requireVerification?, qrCodeEnabled?} |
6.2 会话
| 方法 | 端点 | 描述 |
|---|---|---|
| GET | /api/chats/conversations | 会话列表:置顶优先 → last_message_at 倒序;每项含 unreadCount/摘要/草稿/免打扰/置顶 |
| GET | /api/chats/conversations/{id} | 会话详情(成员、最后消息、我的偏好) |
| POST | /api/chats/conversations/{id}/pin | 置顶/取消 {pinned:true}(上限 50 个) |
| PUT | /api/chats/conversations/{id}/mute | 免打扰 {muted:true} |
| PUT | /api/chats/conversations/{id}/draft | 保存草稿 {draft:"..."}(null/空串清除) |
| DELETE | /api/chats/conversations/{id} | 删除会话(is_deleted=1,消息保留;新消息自动复活并推送 conversation.update) |
| DELETE | /api/chats/conversations/{id}/messages | 清空聊天记录(last_cleared_message_id 游标,单方) |
| GET | /api/chats/conversations/{id}/messages | 历史消息 ?beforeId=&limit=20(倒序游标分页;过滤 < 清空游标) |
| POST | /api/chats/conversations/{id}/read | 上报已读 {lastReadMessageId}(写游标 + 推送 read.receipt) |
6.2b 群聊(✅ 二期已实施,Controller: GroupController,完整表见 §12.2)
| 方法 | 端点 | 权限 | 描述 |
|---|---|---|---|
| POST | /api/chats/groups | 登录 | 建群 {name, memberIds}(仅好友,≤500 人) |
| GET | /api/chats/groups/{id} | 群成员 | 群详情(成员/角色/公告) |
| PUT | /api/chats/groups/{id} | OWNER/ADMIN | 改群名/头像 |
| PUT | /api/chats/groups/{id}/announcement | OWNER/ADMIN | 群公告(Markdown) |
| POST | /api/chats/groups/{id}/members | OWNER/ADMIN | 邀请(仅好友) |
| DELETE | /api/chats/groups/{id}/members/{userId} | OWNER/ADMIN | 踢人(群主不可踢) |
| POST | /api/chats/groups/{id}/members/{userId}/role | OWNER | 设/撤管理员 |
| PUT | /api/chats/groups/{id}/members/me/nickname | 自己 | 群内昵称 |
| POST | /api/chats/groups/{id}/members/{userId}/mute | OWNER/ADMIN | 禁言 {until} / 解除 {until:null} |
| POST | /api/chats/groups/{id}/leave | 自己 | 退群(群主先转让) |
| POST | /api/chats/groups/{id}/transfer | OWNER | 转让群主 |
| DELETE | /api/chats/groups/{id} | OWNER | 解散 |
| GET | /api/chats/groups/{id}/qrcode | OWNER/ADMIN | 群二维码 group:{id} |
6.3 消息
| 方法 | 端点 | 描述 |
|---|---|---|
| POST | /api/chats/messages | 发送消息(单条;多图用 attachments 数组拆多条) |
| POST | /api/chats/messages/{id}/recall | 撤回(2 分钟内发送者本人) |
| POST | /api/chats/messages/forward | 逐条转发 {targetUserIds:[...], messageIds:[...]}(支持多联系人,微信同款) |
| POST | /api/chats/messages/forward-merged | 合并转发(✅):N 条消息摘要 → 一条 CARD(CHAT_RECORD) |
| POST | /api/chats/typing | 输入状态(✅):{conversationId} → WS 推送 typing 给其他成员 |
| GET | /api/chats/export | 聊天记录导出(✅):?conversationId=&limit= 返回 JSON(过滤清空游标/撤回) |
| GET | /api/chats/sync | 全局增量 ?afterId=&limit=200(多会话统一游标,过滤清空游标;重连/换设备补拉) |
| GET | /api/chats/search | 服务端聊天记录搜索 ?keyword=&conversationId?(兜底;客户端优先本地 FTS) |
6.4 请求/响应示例
发送文本:
json
// POST /api/chats/messages
{
"conversationId": null,
"peerUserId": 42,
"msgType": "TEXT",
"content": "{\"text\":\"你好\"}",
"clientMsgId": "uuid-xxxx"
}
// 200:完整 MessageVO(含 id + conversationId)发送图片(先传 OSS):
json
{
"conversationId": 88,
"msgType": "IMAGE",
"content": "{\"url\":\"https://codenote-server.oss-cn-guangzhou.aliyuncs.com/codenote/chat/xxx.jpg\",\"width\":1080,\"height\":1920,\"size\":102400}",
"clientMsgId": "uuid-yyyy"
}消息 VO(MessageVO):
json
{
"id": 1024, "conversationId": 88, "senderId": 1,
"sender": {"userId": 1, "nickname": "小蓝", "avatarUrl": "..."},
"msgType": "TEXT",
"content": "{\"text\":\"你好\"}",
"contentView": {"text": "你好"},
"replyToId": null, "forwardFromId": null,
"status": "SENT", "createdAt": 1754553600000
}6.5 错误码(业务异常,全局统一处理)
| code | 场景 | 客户端提示 |
|---|---|---|
| 4011 | 非好友发消息(被删/未加) | 「对方开启了朋友验证,你还不是他朋友」 |
| 4012 | 被对方拉黑 | 「消息已发出,但被对方拒收了」 |
| 4013 | 不能给自己发消息(文件传输助手除外) | 「不能和自己聊天」 |
| 4014 | 撤回超时/非发送者 | 「只能撤回 2 分钟内的消息」 |
| 4015 | 会话无权限(非成员) | 「会话不存在」 |
| 4020 | 未知消息类型 | 「不支持的消息类型」 |
| 4021 | content 校验失败 | 具体原因(超长/URL 非法/超配额) |
| 4022 | 好友/申请/置顶达上限 | 「好友数量已达上限」/「今日申请次数已达上限」 |
6.6 管理后台 API(聊天设置与运营)
| 方法 | 端点 | 权限点 | 描述 |
|---|---|---|---|
| GET | /api/admin/chat/settings | PLATFORM chat_setting:read | 读聊天设置(按分组返回,类型化 DTO) |
| PUT | /api/admin/chat/settings | PLATFORM chat_setting:update | 批量更新(类型化 DTO + 数值范围校验 + 写 admin_logs) |
| GET | /api/admin/chat/stats | PLATFORM chat_setting:read | 聊天数据概览(会话/消息总量、今日消息、活跃会话) |
| GET | /api/admin/chat/messages | PLATFORM chat:audit(三期) | 按用户/会话/关键词查聊天记录(审计留痕) |
| POST | /api/admin/chat/users/{id}/ban | PLATFORM chat:ban(三期) | 封禁/解封发言(检查点:ChatService.send) |
- 存储:复用
system_settings表(chat.* 前缀),不新建配置表;类型化ChatSettingService做 DTO 映射与范围校验(数值区间/枚举白名单),防绕过前端直接改坏配置 - 读取:App 端
ChatConfigServiceCaffeine 缓存 60s,管理端更新后主动失效(复用 RbacService 缓存失效模式);发送消息/加好友等校验点实时读缓存 - 留痕:设置变更写
admin_logs(UPDATE_CHAT_SETTINGS + 变更 key 清单);统计查询不写操作日志(读操作)
6.7 数据契约(跨端字段级定义,接口先行)
按项目规则:跨端功能先出契约(DTO + 端点),本文档即契约基线;实现时以
dto/ChatDto.kt为准。所有时间戳为毫秒 BIGINT,与现有系统一致。
UserBriefVO(用户摘要)
kotlin
data class UserBriefVO(
val userId: Long,
val nickname: String, // users.nickname
val avatarUrl: String?,
)ContactVO(联系人)
kotlin
data class ContactVO(
val userId: Long,
val nickname: String,
val avatarUrl: String?,
val signature: String?,
val remark: String?, // 我的备注(null=未设置)
val tags: List<String>, // 我的标签
val isBlocked: Boolean, // 我是否拉黑对方
val isMutual: Boolean, // 双向好友?对方删我后此处=false,发消息将 4011
val conversationId: Long?, // 已有会话 id(进入聊天用,null=暂无)
val createdAt: Long,
)ConversationVO(会话)
kotlin
data class ConversationVO(
val id: Long,
val type: String, // SINGLE/GROUP
val name: String?, // 群名;单聊=对方昵称
val avatarUrl: String?,
val peerUser: UserBriefVO?, // 单聊才有
val lastMessage: MessageBriefVO?, // 摘要(删除后复活的会话可能为 null)
val lastMessageAt: Long,
val unreadCount: Int,
val isPinned: Boolean,
val isMuted: Boolean,
val draft: String?,
val memberCount: Int, // GROUP 才有
val createdAt: Long,
)
data class MessageBriefVO(
val id: Long,
val msgType: String,
val summary: String, // Handler.summary() 输出,如「[图片]」「你好」
val createdAt: Long,
)MessageVO(消息,完整)
kotlin
data class MessageVO(
val id: Long,
val conversationId: Long,
val senderId: Long,
val sender: UserBriefVO, // 转发/群聊展示用
val msgType: String,
val content: String, // 原始 JSON(客户端直接存 Room)
val contentView: Map<String, Any?>, // Handler.view() 规范化视图(补默认值,客户端优先用)
val replyToId: Long?,
val forwardFromId: Long?,
val status: String, // SENT/READ/RECALLED
val recalledAt: Long?,
val createdAt: Long,
)ContactRequestVO(好友申请)
kotlin
data class ContactRequestVO(
val id: Long,
val requester: UserBriefVO,
val target: UserBriefVO,
val message: String?,
val source: String, // SEARCH/QRCODE/QRCODE_SCAN
val status: String, // PENDING/ACCEPTED/REJECTED
val createdAt: Long,
)请求/分页/增量
kotlin
data class SendMessageRequest(
val conversationId: Long?, // 单聊懒建时传 null
val peerUserId: Long?, // 懒建用;conversationId 非空时忽略
val msgType: String,
val content: String, // 单条 content JSON
val clientMsgId: String, // UUID,必填,服务端幂等
val attachments: List<Attachment>? = null, // 多图:IMAGE 拆多条,顺序保持
)
data class Attachment(val content: String)
data class PageVO<T>(
val items: List<T>,
val hasMore: Boolean,
val nextCursor: Long?, // 下一页 beforeId;null=无更多
)
data class SyncResult(
val messages: List<MessageVO>,
val maxId: Long, // 本次拉取最大 id(客户端存游标)
val hasMore: Boolean,
)统一错误响应(GlobalExceptionHandler 现有格式)
json
{ "code": 4011, "message": "对方开启了朋友验证,你还不是他朋友", "data": null }7. 关键流程时序
7.1 发送文本消息(核心链路)
Android ──POST /api/chats/messages──▶ ChatService.send()
1. 鉴权:登录态;黑名单双向校验(4012);好友校验(4011,allow_stranger 放行)
2. 幂等:client_msg_id 唯一键冲突 → 直接返回已存在消息(不重复入库)
3. 懒建会话:无 conversationId → 查 (A,B) SINGLE 交集;无 → 建 conversations + 2 行 members
(peerUserId == 自己 → 文件传输助手,放行)
4. 类型化校验:MessageTypeRegistry.handlerOf(type).validate(content)
5. 落库 messages(status=SENT)→ 事务内更新 conversations.last_message_id/at
6. 若会话此前 is_deleted → 复位 0(复活)
7. ChatWsHandler.pushToUsers(会话成员, message.new)
8. 返回 MessageVO7.2 发送媒体消息
Android: 上传 OSS 成功(已有 URL+元数据)
→ 同 7.1(content 带 url,validate 校验域名白名单 + 配额)
→ 失败点:上传失败 = 不发消息(先传后发原则)7.3 接收与离线补拉
在线:WS message.new → Room 落库(按 id 去重)→ UI 更新
断线重连:
→ 指数退避重连成功
→ GET /api/chats/sync?afterId={本地最大消息 id}
→ 服务端返回 (afterId, maxId] 区间消息(过滤清空游标),limit 内
→ 若还有更多(返回 hasMore)→ 循环拉取直到对齐
→ 全部入库 → 会话列表未读重算 → UI 刷新
丢包兜底:WS 收到的消息 id 与本地游标不连续 → 触发 sync 补拉7.4 已读回执
进入会话 / 新消息可见
→ POST /api/chats/conversations/{id}/read {lastReadMessageId: 1024}
→ 更新 members.last_read_message_id(只增不减)
→ WS 推送 read.receipt 给同会话其他成员(不含自己)
→ 对方 UI:单聊显示"已读";会话列表未读清零
→ 多设备:任一设备上报,全部设备同步(游标在服务端)7.5 撤回
发送者 2 分钟内
→ POST /api/chats/messages/{id}/recall
→ 校验 sender_id == 当前用户 && created_at 在 120s 内
→ status=RECALLED + recalled_at(content 保留留痕)
→ WS 推送 message.recalled → 双方本地替换为「你撤回了一条消息」/「对方撤回了一条消息」7.6 转发
多选 N 条 → POST /api/chats/messages/forward {targetUserIds:[...], messageIds}
→ 校验转发者与每个 target 是好友(4011);逐目标校验建会话
→ 复制消息(新 id/clientMsgId,forward_from_id=原 id,sender=转发者)→ 单事务批量落库 → 推送7.7 清空 / 删除会话
清空:DELETE .../messages → members.last_cleared_message_id = 当前最大消息 id
→ 历史接口/sync 过滤 < 游标;客户端本地 Room 同步清理该会话记录
删除会话:DELETE .../conversations/{id} → is_deleted=1
→ 列表不再展示;对方新消息到达时复位 0 + 推送 conversation.update(复活)7.8 并发与数据一致性
| 场景 | 问题 | 方案 |
|---|---|---|
| 会话懒创建并发 | A、B 同时互发首条消息 → 双查无会话 → 双建 | conversations.uniq_key(SINGLE 专用 {minId}:{maxId},如 1:2)唯一索引;INSERT 冲突(DuplicateKey)→ 捕获后回查返回已有会话(§4.4 已加列) |
| 消息幂等并发 | 同一 client_msg_id 重试并发提交 | uk_client_msg 唯一键;DuplicateKey 捕获 → 查询返回已存在消息(状态机 SENT 复位) |
| 好友互加并发 | A→B 与 B→A 两个 PENDING 同时存在 | accept 时若发现反向 PENDING → 一并置 ACCEPTED 并建好友(互加即好友);uk_user_contact 兜底重复插入(捕获忽略) |
| 已读上报乱序 | 多设备/快速滚动导致旧游标后到 | 只增不减:last_read_message_id = max(旧, 新)(行级 UPDATE ... WHERE last_read_message_id < 新值,影响行数=0 则忽略) |
| 会话摘要与消息不一致 | 消息与 last_message_id 不同步 | 发消息 + 更新冗余列同一 @Transactional;sync 补拉后统一刷新会话列表 |
| 未读计数并发 | 多设备同时读 | 未读 = 冗余 last_message_id − last_read_message_id(纯读,无写冲突) |
核心原则:数据库唯一约束兜底 + 应用层捕获冲突回查(不用分布式锁);消息/会话写入全部走事务;游标列只增不减。
8. 聊天记录处理(重点)
微信的聊天记录默认只存本机,换设备需手动迁移。我们做服务端全量存储 + 本地缓存(用户量小、成本可控), 天然获得:多设备漫游、重装不丢、后台合规审计。这是比微信体验更强的点。
8.1 存储模型
| 层 | 存储 | 内容 | 策略 |
|---|---|---|---|
| 服务端 | MySQL messages 表 | 全量消息(含撤回留痕) | 默认永久;chat.retention_days 可配,DataCleanupService 定时清理 |
| 本地 | Room messages | 每会话最近 N 条缓存(默认 500/会话) | 向上滚动懒加载(beforeId 游标从服务端拉更早) |
| 本地 | Room FTS4 | 消息文本全文索引 | 聊天记录搜索(复用现有 FTS 经验),离线可搜 |
8.2 查询路径
- 打开会话:本地 Room 直接渲染(毫秒级)→ 若本地没有服务端更新的消息(游标落后)→ sync 补拉
- 向上翻历史:
GET .../messages?beforeId={本地最早id}&limit=20→ 追加到 Room → UI 滚动位保持 - 搜索:优先本地 FTS(快、离线可用);无结果/跨会话兜底走
GET /api/chats/search(服务端 LIKE + 分页,二期可上全文索引)
8.3 删除语义分级(对齐微信,互不混淆)
| 操作 | 客户端行为 | 服务端行为 | 对方视角 |
|---|---|---|---|
| 撤回(2min) | 本地替换为"撤回了一条消息" | status=RECALLED,content 留痕 | 同样显示撤回 |
| 删除单条消息 | Room 本地删除该条 | 不动 | 不受影响 |
| 清空聊天记录 | 清空该会话本地记录 | last_cleared_message_id 游标(单方) | 不受影响 |
| 删除会话 | 会话从列表移除(本地标记) | is_deleted=1,消息保留 | 对方发消息 → 复活 |
| 删除好友 | 通讯录移除 | 删自己一侧关系行,会话冻结 | 对方列表保留你,发消息被拒 |
说明:清空用游标而非物理删,语义与微信一致(清空是单方视角行为),同时避免物理删除破坏消息 id 单调性(游标机制依赖它)。
8.4 数据导出与合规(二期)
- ✅ 已实施(简化版):
GET /api/chats/export?conversationId=&limit=返回消息 JSON(过滤清空游标与撤回),客户端保存文件;OSS 文件版留待需要时升级 - 管理后台(PLATFORM 域,三期):按用户/会话/关键词查询聊天记录(写 admin_logs 留痕)、封禁用户(禁言:
chat.ban_user检查,发消息 403) - 保留期策略上线需谨慎:默认 0=永久,管理员显式配置后才清理;清理走 DataCleanupService 任务 + 清理日志
- 注销账户(与现有注销语义一致:业务数据保留):删除 user_chat_settings 行 + contacts 双向关系行 + conversation_members 行(单聊会话若无人剩余则一并删除);messages 保留(对方视角留痕),发送者昵称显示「已注销用户」(users 行保留标记)
9. 管理后台设计(聊天设置与运营)
对齐现有 Settings.vue 分组风格(el-card + el-form),独立页面 ChatSettings.vue,路由
/chat-settings。
9.1 设置项总表(管理后台可配,存 system_settings chat.* 前缀)
| 分组 | key | 默认 | 说明 | 服务端执行点 |
|---|---|---|---|---|
| 媒体与文件 | chat.image_max_mb | 20 | 图片最大 MB | ImageHandler.validate + StsService 签发前双校验 |
chat.voice_max_seconds | 60 | 语音最长秒数 | VoiceHandler.validate | |
chat.video_max_mb | 100 | 视频最大 MB | VideoHandler.validate | |
chat.file_max_mb | 50 | 文件最大 MB | FileHandler.validate | |
chat.message_text_max | 5000 | 文本最大字符 | TextHandler.validate | |
chat.media_daily_quota_mb | 200 | 每用户每日媒体上传总量 | StsService 签发前按 files 表当日聚合校验 | |
| 联系人 | chat.max_contacts | 500 | 每用户好友上限 | ContactService.accept / 直接通过(双方都校验) |
chat.max_contact_requests_daily | 20 | 每日加好友申请上限 | ContactService.createRequest | |
chat.contact_remark_max | 50 | 备注最大长度 | ContactService.setRemark | |
chat.contact_tags_max | 10 | 标签最大数量 | ContactService.setTags | |
chat.max_pinned_conversations | 50 | 置顶会话上限 | ChatService.pin | |
| 行为 | chat.allow_stranger | false | 陌生人可私聊 | ChatService.send(4011 校验前置) |
chat.require_verification | true | 加好友需验证(false=直接建好友) | ContactService.createRequest | |
chat.recall_seconds | 120 | 撤回时限(秒) | ChatService.recall | |
chat.max_draft_length | 2000 | 草稿最大字符 | ChatService.saveDraft | |
chat.max_send_rate | 20/10s | 发消息限流 | ChatService.send(限流器) | |
chat.retention_days | 0(永久) | 消息保留天数 | DataCleanupService 定时任务 | |
chat.dnd_start/chat.dnd_end | 空 | 全局勿扰时段 HH:mm | 通知模块(✅ 设置已可配;客户端时间段判断待接) | |
| 群聊(二期) | chat.max_group_members | 500 | 群人数上限 | GroupService |
| 隐私 | chat.phone_searchable | true | 手机号可被搜索(全局默认,个人可覆盖) | ContactService.search |
chat.max_groups_per_user | 50 | 每人建群上限 | GroupService |
媒体大小双保险:App 端选文件时本地提示(体验),服务端 Handler/StsService 强校验(安全)。每日媒体总量按
files表user_id + 日期聚合,超限拒绝签发 STS。
9.2 页面设计(ChatSettings.vue,路由 /chat-settings)
- 入口:管理后台侧边栏「聊天设置」(独立菜单项,避免塞进现有系统设置页导致页面过长)
- 顶部只读统计卡片(el-row + el-statistic,对齐 dashboard 风格):总会话数 / 总消息数 / 今日新增消息 / 今日活跃会话(24h 内有消息)
- 分组表单(el-card,对齐 Settings.vue):媒体与文件限制 / 联系人限制 / 聊天行为 / 群聊限制(二期项置灰禁用)
- 控件:数字用 el-input-number(带 min/max),开关用 el-switch,勿扰时段用 el-time-picker
- 客户端表单校验 + 服务端 ChatSettingService 二次范围校验(防绕过)
- 保存:PUT /api/admin/chat/settings → 成功 toast + admin_logs 留痕(变更 key 清单);失败回显服务端错误
- 权限显隐:按
chat_setting:update权限点控制保存按钮显隐(无权限只读,复用现有按钮显隐机制);chat_setting:read控制页面可访问
9.3 统计口径(GET /api/admin/chat/stats)
| 指标 | 口径 |
|---|---|
| 总会话数 | conversations 行数 |
| 总消息数 | messages 行数 |
| 今日新增消息 | created_at ≥ 当日 0 点 |
| 今日活跃会话 | 今日有消息的会话去重 |
| 活跃用户(可选) | 今日发消息用户去重 |
- 简单只读卡片,不引入图表库;趋势折线二期再加
- 权限点
chat_setting:read,读操作不写 admin_logs
9.4 权限点新增(PermissionRegistry 只增不删)
| 权限点 | 预置授权 | 生效阶段 |
|---|---|---|
chat_setting:read / chat_setting:update | SYSTEM_ADMIN(全量)+ OPERATION_ADMIN | 一期 |
chat:audit(聊天记录查询) | SYSTEM_ADMIN + OPERATION_ADMIN | 三期 |
chat:ban(禁言/封禁发言) | SYSTEM_ADMIN | 三期 |
10. Android 端设计
10.1 Room 表(3 张,独立 DAO)
| Entity | 表 | 说明 |
|---|---|---|
ContactEntity | contacts | 联系人本地镜像(remark/tags/头像/昵称缓存) |
ConversationEntity | conversations | 会话 + 偏好(pinned/muted/deleted/未读/draft/最后消息摘要) |
MessageEntity | messages | 消息(clientMsgId 主键 + 服务端 id 索引;本地状态 SENDING/FAILED;replyToId/forwardFromId;isVoicePlayed) |
- FTS4 表
messages_fts同步维护(insert/update 时写入,与现有二维码 FTS 同法) - 本地清理:会话删除/清空时按游标批量删;磁盘超阈值时清理最老会话的媒体缓存(图片/视频本地文件,OSS 可再下)
10.2 页面与导航(AppNavigation 注册 chatNavigation)
| 页面 | 说明 |
|---|---|
ContactsScreen | 通讯录:好友列表(按首字母分组可选)/搜索/添加入口/新的朋友(红点) |
ContactProfileScreen | 好友资料:头像/昵称/备注/标签/发消息/拉黑/删除 |
ConversationListScreen | 会话列表:置顶/免打扰图标/未读角标(99+)/摘要/时间/草稿标签;长按=删除会话/置顶 |
ChatScreen | 聊天页:气泡列表(本人右侧/对方左侧)+ 时间分隔条 + 输入栏(文字/语音切换 + "+"面板:图片/视频/文件/位置/名片)+ 长按菜单(复制/引用/转发/撤回/删除) |
MediaPreviewScreen | 图片大图/视频播放(复用现有预览能力) |
10.3 关键组件
ChatWsClient:OkHttp WebSocket(/ws/chat?token=),心跳 30s、指数退避重连、重连后触发 sync 补拉;事件分发ChatEventBus(StateFlow)ChatRepository:发送(先写 Room SENDING → API → 回填 SENT;失败 FAILED 可重发);历史分页;增量 sync;已读上报;草稿保存(本地即时 + 服务端异步同步)ChatSyncWorker(WorkManager):周期兜底拉取(App 在后台时 15min 间隔,可配);配合 WS 断线重连- ViewModel:
ChatViewModel(当前会话消息流 + 发送状态机)、ConversationListViewModel(会话列表 + 未读聚合)、ContactsViewModel、ChatSearchViewModel - 媒体:复用
FileManagerOSS 上传;录音用 MediaRecorder(m4a);视频首帧封面用 MediaMetadataRetriever;图片压缩复用 ImageCompressor
10.4 通知体系(应用内)
| 场景 | 方案 |
|---|---|
| App 前台 | WS 实时 → 直接更新 UI,不弹系统通知 |
| App 后台(进程存活) | WS 存活 → 弹系统通知(Channel: "聊天消息";免打扰会话不弹) |
| App 被杀 | 无推送通道 → WorkManager 周期 sync 兜底(延迟可接受);不做厂商推送(小米/华为/OPPO/vivo 通道均不接入,保持简单) |
| 全局勿扰时段(二期) | chat.dnd_start/end 内不弹通知,角标保留 |
10.5 Room 完整 DDL(chat 包独立 3 表 + 1 FTS 表)
kotlin
@Entity(
tableName = "chat_messages",
indices = [
Index(value = ["conversationId", "id"]),
Index(value = ["clientMsgId"], unique = true),
Index(value = ["senderId"]),
],
)
data class MessageEntity(
@PrimaryKey val clientMsgId: String, // 本地主键(幂等,先于服务端 id 存在)
val id: Long = 0L, // 服务端 id,回填后建立查询游标
val conversationId: Long,
val senderId: Long,
val msgType: String,
val content: String, // 原始 content JSON(与后端一致,不做二次解析存储)
val status: String, // SENDING/SENT/FAILED/RECALLED
val sendErrorCode: Int? = null, // 4011/4012 等,UI 展示对应提示
val replyToId: Long? = null,
val forwardFromId: Long? = null,
val isVoicePlayed: Boolean = false, // 语音未播红点(本地状态)
val createdAt: Long,
)
@Entity(tableName = "chat_conversations")
data class ConversationEntity(
@PrimaryKey val id: Long,
val type: String,
val name: String?, val avatarUrl: String?,
val peerUserId: Long?,
val lastMessageSummary: String?, val lastMessageAt: Long,
val unreadCount: Int, val isPinned: Boolean, val isMuted: Boolean,
val draft: String?, val isDeleted: Boolean,
val updatedAt: Long,
)
@Entity(tableName = "chat_contacts")
data class ContactEntity(
@PrimaryKey val userId: Long,
val nickname: String, val avatarUrl: String?, val signature: String?,
val remark: String?, val tags: List<String>?, // TypeConverter JSON
val isBlocked: Boolean, val isMutual: Boolean,
val conversationId: Long?,
val createdAt: Long,
)- FTS4 外部内容表
chat_messages_fts(conversationId + content),insert/update 同步维护,复用现有二维码 FTS 实现 - Room 版本 +1,无存量用户,不写 Migration,直接升级建表
- 本地清理:删除会话/清空记录按游标批量删;磁盘超阈值清最老会话媒体缓存文件(OSS 可重下)
10.6 ChatWsClient 连接状态机
IDLE ──登录成功──▶ CONNECTING ──握手成功──▶ CONNECTED
CONNECTED ──异常/超时──▶ RECONNECTING(指数退避 1s/2s/4s/.../30s 封顶)
RECONNECTING ──成功──▶ 先发 auth 再发 sync(afterId=本地maxId) ──▶ CONNECTED- 心跳:30s ping,服务端 60s 无活动断开;本地 45s 未收到 pong → 主动重连
- 触发重连:网络变更(ConnectivityManager 回调)、App 回前台、心跳超时、WS onFailure
- 收到
message.new但 id 与本地游标不连续(丢包)→ 立即触发 SyncWorker 补拉 - OkHttp WebSocket;前后台切换不主动断开(保持在线收通知)
10.7 消息发送管道(先本地后网络)
用户点击发送
1. 生成 clientMsgId=UUID,构造本地消息(status=SENDING)写入 Room → UI 即时上屏(转圈)
2. 媒体消息:先 FileManager 上传 OSS(进度回调更新气泡下方进度条)→ 成功拿 url + 元数据,失败则 status=FAILED
3. 文本/已就绪媒体 → 进入会话发送队列(同会话串行,按本地 createdAt 顺序)
4. 协程逐条 POST /api/chats/messages → 成功:回填 id、status=SENT
5. 失败分类:
- 网络/超时 → status=FAILED(感叹号),点击重发(同一 clientMsgId,服务端幂等)
- 业务错误 4011/4012/4021 → status=FAILED + sendErrorCode,气泡下展示服务端 message,不自动重试
6. App 退后台有未发送消息 → SyncWorker 继续发送(复用发送管道)10.8 UI 组件与性能
| 点 | 方案 |
|---|---|
| 消息列表 | LazyColumn + Paging 3(本地 PagingSource,key=id 稳定);向上滚动加载更早历史(beforeId) |
| 图片加载 | Coil + OSS 缩略图参数(resize w_400),点击大图查看原图(复用现有预览组件) |
| 语音播放 | MediaPlayer 单例池(同一时刻仅一条播放);气泡波形 + 播放进度 + 未播红点 |
| 时间分隔条 | 相邻消息 > 5 分钟插入本地渲染(不依赖服务端) |
| 发送气泡 | SENDING=转圈 / SENT=无标记 / FAILED=红色感叹号(点击重发)/ 已读显示(单聊自己气泡下「已读」) |
| 会话列表 | LazyColumn + diffUtil;未读角标 99+ 截断;置顶分组排序 |
| 草稿 | 本地即时存(输入防抖 500ms)+ 服务端异步同步(换设备恢复) |
10.9 本地通知细节
- NotificationChannel:
chat_messages(高优先级,声音+振动);点击 PendingIntent deep link 到对应会话页 - 免打扰会话:静默弹(无声音振动,仅角标);全局勿扰时段(二期):不弹通知,角标保留
- 前台时 WS 已实时渲染,不重复弹系统通知(以「页面可见性」判断)
- 应用图标角标:Launcher 角标依赖厂商(可选接入);应用内角标 = 会话未读总和 + 「新的朋友」红点
11. 扩展性设计
| 扩展方向 | 支撑点 | 工作量 |
|---|---|---|
| 新消息类型(红包/投票/订单卡片) | 1 个 Handler + content 契约,注册表自动收集 | 极小 |
| 群聊 | type=GROUP 预留;成员表通用(joined_at 可见性/@ 提醒/公告/踢人退群) | 中(二期) |
| 合并转发 | CARD cardType=CHAT_RECORD,content 内嵌 N 条摘要 | 小 |
| 输入状态 | WS typing 已定义,客户端节流(2s/次)上报 | 小(二期) |
| 陌生人会话 | chat.allow_stranger 开关,Service 一处判断 | 极小 |
| 语音转文字 | VOICE content transcript 预留,三期接 ASR 回填 | 中(三期) |
| 多设备 | 服务端全量 + sync 游标,天然支持 | 0 |
| 后台审计/禁言 | 三期:chat:audit 权限点 + chat.ban_user 检查 | 中 |
| 文件传输助手 | 自己与自己 SINGLE 会话 | 极小 |
| 语音/视频通话 | 独立 WebRTC 通道(信令可复用 WS),消息链路不动 | 远期 |
12. 群聊详细设计(✅ 二期已实施)
表结构与消息链路在一期已预留(type=GROUP / 成员表通用 / SYSTEM 消息),二期只新增群管理面,不动核心。
12.1 群会话模型
conversations.type=GROUP,name/avatar_url/owner_id 生效,uniq_key=nullconversation_members每人一行;群角色:OWNER(群主)/ ADMIN(管理员)/ MEMBER- 预留列(二期加):
group_nickname(群内昵称)、mute_until(禁言截止时间戳) - 上限:群人数
chat.max_group_members(默认 500)、每人建群chat.max_groups_per_user(50)
12.2 群管理 API
| 方法 | 端点 | 权限 | 描述 |
|---|---|---|---|
| POST | /api/chats/groups | 登录 | 建群 {name, memberIds:[...]}(仅可拉好友,≤上限) |
| GET | /api/chats/groups/{id} | 群成员 | 群详情(成员列表/公告/我的群昵称) |
| PUT | /api/chats/groups/{id} | 群主/管理员 | 改群名/群头像 |
| POST | /api/chats/groups/{id}/members | 群主/管理员 | 邀请成员(仅好友,同微信) |
| DELETE | /api/chats/groups/{id}/members/{userId} | 群主/管理员 | 踢人(群主不可被踢)→ 系统消息 |
| POST | /api/chats/groups/{id}/members/{userId}/role | 群主 | 设/撤管理员 |
| PUT | /api/chats/groups/{id}/members/me/nickname | 自己 | 改群内昵称(≤20 字) |
| POST | /api/chats/groups/{id}/leave | 自己 | 退群(群主必须先转让) |
| POST | /api/chats/groups/{id}/transfer | 群主 | 转让群主(新群主需为群成员) |
| DELETE | /api/chats/groups/{id} | 群主 | 解散群(全员系统消息 + 会话失效) |
| PUT | /api/chats/groups/{id}/announcement | 群主/管理员 | 群公告(Markdown,复用渲染链路) |
| POST | /api/chats/groups/{id}/mute | 群主/管理员 | 禁言成员 {userId, until} / 解除 |
| GET | /api/chats/groups/{id}/qrcode | 群主/管理员 | 群二维码(qr_content=group:{id},复用即时渲染) |
12.3 群行为细节(对齐微信)
| 行为 | 规则 |
|---|---|
| 入群方式 | 邀请(仅好友)/ 群二维码扫码(需群主/管理员开启)/ 分享 |
| 新成员可见性 | 只能看到 joined_at 之后的消息(历史按 members.joined_at 过滤,微信同款) |
| @ 提醒 | content 可带 atUserIds;被 @ 者即使会话免打扰也推送提醒 |
| 群事件消息 | SYSTEM 承载:XX 邀请 YY 入群 / XX 退群 / XX 被移出群聊 / XX 修改群名为 XX / 公告更新 |
| 退群/被踢 | members 行删除;历史消息 sender 信息从 users 实时取(不冗余快照) |
| 群主退群 | 必须先转让(微信同款) |
| 解散群 | 全员 members.is_deleted 强制置位 + 系统消息;消息保留(审计) |
| 群聊免打扰/置顶 | 复用 conversation_members 偏好,无新增 |
12.4 群聊消息与推送
- 消息链路完全复用一期(msg_type/content/幂等/已读/撤回/转发),零新增消息类型(SYSTEM 已覆盖群事件)——✅ 已按此实施
- 推送范围:群全部成员在线连接;离线成员靠 sync 补拉(sync 过滤 joined_at + 清空游标)
- 大群优化(>100 人):WS 改为推
conversation.update+ 客户端主动 sync,避免单消息 N 倍放大(§16 风险项) - 群已读:会话级游标(同单聊,显示「N 人已读」需额外聚合,二期评估)
12.5 权限
- 群聊为用户级社交功能(APP 域),同单聊不做 ORG 绑定
- 管理后台:群列表/消息查询归入三期
chat:audit;封群归入chat:ban
13. 权限语义
- 好友/聊天为用户级功能(APP 域),登录即可用,不绑定组织角色(与微信一致)
- 管理后台(PLATFORM 域):一期
chat_setting:read/update(SYSTEM_ADMIN 全量 + OPERATION_ADMIN),聊天设置与统计;三期chat:audit(记录查询留痕)+chat:ban(禁言) - 数据隔离:消息访问严格校验「请求者 ∈ 会话成员且未删除会话」;联系人接口仅本人数据;黑名单校验在 Service 层
- 接口限流(防滥用):发消息 20 条/10 秒、加好友 20 次/天(chat.* 设置可调,限流器计数,参照现有限流做法)
14. 实施计划
| 阶段 | 范围 | 内容 |
|---|---|---|
| 一期 | 联系人 + 单聊 + 记录 + 管理设置 | 6 表建库、好友申请/黑名单/备注标签、懒会话、TEXT/IMAGE/VOICE/VIDEO/FILE、多图、已读/撤回/引用/转发/草稿/清空/删会话、/ws/chat、sync 补拉、Android 通讯录/会话列表/聊天页、聊天记录本地缓存 + FTS 搜索、管理后台聊天设置页(ChatSettings.vue + chat_setting 权限点 + 设置校验)+ 聊天统计概览 |
| 二期 | 群聊 + 体验(详见 §12,** 已实施**) | 群 CRUD/邀请(仅好友)/踢人/退群/转让/解散/公告/群昵称/禁言/群二维码、合并转发(CARD CHAT_RECORD)、输入状态(typing)、全局勿扰(chat.dnd_start/end)、聊天记录导出(JSON);Android 群详情页/建群/语音/图片/文件/名片发送 |
| 三期 | 合规与管理 | 后台聊天审计、封禁/禁言、敏感词过滤、语音转文字、保留期策略上线 |
14.1 文档联动清单(本功能涉及修改的其他文档)
按项目「文档同步要求」,实施本功能时以下文档需同步更新;本专用文档(/designs/messaging/contacts-chat.md)为唯一细节来源。
| 文档 | 修改内容 |
|---|---|
/architecture/data-model.md | 新增 7 表(contact_requests / contacts / contact_blocks / conversations / conversation_members / messages / user_chat_settings)+ users.chat_qr_code 列 + 模块→实体清单更新 |
/reference/backend.md | 项目结构新增 chat 包(controller/service/handler/ws)+ Service 清单 + API 接口清单更新(新增约 30 个端点) |
/reference/api.md | 新增「联系人/聊天」章节:联系人 15 接口、会话 9 接口、消息 5 接口、管理后台 3 接口(以 §6.7 契约为准) |
/architecture/overview.md | 文档索引已更新;§3.5 各角色功能明细表加「聊天」行(个人用户 ✓,其余按角色表) |
/reference/admin.md | 新增「聊天设置」页(ChatSettings.vue,路由 /chat-settings)+ 菜单入口 + chat_setting 权限说明 |
/reference/android.md | 新增 chat 包:Room 3 表 + FTS、页面(通讯录/会话列表/聊天页/资料页/我的二维码/聊天设置/存储管理)、ChatWsClient、组件清单 |
/designs/camera/scan-flow.md | 扫码路由新增 user:{随机码} 前缀分支(我的名片码 → 资料页 → 添加好友);group:{id} 二期 |
/designs/auth/user-state.md | 注销账户补充聊天数据清理行为(§8.4);退出登录/切换账号清空本地聊天缓存(客户端) |
/architecture/data-model.md 附录 | system_settings 新增 chat.* key 清单(§4.7) |
15. 文件清单(一期)
| 文件 | 用途 |
|---|---|
entity/ContactRequest.kt Contact.kt ContactBlock.kt | 好友申请/关系/黑名单实体 |
entity/UserChatSetting.kt | 个人聊天隐私设置(user_chat_settings) |
entity/Conversation.kt ConversationMember.kt Message.kt | 会话/成员/消息实体 |
repository/ 6 个 Repository | 数据访问 |
service/ContactService.kt | 申请/关系/黑名单/备注标签 |
service/ChatService.kt | 会话 + 消息编排(发送/撤回/已读/转发/清空) |
service/ChatSyncService.kt | 增量补拉(afterId 游标 + 清空过滤) |
service/MessageTypeRegistry.kt | 消息类型注册表 |
service/handler/ 接口 + 8 个实现 | 类型校验/摘要/视图 |
ws/ChatWsHandler.kt | 多设备长连接 |
controller/ContactController.kt ChatController.kt | REST API |
dto/ChatDto.kt | 请求/响应 DTO |
config/WsAuthInterceptor.kt | 抽取公共握手校验(通知/聊天共用) |
V1__init_schema.sql | 追加 6 张表 + system_settings 预置 chat.* key |
controller/AdminChatController.kt | 管理后台聊天设置/统计 API |
service/ChatSettingService.kt | 设置类型化读取(缓存)/更新(校验 + 留痕) |
admin: views/ChatSettings.vue + 路由 /chat-settings | 聊天设置页(分组表单 + 统计卡片) |
Android: chat/ 包(Room/Repository/WS/ViewModel/4 页面) | 客户端实现 |
二期已实施:GroupController.kt GroupService.kt job/ChatCleanupJob.kt | 群聊管理面(§12) |
16. 风险与对策
| 风险 | 对策 |
|---|---|
| 消息量增长 | 索引 (conversation_id, id) + 游标分页;预计 1 万用户×10 条/天 ≈ 10 万条/天 ≈ 36GB/年(可接受);retention_days 归档/清理(三期策略) |
| WS 多设备推送放大 | 按会话成员去重推送;群聊大群改「在线拉取」模式(二期权衡) |
| 未读计数漂移 | 游标式计算(max_id − last_read_id),天然自愈,不做冗余计数器 |
| 消息丢失 | client_msg_id 幂等 + WS 断线重连 sync 补拉 + WorkManager 周期兜底,三层保险 |
| 媒体内容审核缺失 | 一期:URL 白名单 + 大小配额;敏感词/图片审核三期(与后台审计同批) |
| 设置项被绕过/越权修改 | chat_setting 仅 SYSTEM_ADMIN + OPERATION_ADMIN;服务端类型化范围校验(数值区间/枚举),客户端校验仅提示;变更写 admin_logs |
| 名片码枚举骚扰/搜索暴露 | 名片码用 16 位随机串(非自增 id)+ 可重置作废;手机号搜索按 phone_searchable 过滤(不暴露存在性) |
| 好友上限误伤存量用户 | 默认 500 足够;达上限拒绝新申请并提示「好友数量已达上限」,不静默失败;上限下调时只拦新申请,不清理存量 |
| 媒体上传滥用(刷存储) | 每日媒体总量配额(media_daily_quota_mb)在 STS 签发前拦截;上传文件走 files 表计量(现有链路) |
| 清空游标误伤新消息 | 清空仅置游标不物理删;发消息/拉取以 max(id) 为准,新消息必然 > 游标 |
| 撤回内容合规 | content 保留 + recalled_at 留痕,管理后台可查(三期审计) |
| 后端单点(WS 无集群) | 单机部署阶段够用;未来集群时 ChatWsHandler 需 Redis pub/sub 广播(架构已隔离,替换内部实现即可) |
17. 容量估算(一期基线)
| 项 | 估算 |
|---|---|
| 单条消息存储 | ~1KB(含 content JSON 与索引开销) |
| 日消息量(1 万活跃用户,10 条/人/天) | ~10 万条 ≈ 100MB/天 |
| 年存储 | ~36GB(MySQL 可承受;超 2 年可启用归档) |
| 会话列表查询 | 走冗余列(last_message_id/at),单用户 O(置顶数+会话数),无大表 join |
| WS 连接数 | 1 万用户 × 2 设备 = 2 万连接,单机 WebSocket 可支撑(NIO) |
| sync 增量拉取 | 单次 ≤200 条,按 id 区间查询走主键索引,毫秒级 |
18. 测试验收与运维监控
18.1 测试验收清单(对照 §2 场景)
| # | 用例 | 预期 |
|---|---|---|
| T01 | 好友申请全流程(发/收/通过/拒绝/红点) | 状态正确、双向建好友、系统消息 |
| T02 | 非好友发消息 | 4011 + 「对方开启了朋友验证」 |
| T03 | 拉黑/取消拉黑 | 双向拦截、历史保留、对方无感知 |
| T04 | 五类消息(文本/图片/语音/视频/文件) | 先传后发、顺序正确、幂等 |
| T05 | 多图 9 张 | 拆条、顺序保持 |
| T06 | 已读回执 | 单聊显示「已读」、会话未读清零 |
| T07 | 撤回(2min 内/外) | 双方可见撤回、超时 4014 |
| T08 | 引用/转发 | 引用块渲染、forward_from_id 正确 |
| T09 | 断网发送 → 重发 | client_msg_id 幂等,无重复消息 |
| T10 | 离线接收(杀进程后重开) | 重连 sync 补拉、未读正确 |
| T11 | 双设备并发 | 多连接在线、已读同步 |
| T12 | 会话懒创建并发(双方同时发首条) | 仅一个会话(uniq_key 兜底) |
| T13 | 清空/删除会话/删除好友 | 游标语义正确、会话复活、聊天冻结 |
| T14 | 管理后台设置生效 | 改文件上限→超限被拒;改好友上限→4022 |
| T15 | 限额/限流 | 发消息限流、每日申请上限、媒体每日总量 |
| T16 | 设置防越权 | 非 chat_setting 权限 403 |
| T17 | 我的二维码(生成/扫码添加/重置) | 扫码路由 user: 前缀、申请来源标记、旧码作废 |
| T18 | 隐私开关 | 关手机号搜索→搜不到;关验证→申请直接成好友 |
| T19 | 文件传输助手 | 自己发给自己、跨设备 sync 后可见 |
| T20 | 退出登录切换账号 | 本地聊天缓存清空、WS 断开、无串号 |
| T21 | 群聊全流程(✅ 已联调) | 建群/群消息/系统消息/设管理员/公告/群详情/合并转发/导出 |
| T22 | 禁言/解除(✅ 已联调) | 禁言 4022 拦截、解除恢复;typing 上报 200 |
18.2 运维监控
| 指标 | 来源 | 告警阈值 |
|---|---|---|
| WS 连接数 | ChatWsHandler 计数器(暴露监控端点) | 峰值 2 倍 |
| 消息 QPS | messages 增长率(定时统计) | 突发 10x |
| 推送失败率 | ChatWsHandler 发送异常计数 | > 5% |
| 发送接口 P99 | 现有日志链路 | > 2s |
| 聊天表容量 | DataCleanupService 统计 | retention 失效 |
| 设置变更留痕 | admin_logs 抽查 | 定期审计 |
18.3 上线检查单
- [ ] V1__init_schema.sql 追加 6 表 + chat.* 设置 key + 权限点(chat_setting read/update 预置授权)
- [ ] NPM
/ws/反代已配置(系统设计 4.2 节) - [ ] OSS 域名白名单配置(聊天 validate 用)
- [ ] 配额/限流默认值核对(§9.1 总表)
- [ ] Android Room 建表(无存量用户,无需 Migration,直接升级版本号)
- [ ] 管理后台菜单 + 按钮权限显隐验证
19. 三端代码对照落地(现状核查)
对照三端实际代码核查方案的落地点与修正点。本节是「方案 ↔ 代码」的映射,实现时按此落地。
19.1 后端(codenote-server)
| 方案点 | 现状代码 | 落地点 |
|---|---|---|
| WS 实时通道 | config/WebSocketConfig.kt 已注册 /ws/notifications(单连接 ConcurrentHashMap,后连顶掉先连) | 抽取公共 WsAuthInterceptor(现拦截器逻辑原样复用),新增 /ws/chat + ChatWsHandler(CopyOnWriteArrayList 多连接);两端点并存 |
| JWT 登录态 | config/UserIdArgumentResolver.kt(@AuthenticationPrincipal userId: Long?) | 聊天 Controller 直接用 @AuthenticationPrincipal,零新增 |
| OSS 直传 | service/StsService.kt(STS 900s)+ FileController | 媒体上传完全复用;每日媒体总量在 StsService.generateStsToken() 内按 files 表当日聚合校验(一处拦截,所有上传生效) |
| system_settings | AdminController GET/PUT /api/admin/settings(Map 批量)+ AdminService.updateSettings | 聊天设置复用 system_settings 存储,但走独立 ChatSettingController(类型化 DTO + 范围校验),不裸用 Map |
| 权限点 | service/PermissionRegistry.kt(212 个,p(domain,resource,action,label) 注册,启动幂等同步) | 新增 chat_setting:read/update(预置授权 OPERATION_ADMIN)+ 三期 chat:audit/chat:ban,同模式注册 |
| 限流参照 | service/EmailService.kt checkRateLimit()(窗口计数) | 发消息限流/每日申请上限照此模式(Caffeine/内存窗口) |
| 建表 | V1__init_schema.sql(禁止 Flyway) | 追加 7 表 + users.chat_qr_code 列 + chat.* 设置 key 预置 |
| 注销清理 | AuthService.account/delete + DataCleanupService | 注销钩子补聊天清理(§8.4) |
19.2 Android(codenote-android,多模块:app + core/{common,data,domain,theme,ui} + feature/×15)
| 方案点 | 现状代码 | 落地点 |
|---|---|---|
| 模块归属 | feature 已有 15 个业务模块 | 新增 feature/chat 模块(依赖 core:data/ui/common/domain + theme),不进 app 单模块 |
| 扫码路由(重要修正) | feature/camera/ScanContentParser.kt:ScanType 枚举(ACTIVITY/CHECK_IN/PASS/ORG_JOIN/URL/WIFI/TEXT)+ parse() 前缀匹配;解析在客户端,后端不参与 | 新增 ScanType.USER + user: 分支(解析随机码)→ ScanResultViewModel/Screen 路由 → 调 GET /api/contacts/by-qr-code/{code} → 聊天模块用户资料页;/designs/camera/scan-flow.md 同步点即此三文件 |
| WS 客户端 | core/data/.../NotificationWebSocketManager.kt(OkHttp + 5s 重连×10 + SharedFlow 事件) | 新建 ChatWsManager(/ws/chat,事件协议见 §3.3);可选抽公共 WS 重连基座,不强制 |
| 网络层 | core/data/.../ApiService.kt(Retrofit,已 754 行) | 聊天接口独立新文件 ChatApiService.kt + DI 注册,避免膨胀现有文件 |
| OSS 上传 | core/data/.../FileManager.kt(uploadAvatar/AssetPhoto/AlbumPhoto + 压缩 + 重试 + 服务端 fallback) | 按 AGENTS.md 规则新增 uploadChatMedia()(复用 uploadWithCompress / uploadToOssWithRetry / notifyServer) |
| Room | core/data/.../CodeNoteDatabase.kt version=3(注释明确:开发阶段无用户,重装全新建库无需迁移) | version 3→4,不写 Migration(与「不考虑旧数据」一致);新增 chat 3 表 + FTS + Daos |
| 离线同步参照 | core/data/sync/SyncManager.kt(实体级 pull/push) | 聊天不复用实体级同步(消息级游标语义不同),独立 ChatRepository + ChatSyncWorker 实现(§10.7) |
| 地图选点 | core/ui/AmapWebMap.kt(活动地址选择在用) | 聊天「位置」消息复用选点组件 → LOCATION content |
| 链接打开 | core/ui/component/MarkdownView.kt(WebView 容器) | 聊天链接/消息渲染复用 WebView 容器能力 |
| 事件总线 | core/common/EventBus.kt | 新消息/会话刷新经 EventBus 广播(会话列表页监听) |
| Toast | core/ui/snackbar/ToastManager.kt(AGENTS.md:必须传具体字符串) | 发送失败/操作反馈统一走 ToastManager |
19.3 管理后台(codenote-admin)
| 方案点 | 现状代码 | 落地点 |
|---|---|---|
| 页面风格 | views/Settings.vue(el-card 分组 + el-switch/el-input-number,226 行) | views/ChatSettings.vue 沿用同风格(统计卡片 + 4 分组表单) |
| 路由/菜单 | 现有路由注册 + 侧边栏菜单 | 新增 /chat-settings 路由 + 菜单项(/reference/admin.md 同步) |
| 错误处理 | request.ts 拦截器统一处理(adminApi 无需重复) | 聊天设置页直接调 adminApi,无重复处理 |
| 权限显隐 | 现有按钮权限机制 | 保存按钮按 chat_setting:update 显隐 |
19.4 方案修正点汇总(对照代码后的变更)
| # | 原方案 | 修正 |
|---|---|---|
| 1 | 扫码路由「/designs/camera/scan-flow.md 同步」 | 精确到:Android ScanContentParser.kt 加 ScanType.USER + user: 解析;后端只新增反查接口 GET /api/contacts/by-qr-code/{code}(§6.1 已补) |
| 2 | 未指明聊天 WS 客户端实现 | 参照已有 NotificationWebSocketManager 模式新建 ChatWsManager,两通道并存 |
| 3 | 聊天 API 进 ApiService | 独立 ChatApiService.kt(现有 ApiService 754 行,不宜再膨胀) |
| 4 | 聊天媒体上传「复用 FileManager」 | 新增 uploadChatMedia() 方法(AGENTS.md:OSS 业务必须加在 FileManager) |
| 5 | Room「版本 +1 不写 Migration」 | 确认现状 version=3 注释即「重装全新建库」策略,3→4 直接建表,与「不考虑旧数据」完全一致 |
| 6 | Android 模块归属未指明 | 新增 feature/chat 模块(对齐 15 个 feature 模块模式) |
| 7 | 媒体每日总量「StsService 签发前校验」 | 确认实现点:StsService.generateStsToken() 内按 files 表当日聚合(一处拦截全端生效) |
20. 一次性执行文件清单(全量,以本节为执行基准)
按依赖顺序分 6 阶段执行;每阶段完成后可编译验证再进入下一阶段。
阶段 0:数据库(先行,后端编译依赖)
| 操作 | 文件 | 内容 |
|---|---|---|
| 改 | codenote-server/src/main/resources/db/migration/V1__init_schema.sql | 追加 7 表(contact_requests/contacts/contact_blocks/user_chat_settings/conversations/conversation_members/messages)+ users.chat_qr_code 列 + system_settings 预置 chat.* key |
阶段 1:后端 chat 包(codenote-server/src/main/kotlin/com/codenote/)
| 操作 | 文件 | 内容 |
|---|---|---|
| 新 | entity/ContactRequest.kt Contact.kt ContactBlock.kt UserChatSetting.kt | 联系人/申请/黑名单/隐私设置实体 |
| 新 | entity/Conversation.kt ConversationMember.kt Message.kt | 会话/成员/消息实体 |
| 新 | repository/ 7 个 Repository | 对应数据访问 |
| 新 | service/ContactService.kt | 申请/关系/黑名单/备注标签/名片码/搜索 |
| 新 | service/ChatService.kt | 会话+消息编排(发送/撤回/已读/转发/清空/草稿) |
| 新 | service/ChatSyncService.kt | afterId 增量补拉 + 清空游标过滤 |
| 新 | service/ChatSettingService.kt | 设置类型化读取(缓存)/更新(校验+留痕) |
| 新 | service/MessageTypeRegistry.kt + service/handler/(接口 + Text/Image/Voice/Video/File/Location/Card/System 8 个实现) | 消息类型注册表 |
| 新 | ws/ChatWsHandler.kt | 多设备长连接 + 事件推送 |
| 新 | controller/ContactController.kt | 联系人 18 接口(§6.1) |
| 新 | controller/ChatController.kt | 会话 9 + 消息 5 接口(§6.2/6.3) |
| 新 | controller/AdminChatController.kt | 管理后台设置/统计 3 接口(§6.6) |
| 新 | dto/ChatDto.kt | 全部 DTO(§6.7 契约) |
| 改 | config/WebSocketConfig.kt | 抽公共 WsAuthInterceptor + 注册 /ws/chat |
| 新 | config/WsAuthInterceptor.kt | 从 WebSocketConfig 抽取的公共握手校验 |
| 改 | service/PermissionRegistry.kt | 新增 chat_setting:read/update(预置授权 OPERATION_ADMIN)+ 三期 chat:audit/chat:ban |
| 改 | service/StsService.kt | generateStsToken() 加每日媒体总量校验(files 当日聚合) |
| 改 | service/AuthService.kt | 注销账户钩子:清 user_chat_settings + contacts + members(§8.4) |
| 改 | service/DataCleanupService.kt | chat.retention_days 消息清理任务 |
阶段 2:Android core 改造(codenote-android)
| 操作 | 文件 | 内容 |
|---|---|---|
| 改 | core/data/src/main/java/com/codenote/core/data/local/CodeNoteDatabase.kt | version 3→4,注册 chat 3 表 + FTS |
| 改 | core/data/src/main/java/com/codenote/core/data/local/Daos.kt | 新增 ChatDao/ChatConversationDao/ChatContactDao |
| 改 | core/data/src/main/java/com/codenote/core/data/repository/FileManager.kt | 新增 uploadChatMedia()(复用压缩/重试/notifyServer) |
| 改 | feature/camera/src/main/java/com/codenote/feature/camera/ScanContentParser.kt | 新增 ScanType.USER + user: 前缀解析 |
| 改 | feature/camera/src/main/java/com/codenote/feature/camera/ScanResultViewModel.kt + ScanResultScreen.kt | USER 类型 → 调 by-qr-code 反查 → 跳聊天模块资料页 |
| 改 | core/data/.../di/DataModule.kt | 提供 ChatApiService / ChatWsManager(Hilt) |
| 改 | settings.gradle.kts | include :feature:chat |
阶段 3:Android feature/chat 模块(新建)
| 操作 | 文件 | 内容 |
|---|---|---|
| 新 | feature/chat/build.gradle.kts + AndroidManifest.xml | 模块骨架(依赖 core:data/ui/common/domain/theme) |
| 新 | data/ChatApiService.kt + ChatModels.kt | Retrofit 接口 + VO(§6.7 契约) |
| 新 | data/ChatRepository.kt | 发送管道/历史分页/sync/已读/草稿 |
| 新 | data/ChatWsManager.kt | /ws/chat 连接(参照 NotificationWebSocketManager)+ 重连 |
| 新 | data/ChatSyncWorker.kt | 后台兜底补拉(WorkManager) |
| 新 | data/local/ChatEntities.kt ChatDaos.kt ChatConverters.kt | Room(§10.5 DDL) |
| 新 | di/ChatModule.kt | Hilt 依赖注入 |
| 新 | ui/ContactsScreen.kt ContactProfileScreen.kt ContactRequestListScreen.kt | 通讯录三页 |
| 新 | ui/ConversationListScreen.kt ChatScreen.kt | 会话列表 + 聊天页 |
| 新 | ui/MyQrCodeScreen.kt ChatSettingsScreen.kt(含存储管理) | 我的二维码 + 聊天设置 |
| 新 | ui/components/(MessageBubble/InputBar/AttachmentPanel/VoiceButton 等) | 聊天 UI 组件 |
| 新 | ui/ChatViewModel.kt ConversationListViewModel.kt ContactsViewModel.kt | 状态管理 |
| 新 | ChatNavigation.kt | 聊天路由 |
| 改 | app/src/main/java/com/codenote/navigation/AppNavigation.kt | 注册 chatNavigation |
| 改 | 主入口/Application 或 MainActivity | ChatWsManager 登录后启动、登出断开 |
阶段 4:管理后台(codenote-admin)
| 操作 | 文件 | 内容 |
|---|---|---|
| 新 | src/views/ChatSettings.vue | 聊天设置页(统计卡片 + 4 分组表单) |
| 改 | src/router/(路由注册文件) | 新增 /chat-settings 路由 + 侧边栏菜单项 |
| 改 | src/api/(adminApi 文件) | 新增 chat settings/stats 方法 |
阶段 5:文档同步(§14.1 联动清单)
| 文档 | 操作 |
|---|---|
/architecture/data-model.md | 改(7 表 + users 列 + chat.* key) |
/reference/backend.md | 改(chat 包 + Service + API 清单) |
/reference/api.md | 改(联系人/聊天/管理后台章节) |
/architecture/overview.md | 改(§3.5 功能明细表加聊天行;索引已加) |
/reference/admin.md | 改(ChatSettings 页 + 路由) |
/reference/android.md | 改(chat 模块页面/组件) |
/designs/camera/scan-flow.md | 改(ScanType.USER 分支) |
/designs/auth/user-state.md | 改(注销清理备注) |
阶段 6:测试验收
- 按 §18.1 用例 T01-T20 执行;每阶段编译通过后再进下一阶段。