# 新事的发布、可见性与媒体

新事是服务端保存的图文与短视频内容，使用独立于聊天消息的表、媒体配额和通知机制。它没有接入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，应按不可再读取处理，不应在客户端继续展示缓存为“公开可见”。

## 两阶段上传与发布

1. 客户端先准备媒体，按每个文件调用`POST /moments/media?name=...`，Content-Type为实际MIME，body为原始字节。
2. 上传成功得到media.id，暂时尚未绑定帖子，仅所有者可读取。
3. 客户端收齐mediaIds后调用`POST /moments`：text、visibility、viewerIds、mediaIds。
4. 服务端在事务中确认媒体为当前用户拥有、尚未绑定其他帖子，插入帖子与受众，并绑定全部媒体。
5. 返回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参考](https://document.thestarline.cn/app/api-reference.md)、[限制与错误](https://document.thestarline.cn/app/limits-and-errors.md)及[验收矩阵](https://document.thestarline.cn/app/qa-matrix.md)。

源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。
