feat: moment

This commit is contained in:
zhr 2026-09-18 17:51:42 +08:00
parent c11fbda141
commit 0c821ffa16
50 changed files with 4782 additions and 16 deletions

View File

@ -78,7 +78,12 @@ Do not convert these to integers.
|----------------|----|------|--------| |----------------|----|------|--------|
| `DeviceType` | login DTO `string` + `oneof` | `device_type` / `platform` | `ios android harmony windows macos web` | | `DeviceType` | login DTO `string` + `oneof` | `device_type` / `platform` | `ios android harmony windows macos web` |
| `Language` | `i18n.Lang` | `language`, `Accept-Language`, `X-Language` | `zh-CN` `en` `zh-TW` (default `zh-CN`) | | `Language` | `i18n.Lang` | `language`, `Accept-Language`, `X-Language` | `zh-CN` `en` `zh-TW` (default `zh-CN`) |
| `UploadPurpose` | `upload/entity.Purpose*` consts + `LookupSpec` | `purpose` | `avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file` | | `UploadPurpose` | `upload/entity.Purpose*` consts + `LookupSpec` | `purpose` | `avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file moment_image moment_video moment_cover` |
| `MomentKind` | `moment/entity.Kind` | `kind` | 1纯文 2图 3视频 |
| `MomentVisibility` | `moment/entity.Visibility` | `visibility` | 0好友 1私密 2白名单 3黑名单 |
| `MomentStatus` | `moment/entity.Status` | | 0正常 1已删 |
| `MomentNoticeKind` | `moment/entity.NoticeKind` | `kind` | 1赞 2评论 3回复 |
| `MomentVisibleDays` | `moment/entity.VisibleDays` | `visible_days` | 0 / 3 / 30 / 180 |
| `SystemKind` | `friend/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` | | `SystemKind` | `friend/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` |
Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum. Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum.

View File

@ -86,6 +86,8 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
| conversations | `/v1/conversations` | list, mute | | conversations | `/v1/conversations` | list, mute |
| messages | `/v1/messages` | history (sync still prefers TCP) | | messages | `/v1/messages` | history (sync still prefers TCP) |
| files | `/v1/files` | upload credential | | files | `/v1/files` | upload credential |
| favorites | `/v1/favorites` | 收藏聊天消息 |
| moments | `/v1/moments` | 朋友圈 Feed、相册、赞评、设置 |
| meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) | | meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) |
Rules: Rules:

View File

@ -4,6 +4,7 @@ Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程
仓库:<https://gitea.wyrs365.com/Spark/spark-server.git> 仓库:<https://gitea.wyrs365.com/Spark/spark-server.git>
完整设计:[docs/architecture-v3.md](docs/architecture-v3.md) 完整设计:[docs/architecture-v3.md](docs/architecture-v3.md)
朋友圈(HTTP v1,无 TCP Push):[docs/moments.md](docs/moments.md)
在线协议:`GET /docs`(Swagger)、`/docs/enums`、`/docs/errors`,规范 `GET /openapi.yaml`。 在线协议:`GET /docs`(Swagger)、`/docs/enums`、`/docs/errors`,规范 `GET /openapi.yaml`。
不上 Kafka、K8s、微服务。不要给 gnet 绑公网端口,不要在 EventLoop 里打 MySQL。 不上 Kafka、K8s、微服务。不要给 gnet 绑公网端口,不要在 EventLoop 里打 MySQL。
@ -12,7 +13,7 @@ Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程
## 它做什么 ## 它做什么
即时通讯:登录、好友、群、发消息、增量同步、撤回删除、媒体直传。 即时通讯:登录、好友、群、发消息、增量同步、撤回删除、媒体直传、朋友圈。
| 层 | 职责 | | 层 | 职责 |
| --- | --- | | --- | --- |
@ -125,6 +126,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 | | 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 | | 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
| 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` | | 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` |
| 朋友圈 | `/v1/moments` | Feed、相册、赞评、通知、封面/不看他 |
| 会话 | `/v1/conversations` | 列表、免打扰 | | 会话 | `/v1/conversations` | 列表、免打扰 |
| 消息 | `/v1/messages` | 历史 / 撤回 / 删除(主路径仍是 TCP) | | 消息 | `/v1/messages` | 历史 / 撤回 / 删除(主路径仍是 TCP) |
@ -207,8 +209,11 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 | | `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
| `chat_gif` | `chat/gif/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 | | `chat_gif` | `chat/gif/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
| `chat_file` | `chat/file/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 | | `chat_file` | `chat/file/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
| `moment_image` | `perm/moment/image/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
| `moment_video` | `perm/moment/video/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
| `moment_cover` | `perm/moment/cover/{yyyyMMdd}/{name}.{ext}` | 永久;换封面删除旧对象 |
日期 UTC `yyyyMMdd`。`ext` 由 mime 映射,未知为 `bin`。收藏复制到 `perm/fav/{yyyyMMdd}/{name}.{ext}`。 日期 UTC `yyyyMMdd`。`ext` 由 mime 映射,未知为 `bin`。收藏复制到 `perm/fav/{yyyyMMdd}/{name}.{ext}`。朋友圈媒体不引用 `chat/`。
合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。 合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。

517
docs/moments.md Normal file
View File

@ -0,0 +1,517 @@
# 朋友圈设计
> 对照:微信「发现 → 朋友圈」
> 形态:Spark IM 模块化单体内的独立业务,不进会话、不走 `messages`
> 状态:v1 HTTP 已落地(发帖/Feed/相册/赞评/通知/设置)。未做 TCP `MOMENT_PUSH`。
> 硬约束:遵守 `docs/architecture-v3.md` C1–C7。不上 Kafka、K8s、微服务、ConversationSeq。
朋友圈是好友之间的时间线,不是群动态,也不是聊天消息。帖子用独立表、独立 REST、独立对象前缀。Snowflake `post_id` 是主键、排序键和翻页游标,与聊天的 C1 同构,互不混用。
---
# 0. 范围
## 0.1 做(对齐微信核心)
| 微信 | Spark |
| --- | --- |
| 只对好友,不对群 | 双向好友可见;拉黑任一侧不可见 |
| 文字 / 最多 9 图 / **或** 1 条视频 | 同样互斥;图允许 jpeg / png / gif |
| 可见范围:公开好友、私密、部分可见、不给谁看 | 同四个;v1 用 uid 列表,**不做好友标签** |
| 不看他 / 不让他看 | 独立有向边,和拉黑分开 |
| 允许朋友查看范围:最近 3 天 / 1 个月 / 半年 / 全部 | 作者侧设置;Feed 与相册都裁 |
| 点赞、评论、回复 | 有;评论按「共同好友」过滤 |
| 封面、发现页红点、赞评消息列表 | 封面 + 未读红点 + 互动通知 |
| 随时删除自己的动态 | 作者软删;删帖后删对象 |
## 0.2 不做
- 好友标签选人、转载公众号/链接、@好友、视频号、直播
- 「仅聊天」好友类型(Spark 尚无此关系)
- 把动态写进 `messages` / 会话 inbox / `SYNC_INBOX`
- 写扩散:给每个好友插一行 MySQL inbox(违反 C7 精神,也无 Kafka 可扛)
- 新动态推送给全部好友(微信也不把发帖当聊天 `MESSAGE`)
- 收藏朋友圈(`favorites` 只收藏聊天;媒体不从 `chat/` 拷过来)
## 0.3 和 V3 的关系
| 约束 | 朋友圈怎么守 |
| --- | --- |
| C1 | 聊天仍是 `messages` + `MsgId`。朋友圈是 `moment_posts.id`,**没有 ConversationSeq** |
| C2 | 发帖/赞评在 HTTP → Application。gnet EventLoop 不碰朋友圈 |
| C3 | 无新握手。可选 TCP `MOMENT_PUSH` 仍是薄 Frame + JSON 信封 |
| C4 | 仍走 `sparkapi` HTTPS。不新开公网端口 |
| C5 | 发帖不是 `SEND_ACK`。落库成功即 HTTP 200。赞评若要实时,COMMIT 后再 `deliver:queue` Push |
| C6 | 发帖/赞评限流用 `reqctx.RealIP` |
| C7 | 朋友圈红点**不**进会话 ZSET。未读用 `moment_settings.last_read_post_id` |
HTTP 与(若加)TCP 共用 `internal/module/moment/logic`。Gin handler 只绑参、写信封。`logic` 不 import `model`。
---
# 1. 总览
```text
客户端
│ HTTPS /v1/moments
│ POST /v1/files (moment_image / moment_video / moment_cover)
▼
Gin → moment/logic
│
├─ MySQL 帖 / 可见名单 / 赞 / 评 / 设置 / 通知
├─ RustFS perm/moment/{image|video|cover}/...
└─ 可选 RPUSH deliver:queue (仅赞、评、回复)
│
Deliver Worker
│
在线设备 MOMENT_PUSH
不写会话列表、不写 mailbox_events
```
实时靠连接只服务「有人赞/评了我」;时间线正文靠 MySQL;媒体只存 object_key。
---
# 2. 模块与目录
新模块 `internal/module/moment`,三层拆分与现有业务一致。
```text
internal/module/moment/
├── entity/ 帖、可见性枚举、content JSON、设置
├── model/ 一表一文件(GORM + COMMENT)
└── logic/ 一功能一文件:publish、feed、album、like、comment、notice、settings
internal/transport/http/handler/moment/
migrations/00xx_moments.sql
```
REST 分组:`/v1/moments`。OpenAPI tag:`moments`。
上传 purpose 扩在 `upload/entity`,不在 moment 里自己签 URL。
依赖(只通过 interface,bootstrap 注入):
| 依赖 | 用途 |
| --- | --- |
| `friend` | `AreFriends`、`ListFriends`、`BlockedEither` |
| `upload` | 校验 object_key 前缀;不代签 |
| `user` | 展示昵称、头像 |
| `id.Generator` | `post_id` / `comment_id` / `notice_id` |
| `objectstore` | 删帖 / 换封面时 `Delete` |
| `deliver` | 赞评 Push(可后做) |
---
# 3. 数据模型
主键一律 Snowflake,不用 AUTO_INCREMENT 业务 id。表和列必须有 COMMENT。时间展示用 `created_at`,**排序和游标用 `id`**。
## 3.1 `moment_posts`
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `id` | BIGINT PK | Snowflake。Feed / 相册游标 |
| `user_id` | BIGINT | 作者 `users.id` |
| `kind` | SMALLINT | 1 纯文 2 图 3 视频 |
| `text` | VARCHAR(1000) | 可空;与媒体至少有一项 |
| `content` | BLOB | 图/视频 JSON 字节,不存媒体二进制 |
| `poi_lat` / `poi_lng` | DOUBLE | 可选 |
| `poi_name` / `poi_address` | VARCHAR | 可选地点文案 |
| `visibility` | SMALLINT | 0 好友公开 1 私密 2 白名单 3 黑名单 |
| `status` | SMALLINT | 0 正常 1 已删 |
| `like_count` | INT | 事务内维护 |
| `comment_count` | INT | 事务内维护(只计未删评论) |
| `created_at` | DATETIME(3) | 展示与 `visible_days` 裁剪 |
索引:
- `PRIMARY KEY (id)`
- `KEY idx_user_id (user_id, id)` — 相册
不建「全表时间线」二级索引。Feed 用 `id < cursor AND user_id IN (好友列表)`。
## 3.2 `moment_audience`
仅 `visibility` 为 2 或 3 时有行。
| 列 | 说明 |
| --- | --- |
| `post_id` | 帖 id |
| `peer_id` | 好友 uid |
| PK | `(post_id, peer_id)` |
白名单:列出**能看**的人。黑名单:列出**不能看**的人(其余好友能看)。单帖最多 **200** 人,且必须是发帖时的双向好友。
## 3.3 `moment_likes`
| 列 | 说明 |
| --- | --- |
| `post_id` | 帖 id |
| `user_id` | 点赞人 |
| `created_at` | DATETIME(3) |
| PK | `(post_id, user_id)` |
重复 PUT 幂等。取消赞删行并减 `like_count`。
## 3.4 `moment_comments`
| 列 | 说明 |
| --- | --- |
| `id` | Snowflake PK |
| `post_id` | 帖 id |
| `user_id` | 评论人 |
| `reply_to_id` | 父评论 id,0 表示直接评帖 |
| `reply_to_uid` | 被回复的用户,方便通知 |
| `text` | VARCHAR(500),必填 |
| `status` | 0 正常 1 已删 |
| `created_at` | DATETIME(3) |
索引:`KEY idx_post (post_id, id)`。
## 3.5 `moment_notices`
互动通知,**不是**聊天 mailbox。
| 列 | 说明 |
| --- | --- |
| `id` | Snowflake PK,列表游标 |
| `to_user_id` | 接收人 |
| `from_user_id` | 动作发起人 |
| `kind` | 1 赞 2 评论 3 回复 |
| `post_id` | 帖 |
| `comment_id` | 评论,赞则为 0 |
| `read_at` | 可空 |
| `created_at` | DATETIME(3) |
索引:`KEY idx_to_user (to_user_id, id)`。
自己赞/评自己的帖不写通知。
## 3.6 `moment_settings`
每用户一行。无行则用默认:无封面、可见范围为全部、`last_read_post_id=0`。
| 列 | 说明 |
| --- | --- |
| `user_id` | PK |
| `cover_key` | `perm/moment/cover/...`,空表示未设 |
| `visible_days` | 0 全部;3 / 30 / 180 |
| `last_read_post_id` | 发现页红点水位 |
| `updated_at` | DATETIME(3) |
## 3.7 `moment_hides`
| 列 | 说明 |
| --- | --- |
| `user_id` | 设置方 |
| `peer_id` | 对方 |
| `hide_him` | 不看他(我的 Feed 去掉他) |
| `hide_me` | 不让他看(他拉我的 Feed/相册时当看不见) |
| PK | `(user_id, peer_id)` |
拉黑仍走 `friend_relations.blocked` / `BlockedEither`,不在本表重复。
## 3.8 枚举(entity + OpenAPI `$ref` + i18n)
| 名 | 值 |
| --- | --- |
| `MomentKind` | 1 text、2 image、3 video |
| `MomentVisibility` | 0 friends、1 private、2 include、3 exclude |
| `MomentStatus` | 0 normal、1 deleted |
| `MomentNoticeKind` | 1 like、2 comment、3 reply |
| `MomentVisibleDays` | 0 / 3 / 30 / 180 |
---
# 4. 可见性
## 4.1 读时过滤(v1 选定)
好友量按几百计。发帖只写作者一行(加可选 `moment_audience`),**不**给每个好友写 inbox。
原因:
- 解绑、改「不看他」、改可见天数、改单帖可见范围,读时立刻生效
- 写扩散要在解绑时扫 inbox,且 V3 禁止 Kafka
- `IN (friend_ids)` + `id` 游标对当前规模足够
观众 V 看作者 A 的帖 P,须同时满足:
1. `P.status = normal`
2. `A == V` → 可见(含私密、含超出 `visible_days` 的旧帖)
3. 否则:双向好友,且 `BlockedEither(A,V) == false`
4. V 没有对 A 设 `hide_him`;A 没有对 V 设 `hide_me`
5. 非本人时:按 A 的 `visible_days` 裁 `P.created_at`(0 表示不裁)
6. `visibility`:
- 0 好友公开 → 过
- 1 私密 → 否
- 2 白名单 → `moment_audience` 含 V
- 3 黑名单 → `moment_audience` 不含 V
非好友访问他人相册:**404**,不返回「有朋友圈但不能看」。
## 4.2 Feed 查询
```text
ids = ListFriends(me) ∪ {me}
ids -= { peer | hide_him(me, peer) }
SELECT * FROM moment_posts
WHERE id < cursor AND user_id IN (ids) AND status = 0
ORDER BY id DESC
LIMIT overfetch -- 建议 50
```
内存套用 4.1。返回 `limit`(默认 20,最大 50)。过滤后不足则再往下翻,**单次请求最多 3 轮 overfetch**,避免大量私密帖拖死接口。下一页 `cursor` 为本页最后一条**已返回**的 `post_id`。
## 4.3 相册
`GET /v1/moments/users/:uid`
- `uid == me`:只按 `status` 过滤,不过可见天数,能看到自己的私密帖
- 否则:必须过 4.1;游标同样是 `post_id`
## 4.4 赞、评对谁可见
V 必须先能看见该帖。
评论对 V 可见,当且仅当:V 是帖作者,或 V 是评论人,或 V 与评论人双向好友。
点赞列表同样过滤:帖作者看见全部赞;其他人只看见自己的赞 + 共同好友的赞。
这是微信「共同好友才看得到这条互动」的简化版,不做「好友的好友」更远的扩散。
---
# 5. 发帖与媒体
## 5.1 客户端顺序
```text
1. POST /v1/files purpose=moment_image|moment_video|moment_cover
2. Presigned PUT(Content-Type / Content-Length / If-None-Match: *)
3. POST /v1/moments
4. 服务端校验 key 前缀、数量、互斥、audience 都是好友
5. Snowflake id → INSERT
6. 信封返回帖视图
```
客户端不自选 object_key。不占用聊天 `client_msg_id`。发帖失败可换新凭证重试;已 PUT 的孤儿对象暂不扫(与当前头像策略相同,删帖路径会删)。
## 5.2 内容规则
| 规则 | 值 |
| --- | --- |
| 文本 | 0–1000 字;可与媒体同时有 |
| 图 | 1–9;不可同时带视频 |
| 视频 | 0 或 1;不可同时带图 |
| 纯文本 | 允许(无媒体) |
| 空正文且无媒体 | 拒绝 |
| 地点 | 可选;有 lat 必须有 lng |
| 白/黑名单 | 仅 visibility 2/3;人数 1–200;必须双向好友且不含自己 |
| 私密 | 禁止带 audience |
`kind` 由服务端根据媒体推断,客户端不必传对。
## 5.3 object_key
路径不含 uid。文件名 16 位 base62(12 字节随机,`reqctx.NewObjectName`)。
| purpose | key | 生命周期 |
| --- | --- | --- |
| `moment_image` | `perm/moment/image/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
| `moment_video` | `perm/moment/video/{yyyyMMdd}/{name}.{ext}` | 同上 |
| `moment_cover` | `perm/moment/cover/{yyyyMMdd}/{name}.{ext}` | 换封面删除旧对象 |
Lifecycle **不**扫 `perm/`(现有只过期 `chat/`)。朋友圈媒体与头像一样走永久前缀,但**删帖 / 换封面必须 `Delete`**,不把孤儿留给 bucket。
规格(v1):
| purpose | MIME | 大小 | 其它 |
| --- | --- | --- | --- |
| `moment_image` | image/jpeg、image/png、image/gif | ≤ 10MB | 九宫格 |
| `moment_video` | 与 `chat_video` 相同 | 与聊天视频上限相同 | 时长 ≤ 30s(逻辑校验 `duration_ms`) |
| `moment_cover` | jpeg / png | ≤ 2MB | 一张 |
`content` JSON 形状对齐聊天 `Image` / `Video`(`key`、`thumb_key`、`cover_key`、`w`、`h`、`size`、`duration_ms`),可多图为数组。禁止引用 `chat/` 或 `perm/avatar/` 等其它前缀。
发帖不把媒体从 `chat/` 复制到 `perm/moment/`。从聊天「转发到朋友圈」若以后要做,再走 copy,与收藏相同。
## 5.4 限流与审计
`reqctx.RealIP` + uid:建议 **2 条/分钟**、**20 条/天**。赞评另限,例如 60 次/分钟。超限 `errs.ErrRateLimited`。
## 5.5 删除
仅作者。`status=deleted`,Feed/相册/单条均当不存在。
随后异步删 `content` 与封面以外的对象;失败只打日志,不回滚软删。
赞评行可保留但对外不可见。通知仍可列出,点开单条返回 404。
无「2 分钟撤回」窗口,与微信删动态一致。
---
# 6. HTTP API
统一信封 `{code,msg,request_id,data}`。鉴权与其它 `/v1` 资源相同。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/v1/moments` | 发帖 |
| `GET` | `/v1/moments` | 好友 Feed。query:`cursor`(post_id,0 表示最新)、`limit` |
| `GET` | `/v1/moments/users/:uid` | 相册 |
| `GET` | `/v1/moments/:id` | 单条;不可见则 404 |
| `DELETE` | `/v1/moments/:id` | 作者删帖 |
| `PUT` | `/v1/moments/:id/like` | 点赞(幂等) |
| `DELETE` | `/v1/moments/:id/like` | 取消赞 |
| `POST` | `/v1/moments/:id/comments` | 评论或回复。body:`text`、`reply_to_id` |
| `DELETE` | `/v1/moments/:id/comments/:cid` | 帖作者或评论人可删 |
| `GET` | `/v1/moments/notices` | 赞评通知。`cursor`=`notice_id` |
| `POST` | `/v1/moments/notices/read` | 标已读;可带 `cursor` 表示已读到该 id |
| `GET` | `/v1/moments/unread` | `{has_new, latest_id}` 发现页红点 |
| `PUT` | `/v1/moments/read` | body:`post_id`,更新 `last_read_post_id`(只增不减) |
| `GET` | `/v1/moments/settings` | 封面、可见天数、不看列表 |
| `PATCH` | `/v1/moments/settings` | 部分更新 |
| `PUT` | `/v1/moments/settings/hides/:uid` | 设置对某人的 hide_him / hide_me |
| `DELETE` | `/v1/moments/settings/hides/:uid` | 清除对该人的两条隐藏 |
Feed / 相册 / 单条的 `data` 建议同一视图:
```json
{
"id": 0,
"user": { "uid": 0, "spark_id": "", "nickname": "", "avatar": "", "remark": "" },
"kind": 2,
"text": "",
"content": {},
"poi": { "lat": 0, "lng": 0, "name": "", "address": "" },
"visibility": 0,
"like_count": 0,
"comment_count": 0,
"liked": false,
"likes": [],
"comments": [],
"created_at": 0
}
```
`likes` / `comments` 已按 4.4 过滤。`remark` 为观众对该作者的好友备注,没有则空串。列表接口可只带赞/评摘要(例如赞 10 人、评 20 条),单条带全量过滤后列表。
`POST /v1/moments` 请求例:
```json
{
"text": "hello",
"images": [{ "key": "perm/moment/image/20260918/xxxx.jpg", "w": 1080, "h": 1080 }],
"video": null,
"visibility": 0,
"audience_uids": [],
"poi": { "lat": 31.2, "lng": 121.5, "name": "外滩" }
}
```
路由注意:静态路径(`/unread`、`/notices`、`/settings`、`/users/:uid`)必须注册在 `/:id` 之前。
---
# 7. 赞、评、通知、红点
## 7.1 赞
能看见帖的人可赞。`PUT` 已赞则原样返回。计数与 `moment_likes` 同一事务。
通知:`to_user_id = 作者`,`kind=like`,作者自己赞跳过。
## 7.2 评论
能看见帖的人可评。`reply_to_id != 0` 时:父评必须同帖、未删。
删评:软删,`comment_count-1`。子评仍在,展示层把父评当「已删除」。
通知:
- 评帖 → 作者(自己评自己跳过)
- 回复 → 被回复人;若被回复人不是作者,作者再收一条 `kind=comment`(与微信「有人评论了我的动态」一致)。同一动作不要给同一人写两行。
v1 **不**把「曾经赞过/评过该帖的其他人」全部再通知一遍。
## 7.3 发现页红点
```text
unread.has_new = Feed 上第一条「别人的」可见 post_id > last_read_post_id
```
打开 Feed 并成功返回后,客户端 `PUT /v1/moments/read` 带本页最大别人 `post_id`。服务端只允许水位前进。
这不是会话未读,禁止写入 `im:dialog:*`。
## 7.4 实时 Push(可后做)
HTTP 赞评 COMMIT 成功后 `DeliverQueue.Push`:
- `Job.MsgID` = `notice_id`(给 `deliver:done:{id}` 幂等用)
- `Cmd` = 新命令 `MOMENT_PUSH`(具体数值接现有 TCP 命令表末尾)
- `ToUserIDs` = 通知接收人
- `NotMailbox = true`、`NotPush` 按设备在线情况
- **不**更新会话 ZSET、**不**写 `mailbox_events`
离线用户下次 `GET /v1/moments/notices`。
**发帖本身不 Push。**
gnet EventLoop 只 `Submit`;解析和查库在 TaskPool(若走 TCP 命令)。首期可以没有 TCP 命令,仅 HTTP 拉通知。
---
# 8. 设置
`PATCH /v1/moments/settings`:
| 字段 | 规则 |
| --- | --- |
| `cover_key` | 必须是本人刚上传的 `perm/moment/cover/` 前缀;空串表示清除封面,并删旧对象 |
| `visible_days` | `oneof=0 3 30 180`;只影响**别人**看我,不影响我看自己 |
`PUT .../hides/:uid`:
```json
{ "hide_him": true, "hide_me": false }
```
对方必须是双向好友。拉黑后 Feed 已不可见,隐藏边可保留,解除拉黑后仍生效。
---
# 9. 和现有模块的边界
| 模块 | 朋友圈 |
| --- | --- |
| `message` / `conversation` / `inbox` / `sync` | 不引用、不写入 |
| `friend` | 好友与拉黑的唯一来源 |
| `upload` | 新 purpose 与 `BuildObjectKey` 分支 |
| `favorite` | 不收藏动态 |
| `user` | 封面不进 `users.avatar` |
| `deliver` / TCP | 仅互动 Push;发现页红点不走 Push |
| 对象存储 lifecycle | 仍只过期 `chat/` |
解绑:不必删帖。读路径 `AreFriends==false` 即不可见。对方相册 404。
---
# 10. 落地顺序
按垂直切片合入,每步可运行、有测试、有 OpenAPI。
| 步 | 内容 |
| --- | --- |
| 1 | migration + entity 枚举 + upload purpose / key |
| 2 | `POST/DELETE /v1/moments`,`GET` 单条(仅自己) |
| 3 | Feed、相册、4.1 可见性、`moment_audience`、hides、拉黑 |
| 4 | 赞、评、通知 HTTP、红点 / `last_read_post_id` |
| 5 | 封面、`visible_days`、删帖删对象、换封面删旧对象 |
| 6 | (可选)`MOMENT_PUSH` + deliver |
每步:`internal/pkg/i18n` 键、`GET /v1/meta/enums`、handler 薄、`make lint`。改了 `migrations/*.sql` 后在本机执行 `go run ./cmd/migrate -config configs/config.yaml`。
---
# 11. 以后才考虑
好友上万导致 `IN (ids)` 变慢时,再对**可见好友**做 Redis ZSET 写扩散(`moment:feed:{uid}` score=`post_id`),在 Application 里同步写,不引入 Kafka。解绑时 `ZREM`。那是压测之后的复杂度,不作为 v1。
举报复用 `friend` 举报或另表 `moment_reports`,产品要时再开。

View File

@ -35,6 +35,8 @@ import (
inboxmodel "spark-server/internal/module/inbox/model" inboxmodel "spark-server/internal/module/inbox/model"
msglogic "spark-server/internal/module/message/logic" msglogic "spark-server/internal/module/message/logic"
msgmodel "spark-server/internal/module/message/model" msgmodel "spark-server/internal/module/message/model"
momentlogic "spark-server/internal/module/moment/logic"
momentmodel "spark-server/internal/module/moment/model"
synclogic "spark-server/internal/module/sync/logic" synclogic "spark-server/internal/module/sync/logic"
syncmodel "spark-server/internal/module/sync/model" syncmodel "spark-server/internal/module/sync/model"
uploadlogic "spark-server/internal/module/upload/logic" uploadlogic "spark-server/internal/module/upload/logic"
@ -169,6 +171,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
messages := wireMessages(idgen, friends, groups, deliverQueue, chat) messages := wireMessages(idgen, friends, groups, deliverQueue, chat)
files := wireFiles(cfg, objects, groups) files := wireFiles(cfg, objects, groups)
favorites := wireFavorites(idgen, db, objects, chat) favorites := wireFavorites(idgen, db, objects, chat)
moments := wireMoments(idgen, db, objects, friends, users)
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
Addr: cfg.Server.TCP.Addr, Addr: cfg.Server.TCP.Addr,
Multicore: cfg.Server.TCP.Multicore, Multicore: cfg.Server.TCP.Multicore,
@ -196,6 +199,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
Groups: groups, Groups: groups,
Files: files, Files: files,
Favorites: favorites, Favorites: favorites,
Moments: moments,
Messages: messages, Messages: messages,
Sync: syncSvc, Sync: syncSvc,
Health: probe, Health: probe,
@ -305,6 +309,23 @@ func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store,
return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen) return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen)
} }
func wireMoments(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *momentlogic.Service {
if idgen == nil || friends == nil || users == nil {
return nil
}
var repo momentlogic.Repository
if db != nil && db.Ready() {
repo = momentmodel.NewRepo(db.Gorm())
} else {
repo = momentmodel.NewMemoryRepo()
}
var objects momentlogic.Objects
if store != nil && store.Ready() {
objects = store
}
return momentlogic.NewService(repo, friends, users, objects, idgen)
}
func wireInbox(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (inboxlogic.Store, synclogic.Mailbox) { func wireInbox(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (inboxlogic.Store, synclogic.Mailbox) {
var inbox inboxlogic.Store var inbox inboxlogic.Store
if rdb != nil && rdb.Ready() { if rdb != nil && rdb.Ready() {

View File

@ -28,6 +28,7 @@ func (s EdgeStatus) Valid() bool {
const ( const (
ListDefault = 20 ListDefault = 20
ListMax = 100 ListMax = 100
FriendCap = 5000
) )
// Relation is one directed edge: UserID's view of FriendID. // Relation is one directed edge: UserID's view of FriendID.

View File

@ -4,9 +4,38 @@ import (
"context" "context"
"errors" "errors"
"spark-server/internal/module/friend/entity"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
func (s *Service) ListFriendIDs(ctx context.Context, userID int64) ([]int64, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
return s.repo.ListFriendIDs(ctx, userID)
}
func (s *Service) ListBlockedPeerIDs(ctx context.Context, userID int64) ([]int64, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
return s.repo.ListBlockedPeerIDs(ctx, userID)
}
func (s *Service) Relation(ctx context.Context, userID, peerID int64) (*entity.Relation, error) {
if !s.ready() || userID <= 0 || peerID <= 0 {
return nil, errs.ErrNotFound
}
return s.repo.GetRelation(ctx, userID, peerID)
}
func (s *Service) BlockedEither(ctx context.Context, a, b int64) (bool, error) {
if !s.ready() || a <= 0 || b <= 0 || a == b {
return false, nil
}
return s.repo.BlockedEither(ctx, a, b)
}
func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) { func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) {
if !s.ready() || a <= 0 || b <= 0 || a == b { if !s.ready() || a <= 0 || b <= 0 || a == b {
return false, nil return false, nil

View File

@ -12,6 +12,8 @@ import (
type Repository interface { type Repository interface {
GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error) GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error)
ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error)
ListFriendIDs(ctx context.Context, userID int64) ([]int64, error)
ListBlockedPeerIDs(ctx context.Context, userID int64) ([]int64, error)
GetRequest(ctx context.Context, id int64) (*entity.Request, error) GetRequest(ctx context.Context, id int64) (*entity.Request, error)
GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error)
UpsertRequest(ctx context.Context, req *entity.Request) error UpsertRequest(ctx context.Context, req *entity.Request) error

View File

@ -58,6 +58,49 @@ func (m *Repo) GetRelation(ctx context.Context, userID, friendID int64) (*entity
return row.entity(), nil return row.entity(), nil
} }
func (m *Repo) ListFriendIDs(ctx context.Context, userID int64) ([]int64, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var ids []int64
err = db.Model(&FriendRelation{}).
Where("user_id = ? AND status = ?", userID, entity.StatusFriend).
Limit(entity.FriendCap).Pluck("friend_id", &ids).Error
if err != nil {
return nil, mapDBErr(err)
}
return ids, nil
}
func (m *Repo) ListBlockedPeerIDs(ctx context.Context, userID int64) ([]int64, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var mine, theirs []int64
q := db.Model(&FriendRelation{})
if err := q.Where("user_id = ? AND is_blocked = ?", userID, true).Limit(entity.FriendCap).Pluck("friend_id", &mine).Error; err != nil {
return nil, mapDBErr(err)
}
if err := db.Model(&FriendRelation{}).Where("friend_id = ? AND is_blocked = ?", userID, true).Limit(entity.FriendCap).Pluck("user_id", &theirs).Error; err != nil {
return nil, mapDBErr(err)
}
seen := make(map[int64]struct{}, len(mine)+len(theirs))
out := make([]int64, 0, len(mine)+len(theirs))
for _, id := range append(mine, theirs...) {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) { func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {

View File

@ -48,6 +48,44 @@ func (m *MemoryRepo) GetRelation(_ context.Context, userID, friendID int64) (*en
return &cp, nil return &cp, nil
} }
func (m *MemoryRepo) ListFriendIDs(_ context.Context, userID int64) ([]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]int64, 0)
for _, rel := range m.relations {
if rel.UserID == userID && rel.Status == entity.StatusFriend {
out = append(out, rel.FriendID)
}
}
return out, nil
}
func (m *MemoryRepo) ListBlockedPeerIDs(_ context.Context, userID int64) ([]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
seen := make(map[int64]struct{})
for _, rel := range m.relations {
if !rel.Blocked {
continue
}
var peer int64
switch {
case rel.UserID == userID:
peer = rel.FriendID
case rel.FriendID == userID:
peer = rel.UserID
default:
continue
}
seen[peer] = struct{}{}
}
out := make([]int64, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out, nil
}
func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]*entity.Relation, error) { func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]*entity.Relation, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -29,4 +29,9 @@ type Catalog struct {
Language []Item `json:"language"` Language []Item `json:"language"`
UploadPurpose []Item `json:"upload_purpose"` UploadPurpose []Item `json:"upload_purpose"`
SystemKind []Item `json:"system_kind"` SystemKind []Item `json:"system_kind"`
MomentKind []Item `json:"moment_kind"`
MomentVisibility []Item `json:"moment_visibility"`
MomentStatus []Item `json:"moment_status"`
MomentNoticeKind []Item `json:"moment_notice_kind"`
MomentVisibleDays []Item `json:"moment_visible_days"`
} }

View File

@ -30,6 +30,11 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
Language: language(lang), Language: language(lang),
UploadPurpose: uploadPurpose(lang), UploadPurpose: uploadPurpose(lang),
SystemKind: systemKind(lang), SystemKind: systemKind(lang),
MomentKind: momentKind(lang),
MomentVisibility: momentVisibility(lang),
MomentStatus: momentStatus(lang),
MomentNoticeKind: momentNoticeKind(lang),
MomentVisibleDays: momentVisibleDays(lang),
} }
} }

View File

@ -0,0 +1,48 @@
package logic
import (
"spark-server/internal/module/meta/entity"
momententity "spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/i18n"
)
func momentKind(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "moment_kind", "text", int(momententity.KindText)),
item(lang, "moment_kind", "image", int(momententity.KindImage)),
item(lang, "moment_kind", "video", int(momententity.KindVideo)),
}
}
func momentVisibility(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "moment_visibility", "friends", int(momententity.VisibilityFriends)),
item(lang, "moment_visibility", "private", int(momententity.VisibilityPrivate)),
item(lang, "moment_visibility", "include", int(momententity.VisibilityInclude)),
item(lang, "moment_visibility", "exclude", int(momententity.VisibilityExclude)),
}
}
func momentStatus(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "moment_status", "normal", int(momententity.StatusNormal)),
item(lang, "moment_status", "deleted", int(momententity.StatusDeleted)),
}
}
func momentNoticeKind(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "moment_notice_kind", "like", int(momententity.NoticeLike)),
item(lang, "moment_notice_kind", "comment", int(momententity.NoticeComment)),
item(lang, "moment_notice_kind", "reply", int(momententity.NoticeReply)),
}
}
func momentVisibleDays(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "moment_visible_days", "all", int(momententity.VisibleAll)),
item(lang, "moment_visible_days", "three_days", int(momententity.Visible3Days)),
item(lang, "moment_visible_days", "month", int(momententity.Visible30Days)),
item(lang, "moment_visible_days", "half_year", int(momententity.Visible180Days)),
}
}

View File

@ -26,6 +26,12 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
if c.EnvelopeCode[0].Value != "0" || c.Language[0].Value != "zh-CN" { if c.EnvelopeCode[0].Value != "0" || c.Language[0].Value != "zh-CN" {
t.Fatalf("wire %+v %+v", c.EnvelopeCode[0], c.Language) t.Fatalf("wire %+v %+v", c.EnvelopeCode[0], c.Language)
} }
if len(c.UploadPurpose) < 10 {
t.Fatalf("upload_purpose %+v", c.UploadPurpose)
}
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
}
en := Catalog(i18n.En) en := Catalog(i18n.En)
if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" { if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" {
t.Fatalf("en labels %+v %+v", en.Gender, en.MessageType) t.Fatalf("en labels %+v %+v", en.Gender, en.MessageType)
@ -34,6 +40,7 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
c.Gender, c.UserStatus, c.ConversationType, c.MessageKind, c.MessageType, c.MessageStatus, c.Gender, c.UserStatus, c.ConversationType, c.MessageKind, c.MessageType, c.MessageStatus,
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole, c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind, c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
} { } {
if len(items) == 0 { if len(items) == 0 {
t.Fatal("empty set") t.Fatal("empty set")

View File

@ -71,6 +71,7 @@ func uploadPurpose(lang i18n.Lang) []entity.Item {
return strSet(lang, "upload_purpose", return strSet(lang, "upload_purpose",
uploadentity.PurposeAvatar, uploadentity.PurposeGroupAvatar, uploadentity.PurposeChatImage, uploadentity.PurposeAvatar, uploadentity.PurposeGroupAvatar, uploadentity.PurposeChatImage,
uploadentity.PurposeChatVoice, uploadentity.PurposeChatVideo, uploadentity.PurposeChatGIF, uploadentity.PurposeChatFile, uploadentity.PurposeChatVoice, uploadentity.PurposeChatVideo, uploadentity.PurposeChatGIF, uploadentity.PurposeChatFile,
uploadentity.PurposeMomentImage, uploadentity.PurposeMomentVideo, uploadentity.PurposeMomentCover,
) )
} }

View File

@ -0,0 +1,163 @@
package entity
import (
"bytes"
"encoding/json"
"fmt"
"strings"
uploadentity "spark-server/internal/module/upload/entity"
)
const maxContentBytes = 32 << 10
// Content is the persisted JSON for images/video. Empty object for text-only posts.
type Content struct {
Images []*Image `json:"images,omitempty"`
Video *Video `json:"video,omitempty"`
}
// Image matches chat image JSON field names so clients reuse the same decoder.
type Image struct {
Key string `json:"key"`
Mime string `json:"mime,omitempty"`
Width int `json:"w,omitempty"`
Height int `json:"h,omitempty"`
Size int64 `json:"size,omitempty"`
ThumbKey string `json:"thumb_key,omitempty"`
OrigKey string `json:"orig_key,omitempty"`
}
// Video matches chat video JSON field names.
type Video struct {
Key string `json:"key"`
Mime string `json:"mime,omitempty"`
Width int `json:"w,omitempty"`
Height int `json:"h,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
Size int64 `json:"size,omitempty"`
CoverKey string `json:"cover_key,omitempty"`
}
func (c *Content) Marshal() ([]byte, error) {
if c == nil {
c = &Content{}
}
if err := c.Validate(); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(c); err != nil {
return nil, fmt.Errorf("moment content: %w", err)
}
b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})
if len(b) > maxContentBytes {
return nil, fmt.Errorf("moment content: too large")
}
return b, nil
}
func UnmarshalContent(raw []byte) (*Content, error) {
if len(raw) == 0 {
return &Content{}, nil
}
var c Content
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("moment content: %w", err)
}
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
func (c *Content) Validate() error {
if c == nil {
return nil
}
if len(c.Images) > 0 && c.Video != nil {
return fmt.Errorf("moment content: image and video")
}
if len(c.Images) > MaxImages {
return fmt.Errorf("moment content: too many images")
}
for _, img := range c.Images {
if err := img.validate(); err != nil {
return err
}
}
if c.Video != nil {
return c.Video.validate()
}
return nil
}
func (c *Content) Kind() Kind {
if c != nil && c.Video != nil {
return KindVideo
}
if c != nil && len(c.Images) > 0 {
return KindImage
}
return KindText
}
func (c *Content) ObjectKeys() []string {
if c == nil {
return nil
}
out := make([]string, 0, 12)
for _, img := range c.Images {
out = appendKeys(out, img.Key, img.ThumbKey, img.OrigKey)
}
if c.Video != nil {
out = appendKeys(out, c.Video.Key, c.Video.CoverKey)
}
return out
}
func (img *Image) validate() error {
if img == nil || !momentKey(img.Key, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: image key")
}
if img.ThumbKey != "" && !momentKey(img.ThumbKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: thumb")
}
if img.OrigKey != "" && !momentKey(img.OrigKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: orig")
}
return nil
}
func (v *Video) validate() error {
if v == nil || !momentKey(v.Key, uploadentity.PrefixMomentVideo) {
return fmt.Errorf("moment content: video key")
}
if v.DurationMS <= 0 || v.DurationMS > MaxVideoMS {
return fmt.Errorf("moment content: duration")
}
if v.CoverKey != "" && !momentKey(v.CoverKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: cover")
}
return nil
}
func momentKey(key, prefix string) bool {
key = strings.TrimSpace(key)
return uploadentity.ValidObjectKey(key) && strings.HasPrefix(key, prefix)
}
func appendKeys(dst []string, keys ...string) []string {
for _, k := range keys {
if k != "" {
dst = append(dst, k)
}
}
return dst
}
func ValidCoverKey(key string) bool {
return momentKey(key, uploadentity.PrefixMomentCover)
}

View File

@ -0,0 +1,92 @@
package entity
// Kind is moment_posts.kind.
type Kind int16
const (
KindText Kind = 1
KindImage Kind = 2
KindVideo Kind = 3
)
func (k Kind) Valid() bool {
return k == KindText || k == KindImage || k == KindVideo
}
// Visibility is moment_posts.visibility.
type Visibility int16
const (
VisibilityFriends Visibility = 0
VisibilityPrivate Visibility = 1
VisibilityInclude Visibility = 2
VisibilityExclude Visibility = 3
)
func (v Visibility) Valid() bool {
return v >= VisibilityFriends && v <= VisibilityExclude
}
func (v Visibility) NeedsAudience() bool {
return v == VisibilityInclude || v == VisibilityExclude
}
// Status is moment_posts.status / moment_comments.status.
type Status int16
const (
StatusNormal Status = 0
StatusDeleted Status = 1
)
func (s Status) Valid() bool {
return s == StatusNormal || s == StatusDeleted
}
// NoticeKind is moment_notices.kind.
type NoticeKind int16
const (
NoticeLike NoticeKind = 1
NoticeComment NoticeKind = 2
NoticeReply NoticeKind = 3
)
func (k NoticeKind) Valid() bool {
return k == NoticeLike || k == NoticeComment || k == NoticeReply
}
// VisibleDays is moment_settings.visible_days.
type VisibleDays int
const (
VisibleAll VisibleDays = 0
Visible3Days VisibleDays = 3
Visible30Days VisibleDays = 30
Visible180Days VisibleDays = 180
MaxAudience = 200
MaxImages = 9
MaxTextRunes = 1000
MaxCommentRunes = 500
MaxVideoMS = 30_000
ListDefault = 20
ListMax = 50
Overfetch = 50
OverfetchRounds = 3
LikePreviewMax = 10
CommentPreviewMax = 20
)
func (d VisibleDays) Valid() bool {
return d == VisibleAll || d == Visible3Days || d == Visible30Days || d == Visible180Days
}
func ClampLimit(limit int) int {
if limit <= 0 {
return ListDefault
}
if limit > ListMax {
return ListMax
}
return limit
}

View File

@ -0,0 +1,80 @@
package entity
// Post is one Moments entry. Content is JSON bytes of images/video keys, never media bytes.
type Post struct {
ID int64
UserID int64
Kind Kind
Text string
Content []byte
POI POI
Visibility Visibility
Status Status
LikeCount int
CommentCount int
CreatedAtMS int64
}
// POI is an optional location pin.
type POI struct {
Lat float64
Lng float64
Name string
Address string
}
func (p POI) Set() bool {
return p.Name != "" || p.Address != "" || p.Lat != 0 || p.Lng != 0
}
// Like is one (post, user) edge.
type Like struct {
PostID int64
UserID int64
CreatedAtMS int64
}
// Comment is one Moments comment or reply.
type Comment struct {
ID int64
PostID int64
UserID int64
ReplyToID int64
ReplyToUID int64
Text string
Status Status
CreatedAtMS int64
}
// Notice is a like/comment notification for one receiver.
type Notice struct {
ID int64
ToUserID int64
FromUserID int64
Kind NoticeKind
PostID int64
CommentID int64
ReadAtMS int64
CreatedAtMS int64
}
// Settings is per-user Moments prefs. Zero value is the documented default.
type Settings struct {
UserID int64
CoverKey string
VisibleDays VisibleDays
LastReadPostID int64
UpdatedAtMS int64
}
// Hide is a directed "don't look / don't let look" edge.
type Hide struct {
UserID int64
PeerID int64
HideHim bool
HideMe bool
}
func DefaultSettings(userID int64) *Settings {
return &Settings{UserID: userID}
}

View File

@ -0,0 +1,63 @@
package logic
import (
"context"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (s *Service) Album(ctx context.Context, viewerID, ownerID, cursor int64, limit int) (*ListResult, error) {
if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized
}
if ownerID <= 0 {
return nil, errs.ErrInvalidArgument
}
if ownerID != viewerID {
ok, err := s.friends.AreFriends(ctx, viewerID, ownerID)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.ErrNotFound
}
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
if err != nil {
return nil, err
}
if blocked {
return nil, errs.ErrNotFound
}
}
limit = entity.ClampLimit(limit)
picked := make([]*entity.Post, 0, limit)
self := ownerID == viewerID
for range entity.OverfetchRounds {
batch, err := s.repo.ListByAuthor(ctx, ownerID, cursor, entity.Overfetch)
if err != nil {
return nil, err
}
if len(batch) == 0 {
break
}
vis, err := s.visFor(ctx, viewerID, batch, self)
if err != nil {
return nil, err
}
for _, p := range batch {
cursor = p.ID
if !vis.canSee(p) {
continue
}
picked = append(picked, p)
if len(picked) >= limit {
break
}
}
if len(picked) >= limit || len(batch) < entity.Overfetch {
break
}
}
return s.listResult(ctx, viewerID, picked, false)
}

View File

@ -0,0 +1,170 @@
package logic
import (
"context"
"spark-server/internal/module/moment/entity"
userentity "spark-server/internal/module/user/entity"
)
func (s *Service) attachViews(ctx context.Context, viewer int64, posts []*entity.Post, full bool) ([]*View, error) {
if len(posts) == 0 {
return []*View{}, nil
}
ids := postIDs(posts)
likes, err := s.repo.ListLikes(ctx, ids)
if err != nil {
return nil, err
}
comments, err := s.repo.ListComments(ctx, ids)
if err != nil {
return nil, err
}
uids := collectUIDs(posts, likes, comments, viewer)
users := s.loadUsers(ctx, uids)
remarks := s.loadRemarks(ctx, viewer, uids)
friendIDs, err := s.friends.ListFriendIDs(ctx, viewer)
if err != nil {
return nil, err
}
friends := idSet(friendIDs)
out := make([]*View, 0, len(posts))
for _, p := range posts {
out = append(out, s.oneView(viewer, p, likes[p.ID], comments[p.ID], users, remarks, friends, full))
}
return out, nil
}
func (s *Service) oneView(viewer int64, p *entity.Post, likes []*entity.Like, comments []*entity.Comment, users map[int64]*userentity.User, remarks map[int64]string, friends map[int64]bool, full bool) *View {
likeCards, liked, likeN := filterLikes(viewer, p.UserID, likes, users, remarks, friends, full)
commentViews, commentN := filterComments(viewer, p.UserID, comments, users, remarks, friends, full)
return &View{
ID: p.ID, User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility,
LikeCount: likeN, CommentCount: commentN,
Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS,
}
}
func filterLikes(viewer, author int64, likes []*entity.Like, users map[int64]*userentity.User, remarks map[int64]string, friends map[int64]bool, full bool) ([]UserCard, bool, int) {
out := make([]UserCard, 0)
liked := false
visible := 0
for _, like := range likes {
if like == nil {
continue
}
if like.UserID == viewer {
liked = true
}
if !seeInteract(viewer, author, like.UserID, friends) {
continue
}
visible++
if full || len(out) < entity.LikePreviewMax {
out = append(out, cardOf(users[like.UserID], remarks[like.UserID]))
}
}
return out, liked, visible
}
func filterComments(viewer, author int64, comments []*entity.Comment, users map[int64]*userentity.User, remarks map[int64]string, friends map[int64]bool, full bool) ([]*CommentView, int) {
out := make([]*CommentView, 0)
alive := 0
for _, c := range comments {
if c == nil || !seeInteract(viewer, author, c.UserID, friends) {
continue
}
cv := &CommentView{
ID: c.ID, User: cardOf(users[c.UserID], remarks[c.UserID]),
ReplyToID: c.ReplyToID, ReplyToUID: c.ReplyToUID, Text: c.Text, CreatedAt: c.CreatedAtMS,
}
if c.Status == entity.StatusDeleted {
cv.Deleted = true
cv.Text = ""
} else {
alive++
}
if full || len(out) < entity.CommentPreviewMax {
out = append(out, cv)
}
}
return out, alive
}
func seeInteract(viewer, author, actor int64, friends map[int64]bool) bool {
if viewer == author || actor == viewer {
return true
}
return friends[actor]
}
func postIDs(posts []*entity.Post) []int64 {
out := make([]int64, 0, len(posts))
for _, p := range posts {
if p != nil {
out = append(out, p.ID)
}
}
return out
}
func collectUIDs(posts []*entity.Post, likes map[int64][]*entity.Like, comments map[int64][]*entity.Comment, viewer int64) []int64 {
seen := map[int64]struct{}{viewer: {}}
add := func(id int64) {
if id > 0 {
seen[id] = struct{}{}
}
}
for _, p := range posts {
if p != nil {
add(p.UserID)
}
}
for _, rows := range likes {
for _, like := range rows {
if like != nil {
add(like.UserID)
}
}
}
for _, rows := range comments {
for _, c := range rows {
if c != nil {
add(c.UserID)
}
}
}
out := make([]int64, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out
}
func (s *Service) loadUsers(ctx context.Context, ids []int64) map[int64]*userentity.User {
out := make(map[int64]*userentity.User, len(ids))
for _, id := range ids {
u, err := s.users.Get(ctx, id)
if err != nil {
continue
}
out[id] = u
}
return out
}
func (s *Service) loadRemarks(ctx context.Context, viewer int64, ids []int64) map[int64]string {
out := make(map[int64]string)
for _, id := range ids {
if id == viewer {
continue
}
rel, err := s.friends.Relation(ctx, viewer, id)
if err != nil || rel == nil {
continue
}
out[id] = rel.Remark
}
return out
}

View File

@ -0,0 +1,117 @@
package logic
import (
"context"
"strings"
"time"
"unicode/utf8"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
type CommentInput struct {
Text string `json:"text" validate:"required,min=1,max=500" label:"field.moment_comment" errmsg:"required:validate.moment.comment_required;min:validate.moment.comment_invalid;max:validate.moment.comment_invalid"`
ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.moment_reply"`
}
func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID)
if err != nil {
return nil, err
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if !s.allowInteract(ctx, userID) {
return nil, errs.ErrRateLimited
}
text := strings.TrimSpace(in.Text)
if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentCommentInvalid, nil))
}
parent, err := s.loadParent(ctx, postID, in.ReplyToID)
if err != nil {
return nil, err
}
id, err := s.ids.NextID()
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
row := &entity.Comment{
ID: id, PostID: postID, UserID: userID, Text: text, Status: entity.StatusNormal, CreatedAtMS: now,
}
if parent != nil {
row.ReplyToID = parent.ID
row.ReplyToUID = parent.UserID
}
if err := s.repo.AddComment(ctx, row); err != nil {
return nil, err
}
_ = s.notify(ctx, commentNotices(post, row, userID))
return s.Get(ctx, userID, postID)
}
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID)
if err != nil {
return nil, err
}
c, err := s.repo.GetComment(ctx, commentID)
if err != nil {
return nil, err
}
if c.PostID != postID || c.Status == entity.StatusDeleted {
return nil, errs.ErrNotFound
}
if c.UserID != userID && post.UserID != userID {
return nil, errs.ErrForbidden
}
if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil {
return nil, err
}
return s.Get(ctx, userID, postID)
}
func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) {
if replyToID <= 0 {
return nil, nil
}
parent, err := s.repo.GetComment(ctx, replyToID)
if err != nil {
return nil, err
}
if parent.PostID != postID || parent.Status != entity.StatusNormal {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentReplyInvalid, nil))
}
return parent, nil
}
func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice {
seen := map[int64]struct{}{}
out := make([]*entity.Notice, 0, 2)
add := func(to int64, kind entity.NoticeKind) {
if to <= 0 || to == from {
return
}
if _, ok := seen[to]; ok {
return
}
seen[to] = struct{}{}
out = append(out, &entity.Notice{
ToUserID: to, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
})
}
if c.ReplyToUID > 0 {
add(c.ReplyToUID, entity.NoticeReply)
}
add(post.UserID, entity.NoticeComment)
return out
}

View File

@ -0,0 +1,36 @@
package logic
import (
"context"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (s *Service) Delete(ctx context.Context, userID, postID int64) error {
if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized
}
if postID <= 0 {
return errs.ErrInvalidArgument
}
post, err := s.repo.SoftDeletePost(ctx, userID, postID)
if err != nil {
return err
}
s.deleteMedia(ctx, post)
return nil
}
func (s *Service) deleteMedia(ctx context.Context, post *entity.Post) {
if s.objects == nil || post == nil {
return
}
content, err := entity.UnmarshalContent(post.Content)
if err != nil {
return
}
for _, key := range content.ObjectKeys() {
_ = s.objects.Delete(ctx, key)
}
}

View File

@ -0,0 +1,86 @@
package logic
import (
"context"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (s *Service) Feed(ctx context.Context, viewerID, cursor int64, limit int) (*ListResult, error) {
if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized
}
limit = entity.ClampLimit(limit)
authors, err := s.feedAuthors(ctx, viewerID)
if err != nil {
return nil, err
}
picked := make([]*entity.Post, 0, limit)
for range entity.OverfetchRounds {
batch, err := s.repo.ListByAuthors(ctx, authors, cursor, entity.Overfetch)
if err != nil {
return nil, err
}
if len(batch) == 0 {
break
}
vis, err := s.visFor(ctx, viewerID, batch, false)
if err != nil {
return nil, err
}
for _, p := range batch {
cursor = p.ID
if !vis.canSee(p) {
continue
}
picked = append(picked, p)
if len(picked) >= limit {
break
}
}
if len(picked) >= limit || len(batch) < entity.Overfetch {
break
}
}
return s.listResult(ctx, viewerID, picked, false)
}
func (s *Service) feedAuthors(ctx context.Context, viewerID int64) ([]int64, error) {
ids, err := s.friends.ListFriendIDs(ctx, viewerID)
if err != nil {
return nil, err
}
hideHim, err := s.repo.HideHimIDs(ctx, viewerID)
if err != nil {
return nil, err
}
skip := idSet(hideHim)
blocked, err := s.friends.ListBlockedPeerIDs(ctx, viewerID)
if err != nil {
return nil, err
}
for _, id := range blocked {
skip[id] = true
}
out := make([]int64, 0, len(ids)+1)
out = append(out, viewerID)
for _, id := range ids {
if !skip[id] {
out = append(out, id)
}
}
return out, nil
}
func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.Post, full bool) (*ListResult, error) {
views, err := s.attachViews(ctx, viewer, posts, full)
if err != nil {
return nil, err
}
next := int64(0)
if len(posts) > 0 {
next = posts[len(posts)-1].ID
}
return &ListResult{Items: views, NextCursor: next}, nil
}

View File

@ -0,0 +1,59 @@
package logic
import (
"context"
"errors"
"time"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID)
if err != nil {
return nil, err
}
if !s.allowInteract(ctx, userID) {
return nil, errs.ErrRateLimited
}
created, err := s.repo.AddLike(ctx, &entity.Like{PostID: postID, UserID: userID, CreatedAtMS: time.Now().UnixMilli()})
if err != nil {
return nil, err
}
if created {
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, postID, 0)})
}
return s.Get(ctx, userID, postID)
}
func (s *Service) Unlike(ctx context.Context, userID, postID int64) (*View, error) {
if _, err := s.visiblePost(ctx, userID, postID); err != nil {
return nil, err
}
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
return nil, err
}
return s.Get(ctx, userID, postID)
}
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64) (*entity.Post, error) {
if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized
}
if postID <= 0 {
return nil, errs.ErrInvalidArgument
}
post, err := s.repo.GetPost(ctx, postID)
if err != nil {
return nil, err
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}, false)
if err != nil {
return nil, err
}
if !vis.canSee(post) {
return nil, errs.ErrNotFound
}
return post, nil
}

View File

@ -0,0 +1,44 @@
package logic
import (
"sync"
"time"
)
type burst struct {
mu sync.Mutex
hits map[string][]time.Time
}
func newBurst() *burst {
return &burst{hits: make(map[string][]time.Time)}
}
func (b *burst) allow(key string, window time.Duration, n int) bool {
if b == nil || n < 1 {
return true
}
now := time.Now()
cut := now.Add(-window)
b.mu.Lock()
defer b.mu.Unlock()
kept := b.hits[key][:0]
for _, t := range b.hits[key] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= n {
b.hits[key] = kept
return false
}
b.hits[key] = append(kept, now)
if len(b.hits) > 8192 {
for k, ts := range b.hits {
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > time.Hour {
delete(b.hits, k)
}
}
}
return true
}

View File

@ -0,0 +1,183 @@
package logic
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"spark-server/internal/infrastructure/id"
friendlogic "spark-server/internal/module/friend/logic"
friendmodel "spark-server/internal/module/friend/model"
"spark-server/internal/module/moment/entity"
momentmodel "spark-server/internal/module/moment/model"
userentity "spark-server/internal/module/user/entity"
userlogic "spark-server/internal/module/user/logic"
usermodel "spark-server/internal/module/user/model"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
)
type memObj struct {
deleted []string
}
func (m *memObj) Delete(_ context.Context, key string) error {
m.deleted = append(m.deleted, key)
return nil
}
func testMoment(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Service) {
t.Helper()
ids, err := id.New(1)
if err != nil {
t.Fatal(err)
}
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil)
svc := NewService(momentmodel.NewMemoryRepo(), friends, users, &memObj{}, ids)
return svc, friends, users
}
func twoAccounts(t *testing.T, users *userlogic.Service) (*userentity.User, *userentity.User) {
t.Helper()
ctx := t.Context()
a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS})
if err != nil {
t.Fatal(err)
}
b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb})
if err != nil {
t.Fatal(err)
}
return a, b
}
func becomeFriends(t *testing.T, friends *friendlogic.Service, a, b *userentity.User) {
t.Helper()
ctx := t.Context()
applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{Email: "test02@spark.com"})
if err != nil {
t.Fatal(err)
}
if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil {
t.Fatal(err)
}
}
func ctxUser(t *testing.T, uid int64, ip string) context.Context {
t.Helper()
return reqctx.With(t.Context(), &reqctx.RequestContext{UserID: uid, Language: "zh-CN", RealIP: ip})
}
func TestPublishFeedPrivacyAndDelete(t *testing.T) {
svc, friends, users := testMoment(t)
a, b := twoAccounts(t, users)
becomeFriends(t, friends, a, b)
ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2")
pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "visible"})
if err != nil || pub.Text != "visible" || pub.Kind != entity.KindText {
t.Fatalf("%+v %v", pub, err)
}
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
if err != nil || len(feed.Items) != 1 || feed.Items[0].ID != pub.ID {
t.Fatalf("feed %+v %v", feed, err)
}
priv, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
if err != nil {
t.Fatal(err)
}
feed, err = svc.Feed(ctxB, b.ID, 0, 20)
if err != nil || len(feed.Items) != 1 {
t.Fatalf("private leaked %+v %v", feed, err)
}
if _, err := svc.Get(ctxB, b.ID, priv.ID); !errors.Is(err, errs.ErrNotFound) {
t.Fatalf("private get %v", err)
}
album, err := svc.Album(ctxB, b.ID, a.ID, 0, 20)
if err != nil || len(album.Items) != 1 {
t.Fatalf("album %+v %v", album, err)
}
if err := svc.Delete(ctxA, a.ID, pub.ID); err != nil {
t.Fatal(err)
}
feed, err = svc.Feed(ctxB, b.ID, 0, 20)
if err != nil || len(feed.Items) != 0 {
t.Fatalf("deleted %+v %v", feed, err)
}
}
func TestLikeCommentNoticeAndHide(t *testing.T) {
svc, friends, users := testMoment(t)
a, b := twoAccounts(t, users)
becomeFriends(t, friends, a, b)
ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2")
post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"})
if err != nil {
t.Fatal(err)
}
liked, err := svc.Like(ctxB, b.ID, post.ID)
if err != nil || !liked.Liked || liked.LikeCount != 1 {
t.Fatalf("like %+v %v", liked, err)
}
notices, err := svc.ListNotices(ctxA, a.ID, 0, 20)
if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike {
t.Fatalf("notice %+v %v", notices, err)
}
commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"})
if err != nil || commented.CommentCount != 1 {
t.Fatalf("comment %+v %v", commented, err)
}
unread, err := svc.UnreadFeed(ctxB, b.ID)
if err != nil || !unread.HasNew || unread.LatestID != post.ID {
t.Fatalf("unread %+v %v", unread, err)
}
if err := svc.MarkFeedRead(ctxB, b.ID, post.ID); err != nil {
t.Fatal(err)
}
unread, err = svc.UnreadFeed(ctxB, b.ID)
if err != nil || unread.HasNew {
t.Fatalf("after read %+v %v", unread, err)
}
if _, err := svc.PutHide(ctxB, b.ID, a.ID, &HideInput{HideHim: true}); err != nil {
t.Fatal(err)
}
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
if err != nil {
t.Fatal(err)
}
for _, item := range feed.Items {
if item.ID == post.ID {
t.Fatal("hide him failed")
}
}
}
func TestMomentImageKeyRejectedIfChatPrefix(t *testing.T) {
svc, _, users := testMoment(t)
a, _ := twoAccounts(t, users)
ctx := ctxUser(t, a.ID, "10.0.0.1")
_, err := svc.Publish(ctx, a.ID, &PublishInput{
Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}},
})
if err == nil {
t.Fatal("accepted chat key")
}
}
func TestContentRoundTrip(t *testing.T) {
raw, err := (&entity.Content{Images: []*entity.Image{{Key: "perm/moment/image/20260918/abcdefghijklmnop.jpg"}}}).Marshal()
if err != nil {
t.Fatal(err)
}
got, err := entity.UnmarshalContent(raw)
if err != nil || got.Kind() != entity.KindImage {
t.Fatalf("%s %v %v", raw, got, err)
}
if !json.Valid(raw) || strings.Contains(string(raw), "uid") {
t.Fatalf("json %s", raw)
}
}

View File

@ -0,0 +1,132 @@
package logic
import (
"context"
"time"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
type NoticeView struct {
ID int64 `json:"id"`
Kind entity.NoticeKind `json:"kind"`
From UserCard `json:"from"`
PostID int64 `json:"post_id"`
CommentID int64 `json:"comment_id,omitempty"`
Read bool `json:"read"`
CreatedAt int64 `json:"created_at"`
}
type NoticeList struct {
Items []*NoticeView `json:"items"`
NextCursor int64 `json:"next_cursor"`
}
type Unread struct {
HasNew bool `json:"has_new"`
LatestID int64 `json:"latest_id"`
}
type ReadInput struct {
PostID int64 `json:"post_id" validate:"required,gt=0" label:"field.moment_id"`
}
type NoticeReadInput struct {
Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.moment_cursor"`
}
func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit int) (*NoticeList, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
limit = entity.ClampLimit(limit)
rows, err := s.repo.ListNotices(ctx, userID, cursor, limit)
if err != nil {
return nil, err
}
uids := make([]int64, 0, len(rows))
for _, n := range rows {
uids = append(uids, n.FromUserID)
}
users := s.loadUsers(ctx, uids)
remarks := s.loadRemarks(ctx, userID, uids)
items := make([]*NoticeView, 0, len(rows))
next := int64(0)
for _, n := range rows {
items = append(items, &NoticeView{
ID: n.ID, Kind: n.Kind, From: cardOf(users[n.FromUserID], remarks[n.FromUserID]),
PostID: n.PostID, CommentID: n.CommentID, Read: n.ReadAtMS > 0, CreatedAt: n.CreatedAtMS,
})
next = n.ID
}
return &NoticeList{Items: items, NextCursor: next}, nil
}
func (s *Service) ReadNotices(ctx context.Context, userID int64, in *NoticeReadInput) error {
if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized
}
cursor := int64(0)
if in != nil {
cursor = in.Cursor
}
return s.repo.MarkNoticesRead(ctx, userID, cursor)
}
func (s *Service) UnreadFeed(ctx context.Context, userID int64) (*Unread, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
st, err := s.repo.GetSettings(ctx, userID)
if err != nil {
return nil, err
}
feed, err := s.Feed(ctx, userID, 0, 1)
if err != nil {
return nil, err
}
latest := int64(0)
for _, item := range feed.Items {
if item != nil && item.User.UID != userID {
latest = item.ID
break
}
}
return &Unread{HasNew: latest > st.LastReadPostID, LatestID: latest}, nil
}
func (s *Service) MarkFeedRead(ctx context.Context, userID, postID int64) error {
if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized
}
if postID <= 0 {
return errs.ErrInvalidArgument
}
return s.repo.AdvanceRead(ctx, userID, postID)
}
func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error {
out := make([]*entity.Notice, 0, len(rows))
now := time.Now().UnixMilli()
for _, n := range rows {
if n == nil || n.ToUserID <= 0 || n.ToUserID == n.FromUserID {
continue
}
id, err := s.ids.NextID()
if err != nil {
return err
}
n.ID = id
n.CreatedAtMS = now
out = append(out, n)
}
return s.repo.CreateNotices(ctx, out)
}
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, postID, commentID int64) *entity.Notice {
if to <= 0 || to == from {
return nil
}
return &entity.Notice{ToUserID: to, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
}

View File

@ -0,0 +1,141 @@
package logic
import (
"context"
"strconv"
"strings"
"time"
"unicode/utf8"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
type PublishInput struct {
Text string `json:"text" validate:"omitempty,max=1000" label:"field.moment_text" errmsg:"max:validate.moment.text_invalid"`
Images []*entity.Image `json:"images" validate:"omitempty,max=9,dive"`
Video *entity.Video `json:"video"`
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.moment_visibility" errmsg:"oneof:validate.moment.visibility_invalid"`
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.moment_audience"`
POI *entity.POI `json:"poi"`
}
func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if !s.allowPublish(ctx, userID) {
return nil, errs.ErrRateLimited
}
content, kind, err := buildContent(ctx, in)
if err != nil {
return nil, err
}
raw, err := content.Marshal()
if err != nil {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentContentInvalid, nil))
}
if err := s.checkAudience(ctx, userID, in); err != nil {
return nil, err
}
id, err := s.ids.NextID()
if err != nil {
return nil, err
}
now := time.Now()
post := &entity.Post{
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
Visibility: in.Visibility, Status: entity.StatusNormal, CreatedAtMS: now.UnixMilli(),
}
if in.POI != nil {
post.POI = *in.POI
}
if err := s.repo.CreatePost(ctx, post, uniqueIDs(in.AudienceUIDs)); err != nil {
return nil, err
}
return s.viewOf(ctx, userID, post, true)
}
func (s *Service) allowPublish(ctx context.Context, userID int64) bool {
key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx)
return s.burst.allow("p1:"+key, time.Minute, 2) && s.burst.allow("pd:"+key, 24*time.Hour, 20)
}
func (s *Service) allowInteract(ctx context.Context, userID int64) bool {
key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx)
return s.burst.allow("i:"+key, time.Minute, 60)
}
func buildContent(ctx context.Context, in *PublishInput) (*entity.Content, entity.Kind, error) {
lang := reqctx.Lang(ctx)
text := strings.TrimSpace(in.Text)
if utf8.RuneCountInString(text) > entity.MaxTextRunes {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentTextInvalid, nil))
}
if len(in.Images) > 0 && in.Video != nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentMediaConflict, nil))
}
if text == "" && len(in.Images) == 0 && in.Video == nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentEmpty, nil))
}
c := &entity.Content{Images: in.Images, Video: in.Video}
if err := c.Validate(); err != nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentContentInvalid, nil))
}
return c, c.Kind(), nil
}
func (s *Service) checkAudience(ctx context.Context, userID int64, in *PublishInput) error {
lang := reqctx.Lang(ctx)
if !in.Visibility.Valid() {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentVisibilityInvalid, nil))
}
uids := uniqueIDs(in.AudienceUIDs)
if !in.Visibility.NeedsAudience() {
if len(uids) > 0 {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentAudienceInvalid, nil))
}
return nil
}
if len(uids) == 0 || len(uids) > entity.MaxAudience {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentAudienceInvalid, nil))
}
for _, peer := range uids {
if peer == userID {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentAudienceInvalid, nil))
}
ok, err := s.friends.AreFriends(ctx, userID, peer)
if err != nil {
return err
}
if !ok {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyMomentAudienceInvalid, nil))
}
}
return nil
}
func uniqueIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View File

@ -0,0 +1,80 @@
package logic
import (
"context"
friendentity "spark-server/internal/module/friend/entity"
"spark-server/internal/module/moment/entity"
userentity "spark-server/internal/module/user/entity"
)
// Repository persists Moments. Model implements it.
type Repository interface {
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
GetPost(ctx context.Context, id int64) (*entity.Post, error)
ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error)
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error)
AddLike(ctx context.Context, like *entity.Like) (created bool, err error)
RemoveLike(ctx context.Context, postID, userID int64) error
ListComments(ctx context.Context, postIDs []int64) (map[int64][]*entity.Comment, error)
GetComment(ctx context.Context, id int64) (*entity.Comment, error)
AddComment(ctx context.Context, c *entity.Comment) error
SoftDeleteComment(ctx context.Context, id int64) error
CreateNotices(ctx context.Context, rows []*entity.Notice) error
ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error)
MarkNoticesRead(ctx context.Context, userID, cursor int64) error
GetSettings(ctx context.Context, userID int64) (*entity.Settings, error)
ListSettings(ctx context.Context, userIDs []int64) (map[int64]*entity.Settings, error)
UpsertSettings(ctx context.Context, s *entity.Settings) error
AdvanceRead(ctx context.Context, userID, postID int64) error
ListHides(ctx context.Context, userID int64) ([]*entity.Hide, error)
HideHimIDs(ctx context.Context, userID int64) ([]int64, error)
HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error)
UpsertHide(ctx context.Context, h *entity.Hide) error
DeleteHide(ctx context.Context, userID, peerID int64) error
}
// Friends is the friend graph used for visibility. Friend module implements it.
type Friends interface {
AreFriends(ctx context.Context, a, b int64) (bool, error)
ListFriendIDs(ctx context.Context, userID int64) ([]int64, error)
ListBlockedPeerIDs(ctx context.Context, userID int64) ([]int64, error)
Relation(ctx context.Context, userID, peerID int64) (*friendentity.Relation, error)
BlockedEither(ctx context.Context, a, b int64) (bool, error)
}
// Users loads profile cards.
type Users interface {
Get(ctx context.Context, id int64) (*userentity.User, error)
}
// Objects deletes Moments media. Nil skips GC.
type Objects interface {
Delete(ctx context.Context, key string) error
}
// IDGen issues Snowflake ids for posts, comments, and notices.
type IDGen interface {
NextID() (int64, error)
}
// Service is the Moments use-case layer (HTTP and TCP share it).
type Service struct {
repo Repository
friends Friends
users Users
objects Objects
ids IDGen
burst *burst
}
func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service {
return &Service{repo: repo, friends: friends, users: users, objects: objects, ids: ids, burst: newBurst()}
}
func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
}

View File

@ -0,0 +1,141 @@
package logic
import (
"context"
"strings"
"time"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
type SettingsView struct {
CoverKey string `json:"cover_key"`
VisibleDays entity.VisibleDays `json:"visible_days"`
Hides []*HideView `json:"hides"`
}
type HideView struct {
User UserCard `json:"user"`
HideHim bool `json:"hide_him"`
HideMe bool `json:"hide_me"`
}
type PatchSettingsInput struct {
CoverKey *string `json:"cover_key" validate:"omitempty,max=512" label:"field.moment_cover"`
VisibleDays *entity.VisibleDays `json:"visible_days" validate:"omitempty,oneof=0 3 30 180" label:"field.moment_visible_days" errmsg:"oneof:validate.moment.visible_days_invalid"`
}
type HideInput struct {
HideHim bool `json:"hide_him"`
HideMe bool `json:"hide_me"`
}
func (s *Service) GetSettings(ctx context.Context, userID int64) (*SettingsView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
st, err := s.repo.GetSettings(ctx, userID)
if err != nil {
return nil, err
}
hides, err := s.repo.ListHides(ctx, userID)
if err != nil {
return nil, err
}
uids := make([]int64, 0, len(hides))
for _, h := range hides {
uids = append(uids, h.PeerID)
}
users := s.loadUsers(ctx, uids)
remarks := s.loadRemarks(ctx, userID, uids)
items := make([]*HideView, 0, len(hides))
for _, h := range hides {
items = append(items, &HideView{User: cardOf(users[h.PeerID], remarks[h.PeerID]), HideHim: h.HideHim, HideMe: h.HideMe})
}
return &SettingsView{CoverKey: st.CoverKey, VisibleDays: st.VisibleDays, Hides: items}, nil
}
func (s *Service) PatchSettings(ctx context.Context, userID int64, in *PatchSettingsInput) (*SettingsView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
st, err := s.repo.GetSettings(ctx, userID)
if err != nil {
return nil, err
}
if in.VisibleDays != nil {
if !in.VisibleDays.Valid() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentVisibleDaysInvalid, nil))
}
st.VisibleDays = *in.VisibleDays
}
if in.CoverKey != nil {
if err := s.setCover(ctx, st, strings.TrimSpace(*in.CoverKey)); err != nil {
return nil, err
}
}
st.UserID = userID
st.UpdatedAtMS = time.Now().UnixMilli()
if err := s.repo.UpsertSettings(ctx, st); err != nil {
return nil, err
}
return s.GetSettings(ctx, userID)
}
func (s *Service) setCover(ctx context.Context, st *entity.Settings, key string) error {
if key != "" && !entity.ValidCoverKey(key) {
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentCoverInvalid, nil))
}
old := st.CoverKey
st.CoverKey = key
if s.objects != nil && old != "" && old != key {
_ = s.objects.Delete(ctx, old)
}
return nil
}
func (s *Service) PutHide(ctx context.Context, userID, peerID int64, in *HideInput) (*SettingsView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if peerID <= 0 || peerID == userID {
return nil, errs.ErrInvalidArgument
}
ok, err := s.friends.AreFriends(ctx, userID, peerID)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentHideNotFriend, nil))
}
if in == nil {
in = &HideInput{}
}
if err := s.repo.UpsertHide(ctx, &entity.Hide{UserID: userID, PeerID: peerID, HideHim: in.HideHim, HideMe: in.HideMe}); err != nil {
return nil, err
}
return s.GetSettings(ctx, userID)
}
func (s *Service) DeleteHide(ctx context.Context, userID, peerID int64) (*SettingsView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if peerID <= 0 {
return nil, errs.ErrInvalidArgument
}
if err := s.repo.DeleteHide(ctx, userID, peerID); err != nil {
return nil, err
}
return s.GetSettings(ctx, userID)
}

View File

@ -0,0 +1,103 @@
package logic
import (
"context"
"encoding/json"
"spark-server/internal/module/moment/entity"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/pkg/errs"
)
type UserCard struct {
UID int64 `json:"uid"`
SparkID string `json:"spark_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Remark string `json:"remark"`
}
type CommentView struct {
ID int64 `json:"id"`
User UserCard `json:"user"`
ReplyToID int64 `json:"reply_to_id,omitempty"`
ReplyToUID int64 `json:"reply_to_uid,omitempty"`
Text string `json:"text"`
Deleted bool `json:"deleted,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type View struct {
ID int64 `json:"id"`
User UserCard `json:"user"`
Kind entity.Kind `json:"kind"`
Text string `json:"text"`
Content json.RawMessage `json:"content"`
POI *entity.POI `json:"poi,omitempty"`
Visibility entity.Visibility `json:"visibility"`
LikeCount int `json:"like_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Likes []UserCard `json:"likes"`
Comments []*CommentView `json:"comments"`
CreatedAt int64 `json:"created_at"`
}
type ListResult struct {
Items []*View `json:"items"`
NextCursor int64 `json:"next_cursor"`
}
func (s *Service) Get(ctx context.Context, viewerID, postID int64) (*View, error) {
if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized
}
if postID <= 0 {
return nil, errs.ErrInvalidArgument
}
post, err := s.repo.GetPost(ctx, postID)
if err != nil {
return nil, err
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}, false)
if err != nil {
return nil, err
}
if !vis.canSee(post) {
return nil, errs.ErrNotFound
}
return s.viewOf(ctx, viewerID, post, true)
}
func (s *Service) viewOf(ctx context.Context, viewer int64, post *entity.Post, full bool) (*View, error) {
views, err := s.attachViews(ctx, viewer, []*entity.Post{post}, full)
if err != nil {
return nil, err
}
if len(views) == 0 {
return nil, errs.ErrNotFound
}
return views[0], nil
}
func cardOf(u *userentity.User, remark string) UserCard {
if u == nil {
return UserCard{Remark: remark}
}
return UserCard{UID: u.ID, SparkID: u.SparkID, Nickname: u.Nickname, Avatar: u.Avatar, Remark: remark}
}
func poiPtr(p entity.POI) *entity.POI {
if !p.Set() {
return nil
}
cp := p
return &cp
}
func contentJSON(raw []byte) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(`{}`)
}
return json.RawMessage(raw)
}

View File

@ -0,0 +1,152 @@
package logic
import (
"context"
"time"
"spark-server/internal/module/moment/entity"
)
type visCtx struct {
viewer int64
selfAlbum bool
friends map[int64]bool
blocked map[int64]bool
hideHim map[int64]bool
hideMe map[int64]bool
settings map[int64]*entity.Settings
audience map[int64][]int64
now time.Time
}
func (v *visCtx) canSee(p *entity.Post) bool {
if p == nil || p.Status != entity.StatusNormal {
return false
}
if p.UserID == v.viewer {
return true
}
if !v.friends[p.UserID] || v.blocked[p.UserID] || v.hideHim[p.UserID] || v.hideMe[p.UserID] {
return false
}
if !v.selfAlbum {
days := entity.VisibleAll
if s := v.settings[p.UserID]; s != nil {
days = s.VisibleDays
}
if !withinDays(p.CreatedAtMS, days, v.now) {
return false
}
}
return visibilityOK(p, v.viewer, v.audience[p.ID])
}
func visibilityOK(p *entity.Post, viewer int64, peers []int64) bool {
switch p.Visibility {
case entity.VisibilityFriends:
return true
case entity.VisibilityPrivate:
return false
case entity.VisibilityInclude:
return containsID(peers, viewer)
case entity.VisibilityExclude:
return !containsID(peers, viewer)
default:
return false
}
}
func withinDays(createdMS int64, days entity.VisibleDays, now time.Time) bool {
if days <= 0 || createdMS <= 0 {
return true
}
return createdMS >= now.AddDate(0, 0, -int(days)).UnixMilli()
}
func containsID(ids []int64, want int64) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
func (s *Service) visFor(ctx context.Context, viewer int64, posts []*entity.Post, selfAlbum bool) (*visCtx, error) {
authors := uniqueAuthors(posts)
v := &visCtx{
viewer: viewer, selfAlbum: selfAlbum, friends: map[int64]bool{}, blocked: map[int64]bool{},
hideHim: map[int64]bool{}, hideMe: map[int64]bool{}, now: time.Now(),
}
friendIDs, err := s.friends.ListFriendIDs(ctx, viewer)
if err != nil {
return nil, err
}
for _, id := range friendIDs {
v.friends[id] = true
}
blocked, err := s.friends.ListBlockedPeerIDs(ctx, viewer)
if err != nil {
return nil, err
}
for _, id := range blocked {
v.blocked[id] = true
}
if err := s.fillHides(ctx, viewer, v); err != nil {
return nil, err
}
v.settings, err = s.repo.ListSettings(ctx, authors)
if err != nil {
return nil, err
}
needAud := make([]int64, 0)
for _, p := range posts {
if p != nil && p.Visibility.NeedsAudience() {
needAud = append(needAud, p.ID)
}
}
v.audience, err = s.repo.ListAudience(ctx, needAud)
return v, err
}
func (s *Service) fillHides(ctx context.Context, viewer int64, v *visCtx) error {
him, err := s.repo.HideHimIDs(ctx, viewer)
if err != nil {
return err
}
for _, id := range him {
v.hideHim[id] = true
}
me, err := s.repo.HideMeFromIDs(ctx, viewer)
if err != nil {
return err
}
for _, id := range me {
v.hideMe[id] = true
}
return nil
}
func uniqueAuthors(posts []*entity.Post) []int64 {
seen := make(map[int64]struct{})
out := make([]int64, 0)
for _, p := range posts {
if p == nil || p.UserID <= 0 {
continue
}
if _, ok := seen[p.UserID]; ok {
continue
}
seen[p.UserID] = struct{}{}
out = append(out, p.UserID)
}
return out
}
func idSet(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}

View File

@ -0,0 +1,247 @@
package model
import (
"context"
"sort"
"sync"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
type likeKey struct {
PostID int64
UserID int64
}
type hideKey struct {
UserID int64
PeerID int64
}
// MemoryRepo is an in-process Moments store for tests and empty-DSN bring-up.
type MemoryRepo struct {
mu sync.Mutex
posts map[int64]*entity.Post
audience map[int64][]int64
likes map[likeKey]*entity.Like
comments map[int64]*entity.Comment
notices map[int64]*entity.Notice
settings map[int64]*entity.Settings
hides map[hideKey]*entity.Hide
}
func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{
posts: make(map[int64]*entity.Post),
audience: make(map[int64][]int64),
likes: make(map[likeKey]*entity.Like),
comments: make(map[int64]*entity.Comment),
notices: make(map[int64]*entity.Notice),
settings: make(map[int64]*entity.Settings),
hides: make(map[hideKey]*entity.Hide),
}
}
func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.posts[id]
if !ok {
return nil, errs.ErrNotFound
}
return clonePost(row), nil
}
func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(authorIDs)
out := make([]*entity.Post, 0)
for _, row := range m.posts {
if row.Status != entity.StatusNormal || !allow[row.UserID] {
continue
}
if cursor > 0 && row.ID >= cursor {
continue
}
out = append(out, clonePost(row))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
}
func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]int64)
for id, peers := range m.audience {
if !allow[id] {
continue
}
out[id] = append([]int64(nil), peers...)
}
return out, nil
}
func (m *MemoryRepo) ListLikes(_ context.Context, postIDs []int64) (map[int64][]*entity.Like, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]*entity.Like)
for _, like := range m.likes {
if !allow[like.PostID] {
continue
}
cp := *like
out[like.PostID] = append(out[like.PostID], &cp)
}
for id := range out {
sort.Slice(out[id], func(i, j int) bool { return out[id][i].CreatedAtMS < out[id][j].CreatedAtMS })
}
return out, nil
}
func (m *MemoryRepo) ListComments(_ context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]*entity.Comment)
for _, c := range m.comments {
if !allow[c.PostID] {
continue
}
out[c.PostID] = append(out[c.PostID], cloneComment(c))
}
for id := range out {
sort.Slice(out[id], func(i, j int) bool { return out[id][i].ID < out[id][j].ID })
}
return out, nil
}
func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.comments[id]
if !ok {
return nil, errs.ErrNotFound
}
return cloneComment(row), nil
}
func (m *MemoryRepo) GetSettings(_ context.Context, userID int64) (*entity.Settings, error) {
m.mu.Lock()
defer m.mu.Unlock()
if row, ok := m.settings[userID]; ok {
cp := *row
return &cp, nil
}
return entity.DefaultSettings(userID), nil
}
func (m *MemoryRepo) ListSettings(_ context.Context, userIDs []int64) (map[int64]*entity.Settings, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(userIDs)
out := make(map[int64]*entity.Settings)
for id, row := range m.settings {
if allow[id] {
cp := *row
out[id] = &cp
}
}
return out, nil
}
func (m *MemoryRepo) ListHides(_ context.Context, userID int64) ([]*entity.Hide, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*entity.Hide, 0)
for _, h := range m.hides {
if h.UserID == userID {
cp := *h
out = append(out, &cp)
}
}
return out, nil
}
func (m *MemoryRepo) HideHimIDs(_ context.Context, userID int64) ([]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]int64, 0)
for _, h := range m.hides {
if h.UserID == userID && h.HideHim {
out = append(out, h.PeerID)
}
}
return out, nil
}
func (m *MemoryRepo) HideMeFromIDs(_ context.Context, viewerID int64) ([]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]int64, 0)
for _, h := range m.hides {
if h.PeerID == viewerID && h.HideMe {
out = append(out, h.UserID)
}
}
return out, nil
}
func (m *MemoryRepo) ListNotices(_ context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*entity.Notice, 0)
for _, n := range m.notices {
if n.ToUserID != userID {
continue
}
if cursor > 0 && n.ID >= cursor {
continue
}
cp := *n
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func toSet(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}
func clonePost(p *entity.Post) *entity.Post {
if p == nil {
return nil
}
cp := *p
if p.Content != nil {
cp.Content = append([]byte(nil), p.Content...)
}
return &cp
}
func cloneComment(c *entity.Comment) *entity.Comment {
if c == nil {
return nil
}
cp := *c
return &cp
}

View File

@ -0,0 +1,186 @@
package model
import (
"context"
"time"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (m *MemoryRepo) CreatePost(_ context.Context, post *entity.Post, audience []int64) error {
if post == nil || post.ID <= 0 || post.UserID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.posts[post.ID]; ok {
return errs.ErrDuplicate
}
m.posts[post.ID] = clonePost(post)
if len(audience) > 0 {
m.audience[post.ID] = append([]int64(nil), audience...)
}
return nil
}
func (m *MemoryRepo) SoftDeletePost(_ context.Context, userID, postID int64) (*entity.Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.posts[postID]
if !ok || row.UserID != userID || row.Status == entity.StatusDeleted {
return nil, errs.ErrNotFound
}
row.Status = entity.StatusDeleted
return clonePost(row), nil
}
func (m *MemoryRepo) AddLike(_ context.Context, like *entity.Like) (bool, error) {
if like == nil || like.PostID <= 0 || like.UserID <= 0 {
return false, errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[like.PostID]
if !ok {
return false, errs.ErrNotFound
}
key := likeKey{PostID: like.PostID, UserID: like.UserID}
if _, ok := m.likes[key]; ok {
return false, nil
}
cp := *like
if cp.CreatedAtMS == 0 {
cp.CreatedAtMS = time.Now().UnixMilli()
}
m.likes[key] = &cp
post.LikeCount++
return true, nil
}
func (m *MemoryRepo) RemoveLike(_ context.Context, postID, userID int64) error {
m.mu.Lock()
defer m.mu.Unlock()
key := likeKey{PostID: postID, UserID: userID}
if _, ok := m.likes[key]; !ok {
return errs.ErrNotFound
}
delete(m.likes, key)
if post, ok := m.posts[postID]; ok && post.LikeCount > 0 {
post.LikeCount--
}
return nil
}
func (m *MemoryRepo) AddComment(_ context.Context, c *entity.Comment) error {
if c == nil || c.ID <= 0 || c.PostID <= 0 || c.UserID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[c.PostID]
if !ok {
return errs.ErrNotFound
}
if _, ok := m.comments[c.ID]; ok {
return errs.ErrDuplicate
}
m.comments[c.ID] = cloneComment(c)
post.CommentCount++
return nil
}
func (m *MemoryRepo) SoftDeleteComment(_ context.Context, id int64) error {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.comments[id]
if !ok || row.Status == entity.StatusDeleted {
return errs.ErrNotFound
}
row.Status = entity.StatusDeleted
if post, ok := m.posts[row.PostID]; ok && post.CommentCount > 0 {
post.CommentCount--
}
return nil
}
func (m *MemoryRepo) CreateNotices(_ context.Context, rows []*entity.Notice) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, n := range rows {
if n == nil || n.ID <= 0 {
return errs.ErrInvalidArgument
}
if _, ok := m.notices[n.ID]; ok {
return errs.ErrDuplicate
}
cp := *n
m.notices[n.ID] = &cp
}
return nil
}
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UnixMilli()
for _, n := range m.notices {
if n.ToUserID != userID || n.ReadAtMS > 0 {
continue
}
if cursor > 0 && n.ID > cursor {
continue
}
n.ReadAtMS = now
}
return nil
}
func (m *MemoryRepo) UpsertSettings(_ context.Context, s *entity.Settings) error {
if s == nil || s.UserID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
cp := *s
if cp.UpdatedAtMS == 0 {
cp.UpdatedAtMS = time.Now().UnixMilli()
}
m.settings[s.UserID] = &cp
return nil
}
func (m *MemoryRepo) AdvanceRead(ctx context.Context, userID, postID int64) error {
cur, err := m.GetSettings(ctx, userID)
if err != nil {
return err
}
if cur.LastReadPostID >= postID {
return nil
}
cur.LastReadPostID = postID
cur.UpdatedAtMS = time.Now().UnixMilli()
return m.UpsertSettings(ctx, cur)
}
func (m *MemoryRepo) UpsertHide(_ context.Context, h *entity.Hide) error {
if h == nil || h.UserID <= 0 || h.PeerID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
cp := *h
m.hides[hideKey{UserID: h.UserID, PeerID: h.PeerID}] = &cp
return nil
}
func (m *MemoryRepo) DeleteHide(_ context.Context, userID, peerID int64) error {
m.mu.Lock()
defer m.mu.Unlock()
key := hideKey{UserID: userID, PeerID: peerID}
if _, ok := m.hides[key]; !ok {
return errs.ErrNotFound
}
delete(m.hides, key)
return nil
}

View File

@ -0,0 +1,214 @@
package model
import (
"context"
"errors"
"gorm.io/gorm"
"spark-server/internal/module/moment/entity"
)
func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row Post
if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.Entity(), nil
}
func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
if len(authorIDs) == 0 {
return []*entity.Post{}, nil
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
q := db.Where("user_id IN ? AND status = ?", authorIDs, entity.StatusNormal)
if cursor > 0 {
q = q.Where("id < ?", cursor)
}
var rows []Post
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
return collectPosts(rows), nil
}
func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
}
func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) {
out := make(map[int64][]int64)
if len(postIDs) == 0 {
return out, nil
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []Audience
if err := db.Where("post_id IN ?", postIDs).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
for _, row := range rows {
out[row.PostID] = append(out[row.PostID], row.PeerID)
}
return out, nil
}
func (m *Repo) ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error) {
out := make(map[int64][]*entity.Like)
if len(postIDs) == 0 {
return out, nil
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []Like
if err := db.Where("post_id IN ?", postIDs).Order("created_at ASC").Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
for i := range rows {
like := rows[i].Entity()
out[like.PostID] = append(out[like.PostID], like)
}
return out, nil
}
func (m *Repo) ListComments(ctx context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) {
out := make(map[int64][]*entity.Comment)
if len(postIDs) == 0 {
return out, nil
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []Comment
if err := db.Where("post_id IN ?", postIDs).Order("id ASC").Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
for i := range rows {
c := rows[i].Entity()
out[c.PostID] = append(out[c.PostID], c)
}
return out, nil
}
func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row Comment
if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.Entity(), nil
}
func (m *Repo) GetSettings(ctx context.Context, userID int64) (*entity.Settings, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row Setting
if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return entity.DefaultSettings(userID), nil
}
return nil, mapDBErr(err)
}
return row.Entity(), nil
}
func (m *Repo) ListSettings(ctx context.Context, userIDs []int64) (map[int64]*entity.Settings, error) {
out := make(map[int64]*entity.Settings)
if len(userIDs) == 0 {
return out, nil
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []Setting
if err := db.Where("user_id IN ?", userIDs).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
for i := range rows {
out[rows[i].UserID] = rows[i].Entity()
}
return out, nil
}
func (m *Repo) ListHides(ctx context.Context, userID int64) ([]*entity.Hide, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []Hide
if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
out := make([]*entity.Hide, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out, nil
}
func (m *Repo) HideHimIDs(ctx context.Context, userID int64) ([]int64, error) {
return m.pluckHideIDs(ctx, "user_id = ? AND hide_him = ?", userID, true, "peer_id")
}
func (m *Repo) HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error) {
return m.pluckHideIDs(ctx, "peer_id = ? AND hide_me = ?", viewerID, true, "user_id")
}
func (m *Repo) pluckHideIDs(ctx context.Context, cond string, id int64, flag bool, col string) ([]int64, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var ids []int64
if err := db.Model(&Hide{}).Where(cond, id, flag).Pluck(col, &ids).Error; err != nil {
return nil, mapDBErr(err)
}
return ids, nil
}
func (m *Repo) ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
q := db.Where("to_user_id = ?", userID)
if cursor > 0 {
q = q.Where("id < ?", cursor)
}
var rows []Notice
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
out := make([]*entity.Notice, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out, nil
}
func collectPosts(rows []Post) []*entity.Post {
out := make([]*entity.Post, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out
}

View File

@ -0,0 +1,41 @@
package model
import (
"context"
"errors"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"spark-server/internal/pkg/errs"
)
// Repo is MySQL persistence for Moments.
type Repo struct {
gdb *gorm.DB
}
func NewRepo(gdb *gorm.DB) *Repo {
return &Repo{gdb: gdb}
}
func (m *Repo) session(ctx context.Context) (*gorm.DB, error) {
if m == nil || m.gdb == nil {
return nil, errs.ErrAvailable
}
return m.gdb.WithContext(ctx), nil
}
func mapDBErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrNotFound
}
var me *mysqldriver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return errs.ErrDuplicate
}
return errs.ErrInner.WithError("moment-mysql", err)
}

View File

@ -0,0 +1,196 @@
package model
import (
"time"
"spark-server/internal/module/moment/entity"
)
// Post maps table moment_posts.
type Post struct {
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 帖 id,Feed/相册游标"`
UserID int64 `gorm:"column:user_id;comment:作者 users.id"`
Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"`
Text string `gorm:"column:text;comment:正文,可空;与媒体至少有一项"`
Content []byte `gorm:"column:content;type:blob;comment:图/视频 JSON 字节,不存媒体二进制"`
POILat float64 `gorm:"column:poi_lat;comment:地点纬度,无地点为 0"`
POILng float64 `gorm:"column:poi_lng;comment:地点经度,无地点为 0"`
POIName string `gorm:"column:poi_name;comment:地点名称"`
POIAddress string `gorm:"column:poi_address;comment:地点地址"`
Visibility int16 `gorm:"column:visibility;comment:0好友公开 1私密 2白名单 3黑名单"`
Status int16 `gorm:"column:status;comment:0正常 1已删"`
LikeCount int `gorm:"column:like_count;comment:点赞数,与 moment_likes 同事务维护"`
CommentCount int `gorm:"column:comment_count;comment:未删评论数,与 moment_comments 同事务维护"`
CreatedAt time.Time `gorm:"column:created_at;comment:发帖时间,visible_days 按此裁剪"`
}
func (Post) TableName() string { return "moment_posts" }
func (r *Post) Entity() *entity.Post {
if r == nil {
return nil
}
return &entity.Post{
ID: r.ID, UserID: r.UserID, Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress},
Visibility: entity.Visibility(r.Visibility), Status: entity.Status(r.Status),
LikeCount: r.LikeCount, CommentCount: r.CommentCount, CreatedAtMS: r.CreatedAt.UnixMilli(),
}
}
func postFrom(p *entity.Post) *Post {
if p == nil {
return nil
}
row := &Post{
ID: p.ID, UserID: p.UserID, Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address,
Visibility: int16(p.Visibility), Status: int16(p.Status),
LikeCount: p.LikeCount, CommentCount: p.CommentCount,
}
if p.CreatedAtMS > 0 {
row.CreatedAt = time.UnixMilli(p.CreatedAtMS)
}
return row
}
// Audience maps table moment_audience.
type Audience struct {
PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"`
PeerID int64 `gorm:"column:peer_id;primaryKey;comment:白名单能看 / 黑名单不能看 的好友 uid"`
}
func (Audience) TableName() string { return "moment_audience" }
// Like maps table moment_likes.
type Like struct {
PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"`
UserID int64 `gorm:"column:user_id;primaryKey;comment:点赞人"`
CreatedAt time.Time `gorm:"column:created_at;comment:点赞时间"`
}
func (Like) TableName() string { return "moment_likes" }
func (r *Like) Entity() *entity.Like {
if r == nil {
return nil
}
return &entity.Like{PostID: r.PostID, UserID: r.UserID, CreatedAtMS: r.CreatedAt.UnixMilli()}
}
// Comment maps table moment_comments.
type Comment struct {
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 评论 id"`
PostID int64 `gorm:"column:post_id;comment:帖 id"`
UserID int64 `gorm:"column:user_id;comment:评论人"`
ReplyToID int64 `gorm:"column:reply_to_id;comment:父评论 id,0 表示直接评帖"`
ReplyToUID int64 `gorm:"column:reply_to_uid;comment:被回复用户,0 表示直接评帖"`
Text string `gorm:"column:text;comment:评论文本"`
Status int16 `gorm:"column:status;comment:0正常 1已删"`
CreatedAt time.Time `gorm:"column:created_at;comment:评论时间"`
}
func (Comment) TableName() string { return "moment_comments" }
func (r *Comment) Entity() *entity.Comment {
if r == nil {
return nil
}
return &entity.Comment{
ID: r.ID, PostID: r.PostID, UserID: r.UserID, ReplyToID: r.ReplyToID, ReplyToUID: r.ReplyToUID,
Text: r.Text, Status: entity.Status(r.Status), CreatedAtMS: r.CreatedAt.UnixMilli(),
}
}
func commentFrom(c *entity.Comment) *Comment {
if c == nil {
return nil
}
row := &Comment{
ID: c.ID, PostID: c.PostID, UserID: c.UserID, ReplyToID: c.ReplyToID, ReplyToUID: c.ReplyToUID,
Text: c.Text, Status: int16(c.Status),
}
if c.CreatedAtMS > 0 {
row.CreatedAt = time.UnixMilli(c.CreatedAtMS)
}
return row
}
// Notice maps table moment_notices.
type Notice struct {
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 通知 id,列表游标"`
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
FromUserID int64 `gorm:"column:from_user_id;comment:动作发起人"`
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
PostID int64 `gorm:"column:post_id;comment:帖 id"`
CommentID int64 `gorm:"column:comment_id;comment:评论 id,赞为 0"`
ReadAt *time.Time `gorm:"column:read_at;comment:已读时间,空表示未读"`
CreatedAt time.Time `gorm:"column:created_at;comment:通知时间"`
}
func (Notice) TableName() string { return "moment_notices" }
func (r *Notice) Entity() *entity.Notice {
if r == nil {
return nil
}
n := &entity.Notice{
ID: r.ID, ToUserID: r.ToUserID, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(),
}
if r.ReadAt != nil {
n.ReadAtMS = r.ReadAt.UnixMilli()
}
return n
}
// Setting maps table moment_settings.
type Setting struct {
UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 users.id"`
CoverKey string `gorm:"column:cover_key;comment:封面 object_key,空表示未设"`
VisibleDays int16 `gorm:"column:visible_days;comment:允许朋友查看范围:0全部 3/30/180 天"`
LastReadPostID int64 `gorm:"column:last_read_post_id;comment:发现页红点水位,只增不减"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"`
}
func (Setting) TableName() string { return "moment_settings" }
func (r *Setting) Entity() *entity.Settings {
if r == nil {
return nil
}
return &entity.Settings{
UserID: r.UserID, CoverKey: r.CoverKey, VisibleDays: entity.VisibleDays(r.VisibleDays),
LastReadPostID: r.LastReadPostID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
}
}
func settingFrom(s *entity.Settings) *Setting {
if s == nil {
return nil
}
row := &Setting{
UserID: s.UserID, CoverKey: s.CoverKey, VisibleDays: int16(s.VisibleDays), LastReadPostID: s.LastReadPostID,
}
if s.UpdatedAtMS > 0 {
row.UpdatedAt = time.UnixMilli(s.UpdatedAtMS)
}
return row
}
// Hide maps table moment_hides.
type Hide struct {
UserID int64 `gorm:"column:user_id;primaryKey;comment:设置方"`
PeerID int64 `gorm:"column:peer_id;primaryKey;comment:对方"`
HideHim bool `gorm:"column:hide_him;comment:1=不看他(Feed 去掉对方)"`
HideMe bool `gorm:"column:hide_me;comment:1=不让他看"`
}
func (Hide) TableName() string { return "moment_hides" }
func (r *Hide) Entity() *entity.Hide {
if r == nil {
return nil
}
return &entity.Hide{UserID: r.UserID, PeerID: r.PeerID, HideHim: r.HideHim, HideMe: r.HideMe}
}

View File

@ -0,0 +1,256 @@
package model
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"spark-server/internal/module/moment/entity"
"spark-server/internal/pkg/errs"
)
func (m *Repo) CreatePost(ctx context.Context, post *entity.Post, audience []int64) error {
if post == nil || post.ID <= 0 || post.UserID <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := postFrom(post)
if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now()
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(row).Error; err != nil {
return mapDBErr(err)
}
for _, peer := range audience {
if err := tx.Create(&Audience{PostID: post.ID, PeerID: peer}).Error; err != nil {
return mapDBErr(err)
}
}
return nil
})
}
func (m *Repo) SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row Post
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", postID, userID).Take(&row).Error; err != nil {
return mapDBErr(err)
}
if row.Status == int16(entity.StatusDeleted) {
return errs.ErrNotFound
}
res := tx.Model(&row).Update("status", entity.StatusDeleted)
if res.Error != nil {
return mapDBErr(res.Error)
}
if res.RowsAffected == 0 {
return errs.ErrNotFound
}
row.Status = int16(entity.StatusDeleted)
return nil
})
if err != nil {
return nil, err
}
return row.Entity(), nil
}
func (m *Repo) AddLike(ctx context.Context, like *entity.Like) (bool, error) {
if like == nil || like.PostID <= 0 || like.UserID <= 0 {
return false, errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return false, err
}
row := Like{PostID: like.PostID, UserID: like.UserID, CreatedAt: time.Now()}
if like.CreatedAtMS > 0 {
row.CreatedAt = time.UnixMilli(like.CreatedAtMS)
}
created := false
err = db.Transaction(func(tx *gorm.DB) error {
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
if res.Error != nil {
return mapDBErr(res.Error)
}
if res.RowsAffected == 0 {
return nil
}
created = true
return bumpCount(tx, like.PostID, "like_count", 1)
})
return created, err
}
func (m *Repo) RemoveLike(ctx context.Context, postID, userID int64) error {
db, err := m.session(ctx)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&Like{})
if res.Error != nil {
return mapDBErr(res.Error)
}
if res.RowsAffected == 0 {
return errs.ErrNotFound
}
return bumpCount(tx, postID, "like_count", -1)
})
}
func (m *Repo) AddComment(ctx context.Context, c *entity.Comment) error {
if c == nil || c.ID <= 0 || c.PostID <= 0 || c.UserID <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := commentFrom(c)
if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now()
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(row).Error; err != nil {
return mapDBErr(err)
}
return bumpCount(tx, c.PostID, "comment_count", 1)
})
}
func (m *Repo) SoftDeleteComment(ctx context.Context, id int64) error {
db, err := m.session(ctx)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var row Comment
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
return mapDBErr(err)
}
if row.Status == int16(entity.StatusDeleted) {
return errs.ErrNotFound
}
if err := tx.Model(&row).Update("status", entity.StatusDeleted).Error; err != nil {
return mapDBErr(err)
}
return bumpCount(tx, row.PostID, "comment_count", -1)
})
}
func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
if len(rows) == 0 {
return nil
}
db, err := m.session(ctx)
if err != nil {
return err
}
now := time.Now()
for _, n := range rows {
row := Notice{
ID: n.ID, ToUserID: n.ToUserID, FromUserID: n.FromUserID, Kind: int16(n.Kind),
PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now,
}
if n.CreatedAtMS > 0 {
row.CreatedAt = time.UnixMilli(n.CreatedAtMS)
}
if err := db.Create(&row).Error; err != nil {
return mapDBErr(err)
}
}
return nil
}
func (m *Repo) MarkNoticesRead(ctx context.Context, userID, cursor int64) error {
db, err := m.session(ctx)
if err != nil {
return err
}
q := db.Model(&Notice{}).Where("to_user_id = ? AND read_at IS NULL", userID)
if cursor > 0 {
q = q.Where("id <= ?", cursor)
}
return mapDBErr(q.Update("read_at", time.Now()).Error)
}
func (m *Repo) UpsertSettings(ctx context.Context, s *entity.Settings) error {
if s == nil || s.UserID <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := settingFrom(s)
if row.UpdatedAt.IsZero() {
row.UpdatedAt = time.Now()
}
return mapDBErr(db.Save(row).Error)
}
func (m *Repo) AdvanceRead(ctx context.Context, userID, postID int64) error {
if userID <= 0 || postID <= 0 {
return errs.ErrInvalidArgument
}
cur, err := m.GetSettings(ctx, userID)
if err != nil {
return err
}
if cur.LastReadPostID >= postID {
return nil
}
cur.LastReadPostID = postID
cur.UpdatedAtMS = time.Now().UnixMilli()
return m.UpsertSettings(ctx, cur)
}
func (m *Repo) UpsertHide(ctx context.Context, h *entity.Hide) error {
if h == nil || h.UserID <= 0 || h.PeerID <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := Hide{UserID: h.UserID, PeerID: h.PeerID, HideHim: h.HideHim, HideMe: h.HideMe}
return mapDBErr(db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "peer_id"}},
DoUpdates: clause.AssignmentColumns([]string{"hide_him", "hide_me"}),
}).Create(&row).Error)
}
func (m *Repo) DeleteHide(ctx context.Context, userID, peerID int64) error {
db, err := m.session(ctx)
if err != nil {
return err
}
res := db.Where("user_id = ? AND peer_id = ?", userID, peerID).Delete(&Hide{})
if res.Error != nil {
return mapDBErr(res.Error)
}
if res.RowsAffected == 0 {
return errs.ErrNotFound
}
return nil
}
func bumpCount(tx *gorm.DB, postID int64, col string, delta int) error {
expr := col + " + ?"
if delta < 0 {
expr = "CASE WHEN " + col + " + ? < 0 THEN 0 ELSE " + col + " + ? END"
return mapDBErr(tx.Model(&Post{}).Where("id = ?", postID).Update(col, gorm.Expr(expr, delta, delta)).Error)
}
return mapDBErr(tx.Model(&Post{}).Where("id = ?", postID).Update(col, gorm.Expr(expr, delta)).Error)
}

View File

@ -15,17 +15,24 @@ const (
PurposeChatVideo = "chat_video" PurposeChatVideo = "chat_video"
PurposeChatGIF = "chat_gif" PurposeChatGIF = "chat_gif"
PurposeChatFile = "chat_file" PurposeChatFile = "chat_file"
PurposeMomentImage = "moment_image"
PurposeMomentVideo = "moment_video"
PurposeMomentCover = "moment_cover"
PrefixPerm = "perm/" PrefixPerm = "perm/"
PrefixChat = "chat/" PrefixChat = "chat/"
PrefixAvatar = "perm/avatar/" PrefixAvatar = "perm/avatar/"
PrefixGroup = "perm/group/" PrefixGroup = "perm/group/"
PrefixFav = "perm/fav/" PrefixFav = "perm/fav/"
PrefixChatImage = "chat/image/" PrefixChatImage = "chat/image/"
PrefixChatVoice = "chat/voice/" PrefixChatVoice = "chat/voice/"
PrefixChatVideo = "chat/video/" PrefixChatVideo = "chat/video/"
PrefixChatGIF = "chat/gif/" PrefixChatGIF = "chat/gif/"
PrefixChatFile = "chat/file/" PrefixChatFile = "chat/file/"
PrefixMoment = "perm/moment/"
PrefixMomentImage = "perm/moment/image/"
PrefixMomentVideo = "perm/moment/video/"
PrefixMomentCover = "perm/moment/cover/"
MethodPUT = "PUT" MethodPUT = "PUT"
MethodGET = "GET" MethodGET = "GET"
@ -34,6 +41,7 @@ const (
const ( const (
maxAvatar = 2 << 20 maxAvatar = 2 << 20
maxImage = 32 << 20 maxImage = 32 << 20
maxMoment = 10 << 20
maxGIF = 10 << 20 maxGIF = 10 << 20
maxVoice = 16 << 20 maxVoice = 16 << 20
maxVideo = 100 << 20 maxVideo = 100 << 20
@ -93,6 +101,12 @@ func BuildObjectKey(purpose string, ownerID int64, name, day, ext string) string
return fmt.Sprintf("%s%s/%s.%s", PrefixAvatar, day, name, ext) return fmt.Sprintf("%s%s/%s.%s", PrefixAvatar, day, name, ext)
case PurposeGroupAvatar: case PurposeGroupAvatar:
return fmt.Sprintf("%s%d/%s.%s", PrefixGroup, ownerID, name, ext) return fmt.Sprintf("%s%d/%s.%s", PrefixGroup, ownerID, name, ext)
case PurposeMomentImage:
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentImage, day, name, ext)
case PurposeMomentVideo:
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentVideo, day, name, ext)
case PurposeMomentCover:
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentCover, day, name, ext)
default: default:
return fmt.Sprintf("%s%s/%s/%s.%s", PrefixChat, chatKind(purpose), day, name, ext) return fmt.Sprintf("%s%s/%s/%s.%s", PrefixChat, chatKind(purpose), day, name, ext)
} }
@ -136,6 +150,9 @@ func ValidObjectKey(key string) bool {
var specs = map[string]Spec{ var specs = map[string]Spec{
PurposeAvatar: {Purpose: PurposeAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, PurposeAvatar: {Purpose: PurposeAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs},
PurposeGroupAvatar: {Purpose: PurposeGroupAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, PurposeGroupAvatar: {Purpose: PurposeGroupAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs},
PurposeMomentCover: {Purpose: PurposeMomentCover, MaxSize: maxAvatar, MIMEs: imageMIMEs},
PurposeMomentImage: {Purpose: PurposeMomentImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs},
PurposeMomentVideo: {Purpose: PurposeMomentVideo, MaxSize: maxVideo, MIMEs: videoMIMEs},
PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs}, PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs},
PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs}, PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs},
PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs}, PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs},
@ -144,6 +161,7 @@ var specs = map[string]Spec{
} }
var imageMIMEs = set("image/jpeg", "image/jpg", "image/png") var imageMIMEs = set("image/jpeg", "image/jpg", "image/png")
var momentImageMIMEs = set("image/jpeg", "image/jpg", "image/png", "image/gif")
var gifMIMEs = set("image/gif", "image/webp") var gifMIMEs = set("image/gif", "image/webp")
var voiceMIMEs = set("audio/mpeg", "audio/mp4", "audio/aac", "audio/amr", "audio/ogg", "audio/wav", "audio/webm", "audio/x-m4a") var voiceMIMEs = set("audio/mpeg", "audio/mp4", "audio/aac", "audio/amr", "audio/ogg", "audio/wav", "audio/webm", "audio/x-m4a")
var videoMIMEs = set("video/mp4", "video/quicktime", "video/webm") var videoMIMEs = set("video/mp4", "video/quicktime", "video/webm")
@ -156,7 +174,7 @@ var fileMIMEs = set(
var mimeExt = map[string]string{ var mimeExt = map[string]string{
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp",
"image/gif": "gif", "image/gif": "gif",
"audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/aac": "aac", "audio/amr": "amr", "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/aac": "aac", "audio/amr": "amr",
"audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "weba", "audio/x-m4a": "m4a", "audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "weba", "audio/x-m4a": "m4a",
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",

View File

@ -26,6 +26,14 @@ func TestBuildObjectKeyByPurpose(t *testing.T) {
if fav != "perm/fav/20260917/99.m4a" { if fav != "perm/fav/20260917/99.m4a" {
t.Fatalf("fav %s", fav) t.Fatalf("fav %s", fav)
} }
img := BuildObjectKey(PurposeMomentImage, 11, "99", "20260917", "jpg")
if img != "perm/moment/image/20260917/99.jpg" {
t.Fatalf("moment image %s", img)
}
cover := BuildObjectKey(PurposeMomentCover, 11, "aa", "20260917", "png")
if cover != "perm/moment/cover/20260917/aa.png" {
t.Fatalf("cover %s", cover)
}
} }
func TestValidObjectKey(t *testing.T) { func TestValidObjectKey(t *testing.T) {

View File

@ -13,7 +13,7 @@ import (
// CreateInput requests a single-object Presigned PUT. The client does not choose the key. // CreateInput requests a single-object Presigned PUT. The client does not choose the key.
type CreateInput struct { type CreateInput struct {
Purpose string `json:"purpose" validate:"required,oneof=avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file" label:"field.purpose" errmsg:"oneof:validate.upload.purpose_invalid"` Purpose string `json:"purpose" validate:"required,oneof=avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file moment_image moment_video moment_cover" label:"field.purpose" errmsg:"oneof:validate.upload.purpose_invalid"`
Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"` Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"`
Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"` Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"`
GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"` GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"`

View File

@ -62,6 +62,9 @@ func TestCreateCredentialChatVsAvatar(t *testing.T) {
if err != nil || !strings.HasPrefix(group.ObjectKey, "perm/group/7/") { if err != nil || !strings.HasPrefix(group.ObjectKey, "perm/group/7/") {
t.Fatalf("%+v %v", group, err) t.Fatalf("%+v %v", group, err)
} }
if _, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeMomentImage, Mime: "image/gif", Size: 2048}); err != nil {
t.Fatalf("moment gif %v", err)
}
if _, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024}); err == nil { if _, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024}); err == nil {
t.Fatal("group avatar without group_id") t.Fatal("group avatar without group_id")
} }

View File

@ -0,0 +1,88 @@
package i18n
func init() {
register(map[string]map[Lang]string{
KeyFieldMomentText: {
ZhCN: "正文", En: "Text", ZhTW: "正文",
},
KeyFieldMomentVisibility: {
ZhCN: "可见范围", En: "Visibility", ZhTW: "可見範圍",
},
KeyFieldMomentAudience: {
ZhCN: "可见名单", En: "Audience", ZhTW: "可見名單",
},
KeyFieldMomentComment: {
ZhCN: "评论", En: "Comment", ZhTW: "評論",
},
KeyFieldMomentReply: {
ZhCN: "回复", En: "Reply", ZhTW: "回覆",
},
KeyFieldMomentCover: {
ZhCN: "封面", En: "Cover", ZhTW: "封面",
},
KeyFieldMomentVisibleDays: {
ZhCN: "允许朋友查看范围", En: "Visible range", ZhTW: "允許朋友查看範圍",
},
KeyFieldMomentID: {
ZhCN: "动态", En: "Moment", ZhTW: "動態",
},
KeyFieldMomentCursor: {
ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
},
KeyMomentTextInvalid: {
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
},
KeyMomentVisibilityInvalid: {
ZhCN: "可见范围无效", En: "Invalid visibility", ZhTW: "可見範圍無效",
},
KeyMomentAudienceInvalid: {
ZhCN: "可见名单无效", En: "Invalid audience", ZhTW: "可見名單無效",
},
KeyMomentContentInvalid: {
ZhCN: "媒体无效", En: "Invalid media", ZhTW: "媒體無效",
},
KeyMomentMediaConflict: {
ZhCN: "图片和视频不能同时发布", En: "Images and video cannot be posted together", ZhTW: "圖片和影片不能同時發布",
},
KeyMomentEmpty: {
ZhCN: "请填写正文或添加媒体", En: "Add text or media", ZhTW: "請填寫正文或新增媒體",
},
KeyMomentCommentRequired: {
ZhCN: "请填写评论", En: "Please enter a comment", ZhTW: "請填寫評論",
},
KeyMomentCommentInvalid: {
ZhCN: "评论无效", En: "Invalid comment", ZhTW: "評論無效",
},
KeyMomentReplyInvalid: {
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
},
KeyMomentCoverInvalid: {
ZhCN: "封面无效", En: "Invalid cover", ZhTW: "封面無效",
},
KeyMomentVisibleDaysInvalid: {
ZhCN: "查看范围无效", En: "Invalid visible range", ZhTW: "查看範圍無效",
},
KeyMomentHideNotFriend: {
ZhCN: "只能对好友设置不看", En: "Hides apply to friends only", ZhTW: "只能對好友設定不看",
},
"enum.moment_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
"enum.moment_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"},
"enum.moment_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"},
"enum.moment_visibility.friends": {ZhCN: "公开好友", En: "Friends", ZhTW: "公開好友"},
"enum.moment_visibility.private": {ZhCN: "私密", En: "Private", ZhTW: "私密"},
"enum.moment_visibility.include": {ZhCN: "部分可见", En: "Include", ZhTW: "部分可見"},
"enum.moment_visibility.exclude": {ZhCN: "不给谁看", En: "Exclude", ZhTW: "不給誰看"},
"enum.moment_status.normal": {ZhCN: "正常", En: "Normal", ZhTW: "正常"},
"enum.moment_status.deleted": {ZhCN: "已删除", En: "Deleted", ZhTW: "已刪除"},
"enum.moment_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"},
"enum.moment_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"},
"enum.moment_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"},
"enum.moment_visible_days.all": {ZhCN: "全部", En: "All", ZhTW: "全部"},
"enum.moment_visible_days.three_days": {ZhCN: "最近三天", En: "Last 3 days", ZhTW: "最近三天"},
"enum.moment_visible_days.month": {ZhCN: "最近一个月", En: "Last month", ZhTW: "最近一個月"},
"enum.moment_visible_days.half_year": {ZhCN: "最近半年", En: "Last 6 months", ZhTW: "最近半年"},
"enum.upload_purpose.moment_image": {ZhCN: "朋友圈图片", En: "Moment image", ZhTW: "朋友圈圖片"},
"enum.upload_purpose.moment_video": {ZhCN: "朋友圈视频", En: "Moment video", ZhTW: "朋友圈影片"},
"enum.upload_purpose.moment_cover": {ZhCN: "朋友圈封面", En: "Moment cover", ZhTW: "朋友圈封面"},
})
}

View File

@ -123,6 +123,28 @@ const (
KeyGroupMentionDenied = "group.mention_denied" KeyGroupMentionDenied = "group.mention_denied"
KeyGroupMentionInvalid = "group.mention_invalid" KeyGroupMentionInvalid = "group.mention_invalid"
KeyFieldMomentText = "field.moment_text"
KeyFieldMomentVisibility = "field.moment_visibility"
KeyFieldMomentAudience = "field.moment_audience"
KeyFieldMomentComment = "field.moment_comment"
KeyFieldMomentReply = "field.moment_reply"
KeyFieldMomentCover = "field.moment_cover"
KeyFieldMomentVisibleDays = "field.moment_visible_days"
KeyFieldMomentID = "field.moment_id"
KeyFieldMomentCursor = "field.moment_cursor"
KeyMomentTextInvalid = "validate.moment.text_invalid"
KeyMomentVisibilityInvalid = "validate.moment.visibility_invalid"
KeyMomentAudienceInvalid = "validate.moment.audience_invalid"
KeyMomentContentInvalid = "validate.moment.content_invalid"
KeyMomentMediaConflict = "validate.moment.media_conflict"
KeyMomentEmpty = "validate.moment.empty"
KeyMomentCommentRequired = "validate.moment.comment_required"
KeyMomentCommentInvalid = "validate.moment.comment_invalid"
KeyMomentReplyInvalid = "validate.moment.reply_invalid"
KeyMomentCoverInvalid = "validate.moment.cover_invalid"
KeyMomentVisibleDaysInvalid = "validate.moment.visible_days_invalid"
KeyMomentHideNotFriend = "validate.moment.hide_not_friend"
NotifyKickReplaced = "notify.kick.replaced" NotifyKickReplaced = "notify.kick.replaced"
NotifyKickLogout = "notify.kick.logout" NotifyKickLogout = "notify.kick.logout"
NotifyFriendRequest = "notify.friend.request" NotifyFriendRequest = "notify.friend.request"

View File

@ -361,6 +361,7 @@ tags:
- name: messages - name: messages
- name: files - name: files
- name: favorites - name: favorites
- name: moments
paths: paths:
/healthz: /healthz:
get: get:
@ -1109,6 +1110,7 @@ paths:
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid); `avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid);
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员); `group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员);
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。 聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。 `{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。 发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。
服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。 服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。
@ -1267,6 +1269,292 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/moments:
get:
tags: [moments]
summary: 好友朋友圈 Feed
description: |
`cursor` 为上一页最后一条 `post_id`,0 表示最新。读时按好友、拉黑、不看他/不让他看、可见范围过滤。
不写会话 inbox。
operationId: listMomentFeed
parameters:
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 50 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
post:
tags: [moments]
summary: 发朋友圈
description: |
纯文,或最多 9 张图,或 1 条视频(图/视频互斥)。媒体先 `POST /v1/files`(`moment_image` / `moment_video`)再填 key。
`visibility`:0 好友公开 1 私密 2 白名单 3 黑名单。2/3 需 `audience_uids`(好友,最多 200)。
operationId: publishMoment
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMomentRequest"
example:
text: hello
visibility: 0
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/unread:
get:
tags: [moments]
summary: 发现页红点
operationId: momentUnread
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/read:
put:
tags: [moments]
summary: 更新发现页已读水位
operationId: momentMarkRead
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MomentReadRequest"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/notices:
get:
tags: [moments]
summary: 赞评通知列表
operationId: listMomentNotices
parameters:
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 50 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/notices/read:
post:
tags: [moments]
summary: 通知标已读
operationId: readMomentNotices
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/MomentNoticeReadRequest"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/settings:
get:
tags: [moments]
summary: 朋友圈设置
operationId: getMomentSettings
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
patch:
tags: [moments]
summary: 改封面或可见天数
operationId: patchMomentSettings
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatchMomentSettingsRequest"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/settings/hides/{uid}:
put:
tags: [moments]
summary: 不看他 / 不让他看
operationId: putMomentHide
parameters:
- $ref: "#/components/parameters/FriendUID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MomentHideRequest"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
delete:
tags: [moments]
summary: 清除对该好友的隐藏
operationId: deleteMomentHide
parameters:
- $ref: "#/components/parameters/FriendUID"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/users/{uid}:
get:
tags: [moments]
summary: 某人朋友圈相册
description: 非好友返回 404。看自己不过可见天数,能看到私密帖。
operationId: listMomentAlbum
parameters:
- $ref: "#/components/parameters/FriendUID"
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 50 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/{id}:
get:
tags: [moments]
summary: 单条动态
operationId: getMoment
parameters:
- $ref: "#/components/parameters/MomentID"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
delete:
tags: [moments]
summary: 删除自己的动态
operationId: deleteMoment
parameters:
- $ref: "#/components/parameters/MomentID"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/{id}/like:
put:
tags: [moments]
summary: 点赞
operationId: likeMoment
parameters:
- $ref: "#/components/parameters/MomentID"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
delete:
tags: [moments]
summary: 取消赞
operationId: unlikeMoment
parameters:
- $ref: "#/components/parameters/MomentID"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/{id}/comments:
post:
tags: [moments]
summary: 评论或回复
operationId: commentMoment
parameters:
- $ref: "#/components/parameters/MomentID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MomentCommentRequest"
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/moments/{id}/comments/{cid}:
delete:
tags: [moments]
summary: 删除评论
operationId: deleteMomentComment
parameters:
- $ref: "#/components/parameters/MomentID"
- name: cid
in: path
required: true
schema: { type: integer, format: int64 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/conversations: /v1/conversations:
get: get:
tags: [conversations] tags: [conversations]
@ -1495,11 +1783,109 @@ components:
description: 缺省 zh-CN description: 缺省 zh-CN
UploadPurpose: UploadPurpose:
type: string type: string
enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file] enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover]
SystemKind: SystemKind:
type: string type: string
enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer]
description: content.system.kind,服务端系统消息 description: content.system.kind,服务端系统消息
MomentKind:
type: integer
enum: [1, 2, 3]
description: moment_posts.kind。1纯文 2图 3视频
MomentVisibility:
type: integer
enum: [0, 1, 2, 3]
description: moment_posts.visibility。0好友公开 1私密 2白名单 3黑名单
MomentStatus:
type: integer
enum: [0, 1]
description: moment_posts.status。0正常 1已删
MomentNoticeKind:
type: integer
enum: [1, 2, 3]
description: moment_notices.kind。1赞 2评论 3回复
MomentVisibleDays:
type: integer
enum: [0, 3, 30, 180]
description: moment_settings.visible_days。0全部 3/30/180 天
PublishMomentRequest:
type: object
properties:
text:
type: string
maxLength: 1000
images:
type: array
maxItems: 9
items:
$ref: "#/components/schemas/MomentImage"
video:
$ref: "#/components/schemas/MomentVideo"
visibility:
$ref: "#/components/schemas/MomentVisibility"
audience_uids:
type: array
maxItems: 200
items:
type: integer
format: int64
poi:
$ref: "#/components/schemas/MomentPOI"
MomentImage:
type: object
required: [key]
properties:
key: { type: string }
mime: { type: string }
w: { type: integer }
h: { type: integer }
size: { type: integer, format: int64 }
thumb_key: { type: string }
orig_key: { type: string }
MomentVideo:
type: object
required: [key, duration_ms]
properties:
key: { type: string }
mime: { type: string }
w: { type: integer }
h: { type: integer }
duration_ms: { type: integer, maximum: 30000 }
size: { type: integer, format: int64 }
cover_key: { type: string }
MomentPOI:
type: object
properties:
lat: { type: number, format: double }
lng: { type: number, format: double }
name: { type: string }
address: { type: string }
MomentReadRequest:
type: object
required: [post_id]
properties:
post_id: { type: integer, format: int64 }
MomentNoticeReadRequest:
type: object
properties:
cursor: { type: integer, format: int64 }
PatchMomentSettingsRequest:
type: object
properties:
cover_key: { type: string }
visible_days:
$ref: "#/components/schemas/MomentVisibleDays"
MomentHideRequest:
type: object
properties:
hide_him: { type: boolean }
hide_me: { type: boolean }
MomentCommentRequest:
type: object
required: [text]
properties:
text: { type: string, maxLength: 500 }
reply_to_id: { type: integer, format: int64 }
TcpCommand: TcpCommand:
type: integer type: integer
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
@ -2085,6 +2471,14 @@ components:
type: integer type: integer
format: int64 format: int64
description: 收藏 Snowflake id。 description: 收藏 Snowflake id。
MomentID:
in: path
name: id
required: true
schema:
type: integer
format: int64
description: 朋友圈 Snowflake post_id。
GroupID: GroupID:
in: path in: path
name: id name: id

View File

@ -0,0 +1,329 @@
package momenthandler
import (
"strconv"
"github.com/gin-gonic/gin"
momentlogic "spark-server/internal/module/moment/logic"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/transport/http/respond"
)
// Handler is the Moments HTTP controller.
type Handler struct {
moments *momentlogic.Service
}
func New(moments *momentlogic.Service) *Handler {
return &Handler{moments: moments}
}
func currentUID(c *gin.Context) (int64, bool) {
rc := reqctx.From(c.Request.Context())
if rc == nil || rc.UserID <= 0 {
respond.JSONError(c, errs.ErrUnauthorized)
return 0, false
}
return rc.UserID, true
}
func pathInt(c *gin.Context, name string) (int64, bool) {
id, err := strconv.ParseInt(c.Param(name), 10, 64)
if err != nil || id <= 0 {
respond.JSONError(c, errs.ErrInvalidArgument)
return 0, false
}
return id, true
}
func queryInt(c *gin.Context, name string) int64 {
n, _ := strconv.ParseInt(c.Query(name), 10, 64)
return n
}
func (h *Handler) Publish(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in momentlogic.PublishInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.moments.Publish(c.Request.Context(), uid, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Feed(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.moments.Feed(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Album(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
owner, ok := pathInt(c, "uid")
if !ok {
return
}
out, err := h.moments.Album(c.Request.Context(), uid, owner, queryInt(c, "cursor"), int(queryInt(c, "limit")))
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Get(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
id, ok := pathInt(c, "id")
if !ok {
return
}
out, err := h.moments.Get(c.Request.Context(), uid, id)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Delete(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
id, ok := pathInt(c, "id")
if !ok {
return
}
if err := h.moments.Delete(c.Request.Context(), uid, id); err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, gin.H{})
}
func (h *Handler) Like(c *gin.Context) {
h.mutatePost(c, func(uid, id int64) (*momentlogic.View, error) {
return h.moments.Like(c.Request.Context(), uid, id)
})
}
func (h *Handler) Unlike(c *gin.Context) {
h.mutatePost(c, func(uid, id int64) (*momentlogic.View, error) {
return h.moments.Unlike(c.Request.Context(), uid, id)
})
}
func (h *Handler) mutatePost(c *gin.Context, fn func(uid, id int64) (*momentlogic.View, error)) {
uid, ok := currentUID(c)
if !ok {
return
}
id, ok := pathInt(c, "id")
if !ok {
return
}
out, err := fn(uid, id)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) CreateComment(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
id, ok := pathInt(c, "id")
if !ok {
return
}
var in momentlogic.CommentInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.moments.Comment(c.Request.Context(), uid, id, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) DeleteComment(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
id, ok := pathInt(c, "id")
if !ok {
return
}
cid, ok := pathInt(c, "cid")
if !ok {
return
}
out, err := h.moments.DeleteComment(c.Request.Context(), uid, id, cid)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) ListNotices(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.moments.ListNotices(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) ReadNotices(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in momentlogic.NoticeReadInput
if c.Request.ContentLength > 0 {
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
}
if err := h.moments.ReadNotices(c.Request.Context(), uid, &in); err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, gin.H{})
}
func (h *Handler) Unread(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.moments.UnreadFeed(c.Request.Context(), uid)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Read(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in momentlogic.ReadInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
if err := h.moments.MarkFeedRead(c.Request.Context(), uid, in.PostID); err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, gin.H{})
}
func (h *Handler) GetSettings(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.moments.GetSettings(c.Request.Context(), uid)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) PatchSettings(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in momentlogic.PatchSettingsInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.moments.PatchSettings(c.Request.Context(), uid, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) PutHide(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
peer, ok := pathInt(c, "uid")
if !ok {
return
}
var in momentlogic.HideInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.moments.PutHide(c.Request.Context(), uid, peer, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) DeleteHide(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
peer, ok := pathInt(c, "uid")
if !ok {
return
}
out, err := h.moments.DeleteHide(c.Request.Context(), uid, peer)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}

View File

@ -0,0 +1,92 @@
package httpx
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"go.uber.org/zap"
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
delivermodel "spark-server/internal/module/deliver/model"
friendlogic "spark-server/internal/module/friend/logic"
friendmodel "spark-server/internal/module/friend/model"
msgmodel "spark-server/internal/module/message/model"
momentlogic "spark-server/internal/module/moment/logic"
momentmodel "spark-server/internal/module/moment/model"
uploadlogic "spark-server/internal/module/upload/logic"
userlogic "spark-server/internal/module/user/logic"
usermodel "spark-server/internal/module/user/model"
"spark-server/internal/pkg/errs"
)
func testMomentEngine(t *testing.T) *ginEngineBundle {
t.Helper()
j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
if err != nil {
t.Fatal(err)
}
ids, err := id.New(1)
if err != nil {
t.Fatal(err)
}
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
friends := friendlogic.NewService(
friendmodel.NewMemoryRepo(), users, ids,
friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
)
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{})
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Login: authlogic.NewLoginService(j, users), Users: users,
Friends: friends, Moments: moments, Files: files,
})
return &ginEngineBundle{engine: r}
}
func TestMomentPublishFeedLikeHTTP(t *testing.T) {
h := testMomentEngine(t).engine
a := loginUser(t, h, "test01@spark.com", 1)
b := loginUser(t, h, "test02@spark.com", 2)
apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com"}`)
if apply.Code != "0" {
t.Fatalf("apply %+v", apply)
}
var applied friendlogic.ApplyResult
if err := json.Unmarshal(apply.Data, &applied); err != nil {
t.Fatal(err)
}
acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d}`, applied.RequestID))
if acc.Code != "0" {
t.Fatalf("accept %+v", acc)
}
pub := authJSON(t, h, &a, http.MethodPost, "/v1/moments", `{"text":"hello moments"}`)
if pub.Code != "0" {
t.Fatalf("publish %+v", pub)
}
var post momentlogic.View
if err := json.Unmarshal(pub.Data, &post); err != nil || post.Text != "hello moments" || post.ID == 0 {
t.Fatalf("post %s %v", pub.Data, err)
}
feed := authJSON(t, h, &b, http.MethodGet, "/v1/moments", "")
if feed.Code != "0" {
t.Fatalf("feed %+v", feed)
}
like := authJSON(t, h, &b, http.MethodPut, fmt.Sprintf("/v1/moments/%d/like", post.ID), "")
if like.Code != "0" {
t.Fatalf("like %+v", like)
}
cred := authJSON(t, h, &a, http.MethodPost, "/v1/files", `{"purpose":"moment_image","mime":"image/jpeg","size":1024}`)
if cred.Code != "0" {
t.Fatalf("cred %+v", cred)
}
stranger := loginUser(t, h, "test03@spark.com", 3)
album := authJSON(t, h, &stranger, http.MethodGet, fmt.Sprintf("/v1/moments/users/%d", a.UserID), "")
if album.Code != errs.ErrNotFound.Code {
t.Fatalf("stranger album %+v", album)
}
}

View File

@ -17,6 +17,7 @@ import (
grouphandler "spark-server/internal/transport/http/handler/group" grouphandler "spark-server/internal/transport/http/handler/group"
msghandler "spark-server/internal/transport/http/handler/message" msghandler "spark-server/internal/transport/http/handler/message"
metahandler "spark-server/internal/transport/http/handler/meta" metahandler "spark-server/internal/transport/http/handler/meta"
momenthandler "spark-server/internal/transport/http/handler/moment"
userhandler "spark-server/internal/transport/http/handler/user" userhandler "spark-server/internal/transport/http/handler/user"
"spark-server/internal/transport/http/respond" "spark-server/internal/transport/http/respond"
) )
@ -32,6 +33,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
registerGroupRoutes(v1, deps) registerGroupRoutes(v1, deps)
registerFileRoutes(v1, deps) registerFileRoutes(v1, deps)
registerFavoriteRoutes(v1, deps) registerFavoriteRoutes(v1, deps)
registerMomentRoutes(v1, deps)
registerConversationRoutes(v1, deps) registerConversationRoutes(v1, deps)
registerMessageRoutes(v1, deps) registerMessageRoutes(v1, deps)
} }
@ -147,6 +149,31 @@ func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g.DELETE("/:id", h.Delete) g.DELETE("/:id", h.Delete)
} }
func registerMomentRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
if deps.Moments == nil {
return
}
h := momenthandler.New(deps.Moments)
g := v1.Group("/moments")
g.GET("", h.Feed)
g.POST("", h.Publish)
g.GET("/unread", h.Unread)
g.PUT("/read", h.Read)
g.GET("/notices", h.ListNotices)
g.POST("/notices/read", h.ReadNotices)
g.GET("/settings", h.GetSettings)
g.PATCH("/settings", h.PatchSettings)
g.PUT("/settings/hides/:uid", h.PutHide)
g.DELETE("/settings/hides/:uid", h.DeleteHide)
g.GET("/users/:uid", h.Album)
g.GET("/:id", h.Get)
g.DELETE("/:id", h.Delete)
g.PUT("/:id/like", h.Like)
g.DELETE("/:id/like", h.Unlike)
g.POST("/:id/comments", h.CreateComment)
g.DELETE("/:id/comments/:cid", h.DeleteComment)
}
func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
if deps.Sync == nil { if deps.Sync == nil {
return return

View File

@ -17,6 +17,7 @@ import (
friendlogic "spark-server/internal/module/friend/logic" friendlogic "spark-server/internal/module/friend/logic"
grouplogic "spark-server/internal/module/group/logic" grouplogic "spark-server/internal/module/group/logic"
msglogic "spark-server/internal/module/message/logic" msglogic "spark-server/internal/module/message/logic"
momentlogic "spark-server/internal/module/moment/logic"
synclogic "spark-server/internal/module/sync/logic" synclogic "spark-server/internal/module/sync/logic"
uploadlogic "spark-server/internal/module/upload/logic" uploadlogic "spark-server/internal/module/upload/logic"
userlogic "spark-server/internal/module/user/logic" userlogic "spark-server/internal/module/user/logic"
@ -45,6 +46,7 @@ type EngineDeps struct {
Groups *grouplogic.Service Groups *grouplogic.Service
Files *uploadlogic.Service Files *uploadlogic.Service
Favorites *favlogic.Service Favorites *favlogic.Service
Moments *momentlogic.Service
Messages *msglogic.Service Messages *msglogic.Service
Sync *synclogic.Service Sync *synclogic.Service
Health Health Health Health

View File

@ -0,0 +1,72 @@
-- 朋友圈。独立于 messages;Snowflake post_id 是主键、排序和游标。媒体在 perm/moment/,不过期,删帖时删对象。
CREATE TABLE IF NOT EXISTS moment_posts (
id BIGINT PRIMARY KEY COMMENT 'Snowflake 帖 id,Feed/相册游标',
user_id BIGINT NOT NULL COMMENT '作者 users.id',
kind SMALLINT NOT NULL COMMENT '1纯文 2图 3视频',
text VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '正文,可空;与媒体至少有一项',
content BLOB NOT NULL COMMENT '图/视频 JSON 字节,不存媒体二进制',
poi_lat DOUBLE NOT NULL DEFAULT 0 COMMENT '地点纬度,无地点为 0',
poi_lng DOUBLE NOT NULL DEFAULT 0 COMMENT '地点经度,无地点为 0',
poi_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '地点名称',
poi_address VARCHAR(256) NOT NULL DEFAULT '' COMMENT '地点地址',
visibility SMALLINT NOT NULL DEFAULT 0 COMMENT '0好友公开 1私密 2白名单 3黑名单',
status SMALLINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删',
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数,与 moment_likes 同事务维护',
comment_count INT NOT NULL DEFAULT 0 COMMENT '未删评论数,与 moment_comments 同事务维护',
created_at DATETIME(3) NOT NULL COMMENT '发帖时间,visible_days 按此裁剪',
KEY idx_user_id (user_id, id)
) COMMENT='朋友圈帖。读时按好友+可见性过滤,不写每好友 inbox';
CREATE TABLE IF NOT EXISTS moment_audience (
post_id BIGINT NOT NULL COMMENT '帖 id',
peer_id BIGINT NOT NULL COMMENT '白名单能看 / 黑名单不能看 的好友 uid',
PRIMARY KEY (post_id, peer_id)
) COMMENT='部分可见名单。仅 visibility=2/3 有行';
CREATE TABLE IF NOT EXISTS moment_likes (
post_id BIGINT NOT NULL COMMENT '帖 id',
user_id BIGINT NOT NULL COMMENT '点赞人',
created_at DATETIME(3) NOT NULL COMMENT '点赞时间',
PRIMARY KEY (post_id, user_id)
) COMMENT='朋友圈点赞。重复 PUT 幂等';
CREATE TABLE IF NOT EXISTS moment_comments (
id BIGINT PRIMARY KEY COMMENT 'Snowflake 评论 id',
post_id BIGINT NOT NULL COMMENT '帖 id',
user_id BIGINT NOT NULL COMMENT '评论人',
reply_to_id BIGINT NOT NULL DEFAULT 0 COMMENT '父评论 id,0 表示直接评帖',
reply_to_uid BIGINT NOT NULL DEFAULT 0 COMMENT '被回复用户,0 表示直接评帖',
text VARCHAR(500) NOT NULL COMMENT '评论文本',
status SMALLINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删',
created_at DATETIME(3) NOT NULL COMMENT '评论时间',
KEY idx_post (post_id, id)
) COMMENT='朋友圈评论。展示按共同好友过滤';
CREATE TABLE IF NOT EXISTS moment_notices (
id BIGINT PRIMARY KEY COMMENT 'Snowflake 通知 id,列表游标',
to_user_id BIGINT NOT NULL COMMENT '接收人',
from_user_id BIGINT NOT NULL COMMENT '动作发起人',
kind SMALLINT NOT NULL COMMENT '1赞 2评论 3回复',
post_id BIGINT NOT NULL COMMENT '帖 id',
comment_id BIGINT NOT NULL DEFAULT 0 COMMENT '评论 id,赞为 0',
read_at DATETIME(3) NULL COMMENT '已读时间,空表示未读',
created_at DATETIME(3) NOT NULL COMMENT '通知时间',
KEY idx_to_user (to_user_id, id)
) COMMENT='朋友圈赞评通知。不进会话 mailbox';
CREATE TABLE IF NOT EXISTS moment_settings (
user_id BIGINT PRIMARY KEY COMMENT '用户 users.id',
cover_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 object_key,空表示未设',
visible_days SMALLINT NOT NULL DEFAULT 0 COMMENT '允许朋友查看范围:0全部 3/30/180 天',
last_read_post_id BIGINT NOT NULL DEFAULT 0 COMMENT '发现页红点水位,只增不减',
updated_at DATETIME(3) NOT NULL COMMENT '最近更新时间'
) COMMENT='朋友圈个人设置。无行则封面空、范围全部、水位 0';
CREATE TABLE IF NOT EXISTS moment_hides (
user_id BIGINT NOT NULL COMMENT '设置方',
peer_id BIGINT NOT NULL COMMENT '对方',
hide_him TINYINT NOT NULL DEFAULT 0 COMMENT '1=不看他(Feed 去掉对方)',
hide_me TINYINT NOT NULL DEFAULT 0 COMMENT '1=不让他看',
PRIMARY KEY (user_id, peer_id)
) COMMENT='不看他 / 不让他看。与拉黑分开';