# 数据模型与一致性

聊天服务以 SQLite 保存身份映射、联系人关系、聊天内容、媒体、新事、事件和通话状态。原生记忆数据另有客户端本地模型。本页描述源码中的逻辑实体与约束，帮助调用方理解 ID、可见性、重试和删除语义；不提供任何用户数据库或生产记录。

## 标识符、时间和对外对象

聊天用户、会话、群、媒体、好友申请和通话主要使用 UUID 字符串。普通消息、新事、评论、通知和事件使用各自的自增整数；企业消息的对外 `id` 被转换成字符串。MLS 使用自增 `sequence`，设备 ID 允许 1 至 80 个字母、数字、下划线或短横线。多数接口路径的 UUID 正则只校验字符集合与长度，客户端仍应产生合法 UUID。

时间字段如 `createdAt`、`updatedAt`、`expiresAt` 是 Unix 毫秒值。API 通常使用 camelCase，数据库使用 snake_case。`null` 表示暂无值，例如尚未接通的 `startedAt`、没有最新消息的 `lastMessage`、无权查看的手机号。调用方不得把 null 当空字符串，也不能跨不同实体比较 ID 大小判断全局时间顺序。

## 身份与资料

| 实体 | 核心关系与约束 |
| --- | --- |
| `users` | 本地 `id`；唯一 Account `issuer_sub`；显示名；唯一好友 ID `chat_code`；手机号查找摘要及有效期 |
| `user_profiles` | 每用户一条；简介、加密联系方式、手机号可见范围、更新时间 |
| `user_profile_phone_viewers` | 指定可见者的 owner/viewer 复合键；不能指定自己 |
| `oauth_flows` | state 摘要、服务端 verifier、到期时间、产品标识、原生挑战 |
| `login_tickets` | ticket 摘要、用户、到期时间、原生挑战及 state 摘要 |
| `device_sessions` | token 摘要、用户、到期时间 |

公开 `User` 只含 `{id,displayName,friendId}`，不会返回 Account subject、token 摘要或查找摘要。手机号查找使用带服务端密钥的 HMAC-SHA256，并在登录时刷新为 24 小时有效；个人资料中的可展示联系电话是另一字段，使用 AES-256-GCM 保存，由资料可见性控制。两者用途和存储形式不同。

## 好友、单聊与个人视图

`friend_requests` 保存申请双方及时间，禁止自己申请自己，同方向双方组合唯一。`friend_codes` 每用户只保留一个当前临时码摘要；`friend_lookup_attempts` 按用户与查找方法记录 10 分钟窗口内次数。

`conversations` 保存排序后的 `user_a` 与 `user_b`，要求前者小于后者并且双方组合唯一；`is_friend` 表示好友关系是否仍有效。删除好友不会仅靠删一条消息实现。`user_blocks` 是有方向的屏蔽关系，但发送、通话和部分资料检查使用任一方向屏蔽的联合条件。

`conversation_preferences` 为每个用户保存 `is_hidden` 和 `visible_after_message_id`。删除聊天会把个人可见边界推进到当时最后一条消息并隐藏列表项；恢复只取消隐藏，不回退边界。`hidden_messages` 为用户、范围与消息建立单条隐藏记录。因此“删除消息”是当前用户视图删除，不能解释为对方撤回、数据库物理擦除或备份销毁。

## 消息、群与已读

`messages` 与 `group_messages` 分别保存所属范围、发送者、`client_id`、正文、可选媒体引用和时间。二者各自要求 `(sender_id,client_id)` 唯一；同一发送者不能在该表内把同一 clientId 改用于其他范围或其他内容。服务端发现完全相同重试返回既有消息，内容变化返回 `CLIENT_ID_REUSED`。

`read_positions` 保存单聊用户最后已读消息，群的已读位置位于 `group_members`。更新只前进，且目标消息必须属于当前范围。未读数还受个人可见边界、单条隐藏和发送者过滤影响，不能只用最新 ID 减已读 ID 计算。

`groups` 保存名称、群主与创建时间；`group_members` 复合键保存加入时间、最后已读、历史可见边界、隐藏和静音设置。个人群最多 50 人。新成员的历史边界、退出或移除成员后的范围权限，均由服务端当前成员记录决定。群主可邀请、改名、移除成员或解散；群主退出时的归属处理由服务端事务完成，不能由客户端只删本地列表代替。

## 媒体、新事与位置

聊天 `media` 保存所有者、sticker/file、文件名、MIME、字节数、BLOB 和创建时间；城市内置贴纸还有用于按账号去重的 builtin key。消息通过 `media_id` 引用。下载权限来自所有者或当前用户可见消息，不能只凭 UUID 下载。

新事数据单独保存于 `moments_posts`、`moments_audience`、`moments_media`、`moments_likes`、`moments_comments`、`moments_notifications`、`moments_hidden` 和 `moments_reports`。可见性为 friends、selected、public、private；selected 另存指定好友列表，public 仍需登录访问 API。点赞是用户与帖子的唯一组合；评论可引用同帖评论。未绑定帖子的媒体超过 24 小时会被清理。新事不是 MLS 加密聊天数据。

`live_locations` 以范围类型、范围 ID、用户为复合键，保存经纬度、精度、开始和更新时间。POST 显式开启共享，PUT 只更新既有共享，DELETE 停止。服务端没有在本表中设置自动过期字段，调用方应展示更新时间并明确停止共享。

## MLS、通话与变更通知

MLS 表包括设备签名公钥、一次性密钥包、范围模式、加密事件和按设备收件确认。`e2ee_scopes` 的 preparing/active/blocked 与没有记录时的 legacy 共同构成状态机。密钥包以内容 SHA-256 作为 ID；事件唯一键为发送用户、设备和 clientId，payload 摘要用于检查重试一致性。

`calls` 保存通话范围、发起者、audio/video、状态、时间和结束原因；内部房间名不会出现在普通公开 call 对象中。`call_invites` 描述每位邀请者的响应，`call_participants` 描述实际加入和离开。`voip_devices` 将设备、推送 token 与登录 session 摘要关联。

`events` 是用户收件范围内的变更索引，含可空的 conversationId/groupId/messageId。MLS sequence 可作为 e2ee 通知的 messageId，但不因此变成普通消息 ID。长轮询默认每批最多 100 条。详见[实时同步](https://document.thestarline.cn/app/realtime-and-calls.md)。

## 存储与产品边界

数据库启用外键及 WAL，写操作通过事务维护去重、消息和事件一致性。备份与恢复必须同时考虑数据库、媒体 BLOB、加密配置、MLS 客户端状态和 Account 身份映射，不能只复制某一个消息表。

`enterprise_*` 表属于共享后台的独立星企领域，组织、部门、成员角色、企业频道、企业待办、公告与服务均以企业 ID 隔离，不属于星线 App 个人聊天数据模型的前台功能承诺。更多接口字段见[API 参考](https://document.thestarline.cn/app/api-reference.md)，数据保护边界见[安全和隐私](https://document.thestarline.cn/app/security-and-privacy.md)。

状态操作的具体影响见[联系人与个人群](https://document.thestarline.cn/app/contacts-and-groups.md)、[新事](https://document.thestarline.cn/app/moments.md)及[位置共享](https://document.thestarline.cn/app/location-sharing.md)，尤其注意个人视图删除、关系移除和完整解散使用不同清理分支。
