Skip to content

联系人聊天功能(IM 模块)详细方案

专用文档:本文档为联系人/聊天功能唯一详细设计文档,各端功能说明仅做索引引用与概要,实现细节一律以本文档为准(跨端契约 §6.7、表结构 §4、接口 §6、时序 §7)。

对标微信的通讯录 + 即时聊天能力。本文档从微信真实聊天场景出发逐场景拆解,给出可直接落地的完整设计: 好友关系、会话、多类型消息(文字/图片/语音/视频/文件)、聊天记录处理、已读/撤回/转发/引用、实时推送、离线补拉、多设备、通知体系。 设计目标:零耦合、可扩展 —— 独立模块独立建表,不修改现有表;消息/会话类型注册表驱动扩展。 开发前提:无存量用户、无旧版本兼容负担 —— 不写数据库迁移脚本(直接建表/改表),不兼容旧版 App,不留历史数据迁移逻辑。


1. 概述

1.1 与微信的功能对照(完整)

微信功能本方案实现状态
通讯录(好友列表)联系人列表 + 备注名 + 标签分组一期
添加好友:搜索手机号/用户IDPOST /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 设计原则

  1. 零耦合:全部新表(contact_* / conversation_* / messages),不触碰现有表;独立 com.codenote.chat 包;只复用公共设施(JWT、OSS、用户体系、扫码路由)。
  2. 类型驱动:消息体统一 msg_type + content(JSON),语义全在 content;新类型只注册 Handler。
  3. 懒会话:单聊会话不预建,首条消息时自动创建(微信同款)。
  4. 幂等:每条消息带 client_msg_id(UUID),唯一键去重,断网重发安全。
  5. 游标同步:消息 id 全局自增即会话内单调序号,承担四重职责:历史分页游标、增量补拉游标、已读游标、清空游标。
  6. 存储与传输解耦:媒体一律 OSS 直传(STS),消息表只存 URL + 元数据。
  7. 删除语义分级(对齐微信):撤回=双方可见标记;删消息=本地;清空=服务端游标;删会话=成员级标记。互不混淆。

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 / NotificationService

3.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 } }
typedata说明
message.newMessageVO新消息(推给会话全部成员)
message.recalled{messageId, conversationId, senderId}撤回
read.receipt{conversationId, userId, lastReadMessageId}已读回执
conversation.updateConversationVO会话变更(置顶/免打扰/草稿同步/复活)
contact.requestContactRequestVO收到好友申请
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_atcontent 保留(合规留痕,客户端按 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_strangerfalse陌生人可私聊(关闭=必须双向好友)
chat.require_verificationtrue加好友需验证(false=直接通过,双方自动建好友)
chat.retention_days0(永久)服务端消息保留天数,0=永久;到期由 DataCleanupService 定时清理
chat.recall_seconds120撤回时限(秒,微信为 2 分钟)
chat.max_send_rate20/10s发消息限流(防刷屏)
chat.max_draft_length2000草稿最大字符
chat.dnd_start / chat.dnd_end全局勿扰时段(✅ 已实施,HH:mm,管理后台可配)
chat.image_max_mb20图片最大 MB
chat.voice_max_seconds60语音最长秒数
chat.video_max_mb100视频最大 MB
chat.file_max_mb50文件最大 MB
chat.message_text_max5000文本最大字符
chat.media_daily_quota_mb200每用户每日媒体上传总量(MB),防滥用
chat.max_contacts500每用户好友上限
chat.max_contact_requests_daily20每日加好友申请上限
chat.contact_remark_max50好友备注最大长度
chat.contact_tags_max10好友标签最大数量
chat.max_pinned_conversations50置顶会话上限
chat.max_group_members500群人数上限(✅ 已生效)
chat.max_groups_per_user50每人建群上限(✅ 已生效)
chat.phone_searchabletrue手机号可被搜索(全局默认,个人可覆盖)

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_typecontent 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-countPENDING 数量("新的朋友"红点)
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}/announcementOWNER/ADMIN群公告(Markdown)
POST/api/chats/groups/{id}/membersOWNER/ADMIN邀请(仅好友)
DELETE/api/chats/groups/{id}/members/{userId}OWNER/ADMIN踢人(群主不可踢)
POST/api/chats/groups/{id}/members/{userId}/roleOWNER设/撤管理员
PUT/api/chats/groups/{id}/members/me/nickname自己群内昵称
POST/api/chats/groups/{id}/members/{userId}/muteOWNER/ADMIN禁言 {until} / 解除 {until:null}
POST/api/chats/groups/{id}/leave自己退群(群主先转让)
POST/api/chats/groups/{id}/transferOWNER转让群主
DELETE/api/chats/groups/{id}OWNER解散
GET/api/chats/groups/{id}/qrcodeOWNER/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未知消息类型「不支持的消息类型」
4021content 校验失败具体原因(超长/URL 非法/超配额)
4022好友/申请/置顶达上限「好友数量已达上限」/「今日申请次数已达上限」

6.6 管理后台 API(聊天设置与运营)

方法端点权限点描述
GET/api/admin/chat/settingsPLATFORM chat_setting:read读聊天设置(按分组返回,类型化 DTO)
PUT/api/admin/chat/settingsPLATFORM chat_setting:update批量更新(类型化 DTO + 数值范围校验 + 写 admin_logs)
GET/api/admin/chat/statsPLATFORM chat_setting:read聊天数据概览(会话/消息总量、今日消息、活跃会话)
GET/api/admin/chat/messagesPLATFORM chat:audit(三期)按用户/会话/关键词查聊天记录(审计留痕)
POST/api/admin/chat/users/{id}/banPLATFORM chat:ban(三期)封禁/解封发言(检查点:ChatService.send)
  • 存储:复用 system_settings 表(chat.* 前缀),不新建配置表;类型化 ChatSettingService 做 DTO 映射与范围校验(数值区间/枚举白名单),防绕过前端直接改坏配置
  • 读取:App 端 ChatConfigService Caffeine 缓存 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. 返回 MessageVO

7.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_mb20图片最大 MBImageHandler.validate + StsService 签发前双校验
chat.voice_max_seconds60语音最长秒数VoiceHandler.validate
chat.video_max_mb100视频最大 MBVideoHandler.validate
chat.file_max_mb50文件最大 MBFileHandler.validate
chat.message_text_max5000文本最大字符TextHandler.validate
chat.media_daily_quota_mb200每用户每日媒体上传总量StsService 签发前按 files 表当日聚合校验
联系人chat.max_contacts500每用户好友上限ContactService.accept / 直接通过(双方都校验)
chat.max_contact_requests_daily20每日加好友申请上限ContactService.createRequest
chat.contact_remark_max50备注最大长度ContactService.setRemark
chat.contact_tags_max10标签最大数量ContactService.setTags
chat.max_pinned_conversations50置顶会话上限ChatService.pin
行为chat.allow_strangerfalse陌生人可私聊ChatService.send(4011 校验前置)
chat.require_verificationtrue加好友需验证(false=直接建好友)ContactService.createRequest
chat.recall_seconds120撤回时限(秒)ChatService.recall
chat.max_draft_length2000草稿最大字符ChatService.saveDraft
chat.max_send_rate20/10s发消息限流ChatService.send(限流器)
chat.retention_days0(永久)消息保留天数DataCleanupService 定时任务
chat.dnd_start/chat.dnd_end全局勿扰时段 HH:mm通知模块(✅ 设置已可配;客户端时间段判断待接)
群聊(二期)chat.max_group_members500群人数上限GroupService
隐私chat.phone_searchabletrue手机号可被搜索(全局默认,个人可覆盖)ContactService.search
chat.max_groups_per_user50每人建群上限GroupService

媒体大小双保险:App 端选文件时本地提示(体验),服务端 Handler/StsService 强校验(安全)。每日媒体总量按 filesuser_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:updateSYSTEM_ADMIN(全量)+ OPERATION_ADMIN一期
chat:audit(聊天记录查询)SYSTEM_ADMIN + OPERATION_ADMIN三期
chat:ban(禁言/封禁发言)SYSTEM_ADMIN三期

10. Android 端设计

10.1 Room 表(3 张,独立 DAO)

Entity说明
ContactEntitycontacts联系人本地镜像(remark/tags/头像/昵称缓存)
ConversationEntityconversations会话 + 偏好(pinned/muted/deleted/未读/draft/最后消息摘要)
MessageEntitymessages消息(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(会话列表 + 未读聚合)、ContactsViewModelChatSearchViewModel
  • 媒体:复用 FileManager OSS 上传;录音用 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=null
  • conversation_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.ktREST 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 倍
消息 QPSmessages 增长率(定时统计)突发 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_settingsAdminController 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)
Roomcore/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 广播(会话列表页监听)
Toastcore/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)
5Room「版本 +1 不写 Migration」确认现状 version=3 注释即「重装全新建库」策略,3→4 直接建表,与「不考虑旧数据」完全一致
6Android 模块归属未指明新增 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.ktafterId 增量补拉 + 清空游标过滤
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.ktgenerateStsToken() 加每日媒体总量校验(files 当日聚合)
service/AuthService.kt注销账户钩子:清 user_chat_settings + contacts + members(§8.4)
service/DataCleanupService.ktchat.retention_days 消息清理任务

阶段 2:Android core 改造(codenote-android)

操作文件内容
core/data/src/main/java/com/codenote/core/data/local/CodeNoteDatabase.ktversion 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.ktUSER 类型 → 调 by-qr-code 反查 → 跳聊天模块资料页
core/data/.../di/DataModule.kt提供 ChatApiService / ChatWsManager(Hilt)
settings.gradle.ktsinclude :feature:chat

阶段 3:Android feature/chat 模块(新建)

操作文件内容
feature/chat/build.gradle.kts + AndroidManifest.xml模块骨架(依赖 core:data/ui/common/domain/theme)
data/ChatApiService.kt + ChatModels.ktRetrofit 接口 + VO(§6.7 契约)
data/ChatRepository.kt发送管道/历史分页/sync/已读/草稿
data/ChatWsManager.kt/ws/chat 连接(参照 NotificationWebSocketManager)+ 重连
data/ChatSyncWorker.kt后台兜底补拉(WorkManager)
data/local/ChatEntities.kt ChatDaos.kt ChatConverters.ktRoom(§10.5 DDL)
di/ChatModule.ktHilt 依赖注入
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 或 MainActivityChatWsManager 登录后启动、登出断开

阶段 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 执行;每阶段编译通过后再进下一阶段。