# 家庭聊天模块 ## 概述 一个家庭一个聊天室,成员通过 WebSocket 实时收发消息。支持模块内容转发、图片/视频/文件、消息撤回与删除。 --- ## 数据库表 ### b_chat — 聊天消息 ```sql CREATE TABLE b_chat ( id BIGINT PRIMARY KEY, family_id BIGINT NOT NULL, user_id BIGINT NOT NULL, content TEXT NOT NULL DEFAULT '', type SMALLINT NOT NULL DEFAULT 0, -- 0=text 1=image 2=video 3=file 4=source source_table VARCHAR(64), source_id BIGINT, source_data TEXT, -- 转发时存的快照 JSON recalled SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=撤回 deleted SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=管理员删除 create_time TIMESTAMPTZ NOT NULL, update_time TIMESTAMPTZ NOT NULL, create_by BIGINT NOT NULL, update_by BIGINT NOT NULL ); ``` **为什么不存 nickname / avatar** 消息只存 `user_id`,昵称和头像通过 `b_family_member` JOIN 实时取。这样用户更新资料后,所有历史消息自动显示最新的昵称和头像。如果成员已退出家庭(JOIN 不到),前端显示"已退出的成员"兜底。 **字段说明** - type: `0` 文本 / `1` 图片 / `2` 视频 / `3` 文件 / `4` 转发(所有子模块均可转发,服务端校验 `source_table` 是否存在即可) - recalled: 发送者 **2 分钟内**可撤回,全员看到"xxx 撤回了一条消息" - deleted: `b_family_member.role = 0`(管理员)可删除任意消息,全员不可见 ### b_file — 通用附件 ```sql CREATE TABLE b_file ( id BIGINT PRIMARY KEY, target_id BIGINT NOT NULL, file_key VARCHAR(256) NOT NULL, file_name VARCHAR(256), file_type SMALLINT NOT NULL, -- 1=image 2=video 3=file mime_type VARCHAR(64), file_size BIGINT, width INT, height INT, duration INT, sort_order INT NOT NULL DEFAULT 0, create_time TIMESTAMPTZ NOT NULL, update_time TIMESTAMPTZ NOT NULL, create_by BIGINT NOT NULL, update_by BIGINT NOT NULL ); ``` `target_id` 关联到任意表的记录(雪花 ID 全局唯一),chat / note / task 等模块共用。 ### 存量表改动 ```sql ALTER TABLE b_family_member ADD COLUMN last_read_id BIGINT DEFAULT 0; ``` --- ## 后端实现 ### 文件结构 ``` app-go/internal/ ├── ws/ │ ├── hub.go -- Hub + Room(map[family_id]*Room) │ ├── client.go -- 单连接读写 goroutine、心跳、断连处理 │ └── handler.go -- HTTP → WS 升级、鉴权、消息路由 └── router/ └── router.go -- 新增 GET /app/chat/ws ``` 依赖:`github.com/gofiber/contrib/v3/websocket`(Fiber v3 官方 WS 支持) ### WebSocket 协议 连接:`GET /app/chat/ws?token=xxx&family_id=123` 鉴权独立实现(不复用 `middleware.Auth()`,WS 无法设自定义 header):token 解 user_id → family_id 校验归属 → 加入 Room。 客户端消息: ```jsonc // 发送文本 {"type": "send", "content": "晚上吃啥"} // 发送图片/视频/文件(先 S3 上传拿 key,再发消息) {"type": "send", "content": "", "msg_type": 1, "attachments": [ {"file_key": "2026/06/25/uuid.jpg", "file_name": "photo.jpg", "file_type": 1, "mime_type": "image/jpeg", "file_size": 204800, "width": 1080, "height": 720} ]} // 转发 {"type": "send", "content": "看看这笔", "msg_type": 4, "source_table": "b_finance", "source_id": 456} // 撤回 {"type": "recall", "message_id": 123} // 删除(管理员) {"type": "delete", "message_id": 123} ``` 服务端广播: ```jsonc // 新消息 {"type": "message", "data": { "id": 123, "family_id": 1, "user_id": 1, "nickname": "小明", "avatar": "https://...", // 从 b_family_member JOIN "content": "晚上吃啥", "msg_type": 0, "attachments": null, "source_table": null, "source_id": null, "source_data": null, "create_time": "2026-06-25 21:30:00" }} // 撤回通知(全员) {"type": "message_recalled", "message_id": 123} // 删除通知(全员) {"type": "message_deleted", "message_id": 123} // 在线成员(全员广播,全量替换式) {"type": "online", "users": [{"user_id": 1, "nickname": "小明", "avatar": "https://..."}]} // 成员下线(单条) {"type": "offline", "user_id": 3} ``` ### 消息处理流程 ``` 客户端 WS 消息 → 解析 type 字段路由: send: 1. 从连接上下文取 family_id / user_id 2. getUniqueId 拿消息 ID 3. 若 msg_type = 4(转发):查 source_table WHERE id = source_id,整行转 JSON 存 source_data 4. 若 attachments 非空:批量 INSERT b_file(target_id = 消息 ID) 5. INSERT b_chat 6. JOIN b_family_member 取 nickname / avatar 7. 组装 message 广播到 Room recall: 1. 查 b_chat WHERE id = message_id 2. 校验 user_id == 当前用户 && now - create_time <= 2 分钟 3. UPDATE b_chat SET recalled = 1 4. 广播 message_recalled delete: 1. 查 b_family_member WHERE family_id = ? AND user_id = 当前用户 2. 校验 role = 0 3. UPDATE b_chat SET deleted = 1 4. 广播 message_deleted ``` 在线状态:WS 连上时广播 `online`(全量),断开时广播 `offline`(单条)。 --- ## 前端实现 ### 文件结构 ``` app-rn/src/ ├── app/(main)/(tabs)/chat/ │ └── index.tsx -- 聊天室 ├── app/(main)/chat/ │ └── history.tsx -- 历史消息搜索 ├── components/chat/ │ ├── MessageList.tsx -- FlatList 倒序渲染 │ ├── TextBubble.tsx -- 文本气泡 │ ├── ImageBubble.tsx -- 图片气泡 │ ├── VideoBubble.tsx -- 视频气泡 │ ├── FileBubble.tsx -- 文件气泡 │ ├── ForwardCard.tsx -- 转发卡片(source_data 渲染) │ ├── InputBar.tsx -- 输入框 + 附件按钮 │ └── OnlineBar.tsx -- 在线成员头像行 └── hooks/ └── useChatWebSocket.ts -- WS 连接、自动重连、消息去重 ``` ### 聊天页面数据流 ``` 进入页面 → loadDataPageApi("b_chat", "family_id = ? AND deleted = 0", "create_time DESC", [familyId]) → 取 b_family_member 获取成员昵称/头像 Map(发消息和渲染时用) → 若 msg_type IN (1,2,3):批量查 b_file WHERE target_id IN (...) → 连接 WS 收到 WS message → id 去重 → 追加到 FlatList 底部 → 更新 last_read_id 发送消息 → 文本:直接 WS send → 图片/视频/文件:先 s3UploadFileApi → 拿 key → WS send → 转发:子模块调 WS send 带 source_table + source_id → 乐观更新(灰色气泡 → 成功实色 / 失败红色+重试) 撤回 → WS send recall → 收到 message_recalled → 对应气泡替换为提示 删除(管理员可见删除按钮) → WS send delete → 收到 message_deleted → 移除该消息 ``` 依赖:React Native 内置 [`WebSocket`](https://reactnative.dev/docs/network#websocket),无需额外安装。重连逻辑在 `useChatWebSocket` hook 里封装。 ### WS 连接管理 - 断线自动重连(exponential backoff:1s → 2s → 4s → … → 上限 30s) - 重连后用 `loadDataPageApi` 拉 `WHERE id > lastReceivedId` 补洞 - `online` 事件更新 OnlineBar,`offline` 移除对应头像 ### 转发卡片渲染 `JSON.parse(source_data)` 展示关键字段,点击跳转对应模块详情页。源记录删除后卡片仍可展示(快照自包含)。 ### 历史消息搜索 独立页面 `chat/history.tsx`,走 `loadDataPageApi` + `loadDataBySqlApi`: - 关键词搜索:`WHERE content ILIKE '%keyword%'` - 按 msg_type 筛选 - 按日期范围筛选 - 点击消息跳回聊天室定位