新事的发布、可见性与媒体
新事是服务端保存的图文与短视频内容,使用独立于聊天消息的表、媒体配额和通知机制。它没有接入MLS;可见范围由每次读取时的当前关系决定。服务端支持媒体Range,但当前iOS与Android播放器路径都先完整下载后播放。
模型与响应字段
| 对象 | 字段与含义 |
|---|---|
| Post | id、authorId、text、visibility、createdAt、author、media、likeCount、commentCount、liked、recentComments;作者读selected帖子另有viewerIds |
| Media | id、name、mimeType、kind=image/video、size、createdAt;原始字节另存BLOB |
| Comment | id、postId、authorId、replyToId、text、createdAt、authorName |
| Notification | id、actorId、actorName、postId、commentId、kind、createdAt、readAt;kind为like/comment/reply |
帖子、评论、通知ID为各自的整数序列,媒体和用户为UUID;createdAt/readAt是Unix毫秒,readAt可null。完整表定义及模型组装见chat-service/src/moments-repository.mjs:8-71,149-164,204-231,277-301。
可见范围不是发布时冻结的好友快照
| visibility | 作者之外的读取规则 |
|---|---|
| friends | 当前仍是好友、没有任一方向屏蔽 |
| selected | 上述好友条件成立,且用户在该帖moments_audience中 |
| public | 已登录聊天API用户,双方未屏蔽;不是匿名公开网页 |
| private | 只有作者 |
个人hide优先过滤,包括作者本人隐藏自己的帖子后也不会通过普通post读取。作者通常可读自己的帖子,其他用户还会结合屏蔽与当前好友关系。selected发布须有至少一位当前未屏蔽好友,最多100、不重复,不含自己;其他范围viewerIds必须为空。删除好友后selected对象即使仍在受众表,也不再满足当前好友条件。依据:chat-service/src/moments-repository.mjs:80-89,126-145。
没有公开PATCH帖子或调整既有visibility的路由。若页面因失去可见资格而返回404,应按不可再读取处理,不应在客户端继续展示缓存为“公开可见”。
两阶段上传与发布
- 客户端先准备媒体,按每个文件调用
POST /moments/media?name=...,Content-Type为实际MIME,body为原始字节。 - 上传成功得到media.id,暂时尚未绑定帖子,仅所有者可读取。
- 客户端收齐mediaIds后调用
POST /moments:text、visibility、viewerIds、mediaIds。 - 服务端在事务中确认媒体为当前用户拥有、尚未绑定其他帖子,插入帖子与受众,并绑定全部媒体。
- 返回201
{post}后更新列表;上传成功但未发帖不等于发布成功。
| 项目 | 服务端限制 |
|---|---|
| text | trim后最多2000个Unicode码点;文字或媒体至少一项 |
| 图片 | JPEG或PNG,单项10 MiB;PNG宽高1–2048 |
| 视频 | MP4或MOV,单项30 MiB;检查ftyp标识 |
| 单帖媒体 | 最多9图,或仅1段视频;不能图视频混合 |
| 账号媒体 | 新事独立500 MiB配额;未绑定媒体超过24小时清理 |
| JSON与文件名 | 发布JSON最多20000字节;name为1–120码点,无控制字符或路径分隔符 |
这些值见chat-service/src/server.mjs:350-365,416-434、chat-service/src/moments-repository.mjs:97-145。媒体原始字节和帖子内容都能被服务端读取;文件头检查不等于完整转码、恶意文件扫描或编解码器兼容性验收。
发布和上传没有clientId去重契约。若响应丢失,先查询自己的最新新事确认结果;重新上传可能额外占用空间,重复发帖也可能产生第二条。若上次实际发布成功,相同mediaIds再次发布会因已绑定返回INVALID_POST,而不是返回上次帖子。不能套用聊天消息的“相同clientId安全重试”。
Range读取与客户端实际行为
GET /moments/media/{mediaId}先按当前帖子权限检查,随后处理一个可选Range。支持bytes=0-1023或bytes=1024-,不支持bytes=-1024、多段或其他语法。合法范围返回206、Content-Range和Content-Length;不带Range返回200。结束位置超出文件末尾会截到末尾,起点超出大小、起点大于终点或非安全整数返回416 INVALID_RANGE。缓存为private、max-age=300,不是公开CDN许可。
合成例:对大小2048字节的有权访问媒体,请求Range: bytes=0-1023返回前1024字节;bytes=2048-返回416。首次未绑定媒体仅本人能下载,即使另一个用户知道媒体UUID也返回404。依据:chat-service/src/server.mjs:367-392、chat-service/src/moments-repository.mjs:117-123。
iOS MomentsStore.mediaData只接受200,把完整Data缓存,视频写临时mp4/mov再交AVPlayer;Android下载也只接受200,按图片10 MiB、视频30 MiB有界读取,点击视频后才下载并写缓存文件交VideoView。因此“后端支持Range”不代表当前两端已实现边下载边播放。依据:ios-app/Sources/Features/Moments/MomentsStore.swift:186-206、android-app/app/src/main/java/cn/thestarline/workbench/MomentsScreens.kt:380-438。
点赞、评论、删除和通知
PUT like为幂等点赞,DELETE like为取消;只有首次新增点赞产生通知,给自己的内容点赞不通知自己。评论1–500字,可带同帖replyToId,成功后产生comment或reply通知;回复对象缺失为INVALID_COMMENT。作者可删除自己评论,帖子作者可删除其帖下任意评论。删除被回复评论会把后续评论的replyToId置null,不会一并删掉所有回复。
只有帖子作者可DELETE帖子,数据库外键级联删除其媒体、评论、点赞与相关通知。hide只对本人隐藏,report以帖子+举报者去重并更新原因,不能举报自己的帖子。report成功只证明记录写入,不证明已有审核处理工作流。依据:chat-service/src/moments-repository.mjs:188-267。
Feed默认20、最多50条;before指向更旧帖子,返回nextBefore,不能把缺省替换成0。评论与通知固定每页30,通常倒序,nextBefore=null表示该次查询没有下一页。通知列表动态过滤屏蔽和不可见帖子,unreadCount按当前可见通知计算;mark read会将当前账号全部未读通知标记已读。新事没有在这些方法中写普通/events,客户端靠进入页面或显式刷新同步。
分页合并、互动历史与账号切换
feed在内部每次扫描最多100条候选,再逐条检查当前可见性,累积到请求数量加一才决定下一页。因此返回ID不连续、帖子数量少于候选数量都是正常情况;分页应使用nextBefore,不应自行减ID或认为被过滤内容不存在。客户端追加下一页还按帖子ID去重,避免刷新与分页重叠产生重复卡片。依据:chat-service/src/moments-repository.mjs:166-185、ios-app/Sources/Features/Moments/MomentsStore.swift:67-77。
通知记录的是过去发生的互动。取消赞只删除like关系,没有同步删除原来的点赞通知;取消后再点赞可能产生新的互动通知。当前liked状态应以帖子响应为准,不能由“收到过点赞通知”推断。评论者、帖子作者和被回复者相同时,会避免给同一人重复生成自己参与的通知;通知可见性仍在读取时重新过滤。
iOS在refresh检测到账号变化或退出时清空帖子、通知和内存媒体缓存,并删除其登记的视频临时文件;这不等于整个操作系统缓存已擦除。下载完成与刷新之间若账号发生变化,还需通过客户端生命周期测试验证旧响应不会影响新账号。本轮只记录源码机制,没有执行切换账号或缓存清除验收。依据:ios-app/Sources/Features/Moments/MomentsStore.swift:31-63。
双端差异与失败样例
| 场景 | iOS当前实现 | Android当前实现 |
|---|---|---|
| 图片准备 | 最长边缩到2048,以0.82质量重编码JPEG | 读取选择器原始JPEG/PNG字节,最多读上限+1;不做同等重编码 |
| 文字计数 | Swift String.count,按字素簇提示和禁用发布 | Kotlin take(2000)/length,按UTF-16代码单元截取 |
| 指定受众 | 候选过滤isFriend与非isBlocked;非selected强制发送[] | 候选从conversations取peer;发布固定带selected列表 |
| 发布后刷新 | 将返回post插入当前列表 | 确认提交后切回新事,再刷新列表 |
服务端计数是Unicode码点,包含组合字符的输入可能与两端计数不同,以服务端判定为准。Android还有源码可推导的失败场景:先选指定好友,再切到friends/public/private,selected列表未清空仍随请求发出,服务端因非selected带viewerIds拒绝400 INVALID_POST;iOS发送前清空该字段。该场景本轮未运行原生验收,不能写成已修复。依据:ios-app/Sources/Features/Moments/MomentComposerView.swift:36-62,194-236、ios-app/Sources/Features/Moments/MomentsStore.swift:91-108、android-app/app/src/main/java/cn/thestarline/workbench/MomentsScreens.kt:458-500,513-554。
正常例:friends+viewerIds=[]+一张刚上传的本人图片返回post。失败例:混传一图一视频、引用别人媒体、复用已绑定媒体或selected列表含已删除好友,均不会完成新帖子写入。详见API参考、限制与错误及验收矩阵。
源码核对范围:2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。