feat: moment
This commit is contained in:
parent
c11fbda141
commit
0c821ffa16
@ -78,7 +78,12 @@ Do not convert these to integers.
|
||||
|----------------|----|------|--------|
|
||||
| `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`) |
|
||||
| `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` |
|
||||
|
||||
Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum.
|
||||
|
||||
@ -86,6 +86,8 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
|
||||
| conversations | `/v1/conversations` | list, mute |
|
||||
| messages | `/v1/messages` | history (sync still prefers TCP) |
|
||||
| files | `/v1/files` | upload credential |
|
||||
| favorites | `/v1/favorites` | 收藏聊天消息 |
|
||||
| moments | `/v1/moments` | 朋友圈 Feed、相册、赞评、设置 |
|
||||
| meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) |
|
||||
|
||||
Rules:
|
||||
|
||||
@ -4,6 +4,7 @@ Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程
|
||||
|
||||
仓库:<https://gitea.wyrs365.com/Spark/spark-server.git>
|
||||
完整设计:[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`。
|
||||
|
||||
不上 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/files` | 上传/下载凭证、批量、图片派生 |
|
||||
| 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` |
|
||||
| 朋友圈 | `/v1/moments` | Feed、相册、赞评、通知、封面/不看他 |
|
||||
| 会话 | `/v1/conversations` | 列表、免打扰 |
|
||||
| 消息 | `/v1/messages` | 历史 / 撤回 / 删除(主路径仍是 TCP) |
|
||||
|
||||
@ -207,8 +209,11 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
|
||||
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||||
| `chat_gif` | `chat/gif/{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。
|
||||
|
||||
|
||||
517
docs/moments.md
Normal file
517
docs/moments.md
Normal 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`,产品要时再开。
|
||||
@ -35,6 +35,8 @@ import (
|
||||
inboxmodel "spark-server/internal/module/inbox/model"
|
||||
msglogic "spark-server/internal/module/message/logic"
|
||||
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"
|
||||
syncmodel "spark-server/internal/module/sync/model"
|
||||
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)
|
||||
files := wireFiles(cfg, objects, groups)
|
||||
favorites := wireFavorites(idgen, db, objects, chat)
|
||||
moments := wireMoments(idgen, db, objects, friends, users)
|
||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||
Addr: cfg.Server.TCP.Addr,
|
||||
Multicore: cfg.Server.TCP.Multicore,
|
||||
@ -196,6 +199,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
Groups: groups,
|
||||
Files: files,
|
||||
Favorites: favorites,
|
||||
Moments: moments,
|
||||
Messages: messages,
|
||||
Sync: syncSvc,
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
var inbox inboxlogic.Store
|
||||
if rdb != nil && rdb.Ready() {
|
||||
|
||||
@ -28,6 +28,7 @@ func (s EdgeStatus) Valid() bool {
|
||||
const (
|
||||
ListDefault = 20
|
||||
ListMax = 100
|
||||
FriendCap = 5000
|
||||
)
|
||||
|
||||
// Relation is one directed edge: UserID's view of FriendID.
|
||||
|
||||
@ -4,9 +4,38 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"spark-server/internal/module/friend/entity"
|
||||
"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) {
|
||||
if !s.ready() || a <= 0 || b <= 0 || a == b {
|
||||
return false, nil
|
||||
|
||||
@ -12,6 +12,8 @@ import (
|
||||
type Repository interface {
|
||||
GetRelation(ctx context.Context, userID, friendID int64) (*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)
|
||||
GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error)
|
||||
UpsertRequest(ctx context.Context, req *entity.Request) error
|
||||
|
||||
@ -58,6 +58,49 @@ func (m *Repo) GetRelation(ctx context.Context, userID, friendID int64) (*entity
|
||||
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) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
|
||||
@ -48,6 +48,44 @@ func (m *MemoryRepo) GetRelation(_ context.Context, userID, friendID int64) (*en
|
||||
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) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@ -29,4 +29,9 @@ type Catalog struct {
|
||||
Language []Item `json:"language"`
|
||||
UploadPurpose []Item `json:"upload_purpose"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -30,6 +30,11 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
||||
Language: language(lang),
|
||||
UploadPurpose: uploadPurpose(lang),
|
||||
SystemKind: systemKind(lang),
|
||||
MomentKind: momentKind(lang),
|
||||
MomentVisibility: momentVisibility(lang),
|
||||
MomentStatus: momentStatus(lang),
|
||||
MomentNoticeKind: momentNoticeKind(lang),
|
||||
MomentVisibleDays: momentVisibleDays(lang),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
internal/module/meta/logic/enums_moment.go
Normal file
48
internal/module/meta/logic/enums_moment.go
Normal 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)),
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,12 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
||||
if c.EnvelopeCode[0].Value != "0" || c.Language[0].Value != "zh-CN" {
|
||||
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)
|
||||
if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" {
|
||||
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.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.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
||||
} {
|
||||
if len(items) == 0 {
|
||||
t.Fatal("empty set")
|
||||
|
||||
@ -71,6 +71,7 @@ func uploadPurpose(lang i18n.Lang) []entity.Item {
|
||||
return strSet(lang, "upload_purpose",
|
||||
uploadentity.PurposeAvatar, uploadentity.PurposeGroupAvatar, uploadentity.PurposeChatImage,
|
||||
uploadentity.PurposeChatVoice, uploadentity.PurposeChatVideo, uploadentity.PurposeChatGIF, uploadentity.PurposeChatFile,
|
||||
uploadentity.PurposeMomentImage, uploadentity.PurposeMomentVideo, uploadentity.PurposeMomentCover,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
163
internal/module/moment/entity/content.go
Normal file
163
internal/module/moment/entity/content.go
Normal 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)
|
||||
}
|
||||
92
internal/module/moment/entity/kind.go
Normal file
92
internal/module/moment/entity/kind.go
Normal 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
|
||||
}
|
||||
80
internal/module/moment/entity/post.go
Normal file
80
internal/module/moment/entity/post.go
Normal 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}
|
||||
}
|
||||
63
internal/module/moment/logic/album.go
Normal file
63
internal/module/moment/logic/album.go
Normal 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)
|
||||
}
|
||||
170
internal/module/moment/logic/attach.go
Normal file
170
internal/module/moment/logic/attach.go
Normal 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
|
||||
}
|
||||
117
internal/module/moment/logic/comment.go
Normal file
117
internal/module/moment/logic/comment.go
Normal 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
|
||||
}
|
||||
36
internal/module/moment/logic/delete.go
Normal file
36
internal/module/moment/logic/delete.go
Normal 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)
|
||||
}
|
||||
}
|
||||
86
internal/module/moment/logic/feed.go
Normal file
86
internal/module/moment/logic/feed.go
Normal 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
|
||||
}
|
||||
59
internal/module/moment/logic/like.go
Normal file
59
internal/module/moment/logic/like.go
Normal 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
|
||||
}
|
||||
44
internal/module/moment/logic/limit.go
Normal file
44
internal/module/moment/logic/limit.go
Normal 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
|
||||
}
|
||||
183
internal/module/moment/logic/moment_test.go
Normal file
183
internal/module/moment/logic/moment_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
132
internal/module/moment/logic/notice.go
Normal file
132
internal/module/moment/logic/notice.go
Normal 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}
|
||||
}
|
||||
141
internal/module/moment/logic/publish.go
Normal file
141
internal/module/moment/logic/publish.go
Normal 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
|
||||
}
|
||||
80
internal/module/moment/logic/service.go
Normal file
80
internal/module/moment/logic/service.go
Normal 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
|
||||
}
|
||||
141
internal/module/moment/logic/settings.go
Normal file
141
internal/module/moment/logic/settings.go
Normal 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)
|
||||
}
|
||||
103
internal/module/moment/logic/view.go
Normal file
103
internal/module/moment/logic/view.go
Normal 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)
|
||||
}
|
||||
152
internal/module/moment/logic/visible.go
Normal file
152
internal/module/moment/logic/visible.go
Normal 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
|
||||
}
|
||||
247
internal/module/moment/model/memory.go
Normal file
247
internal/module/moment/model/memory.go
Normal 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
|
||||
}
|
||||
186
internal/module/moment/model/memory_write.go
Normal file
186
internal/module/moment/model/memory_write.go
Normal 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
|
||||
}
|
||||
214
internal/module/moment/model/query.go
Normal file
214
internal/module/moment/model/query.go
Normal 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
|
||||
}
|
||||
41
internal/module/moment/model/repo.go
Normal file
41
internal/module/moment/model/repo.go
Normal 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)
|
||||
}
|
||||
196
internal/module/moment/model/row.go
Normal file
196
internal/module/moment/model/row.go
Normal 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}
|
||||
}
|
||||
256
internal/module/moment/model/write.go
Normal file
256
internal/module/moment/model/write.go
Normal 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)
|
||||
}
|
||||
@ -15,17 +15,24 @@ const (
|
||||
PurposeChatVideo = "chat_video"
|
||||
PurposeChatGIF = "chat_gif"
|
||||
PurposeChatFile = "chat_file"
|
||||
PurposeMomentImage = "moment_image"
|
||||
PurposeMomentVideo = "moment_video"
|
||||
PurposeMomentCover = "moment_cover"
|
||||
|
||||
PrefixPerm = "perm/"
|
||||
PrefixChat = "chat/"
|
||||
PrefixAvatar = "perm/avatar/"
|
||||
PrefixGroup = "perm/group/"
|
||||
PrefixFav = "perm/fav/"
|
||||
PrefixChatImage = "chat/image/"
|
||||
PrefixChatVoice = "chat/voice/"
|
||||
PrefixChatVideo = "chat/video/"
|
||||
PrefixChatGIF = "chat/gif/"
|
||||
PrefixChatFile = "chat/file/"
|
||||
PrefixPerm = "perm/"
|
||||
PrefixChat = "chat/"
|
||||
PrefixAvatar = "perm/avatar/"
|
||||
PrefixGroup = "perm/group/"
|
||||
PrefixFav = "perm/fav/"
|
||||
PrefixChatImage = "chat/image/"
|
||||
PrefixChatVoice = "chat/voice/"
|
||||
PrefixChatVideo = "chat/video/"
|
||||
PrefixChatGIF = "chat/gif/"
|
||||
PrefixChatFile = "chat/file/"
|
||||
PrefixMoment = "perm/moment/"
|
||||
PrefixMomentImage = "perm/moment/image/"
|
||||
PrefixMomentVideo = "perm/moment/video/"
|
||||
PrefixMomentCover = "perm/moment/cover/"
|
||||
|
||||
MethodPUT = "PUT"
|
||||
MethodGET = "GET"
|
||||
@ -34,6 +41,7 @@ const (
|
||||
const (
|
||||
maxAvatar = 2 << 20
|
||||
maxImage = 32 << 20
|
||||
maxMoment = 10 << 20
|
||||
maxGIF = 10 << 20
|
||||
maxVoice = 16 << 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)
|
||||
case PurposeGroupAvatar:
|
||||
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:
|
||||
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{
|
||||
PurposeAvatar: {Purpose: PurposeAvatar, 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},
|
||||
PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs},
|
||||
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 momentImageMIMEs = set("image/jpeg", "image/jpg", "image/png", "image/gif")
|
||||
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 videoMIMEs = set("video/mp4", "video/quicktime", "video/webm")
|
||||
@ -156,7 +174,7 @@ var fileMIMEs = set(
|
||||
|
||||
var mimeExt = map[string]string{
|
||||
"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/ogg": "ogg", "audio/wav": "wav", "audio/webm": "weba", "audio/x-m4a": "m4a",
|
||||
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
|
||||
|
||||
@ -26,6 +26,14 @@ func TestBuildObjectKeyByPurpose(t *testing.T) {
|
||||
if fav != "perm/fav/20260917/99.m4a" {
|
||||
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) {
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
|
||||
// CreateInput requests a single-object Presigned PUT. The client does not choose the key.
|
||||
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"`
|
||||
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"`
|
||||
|
||||
@ -62,6 +62,9 @@ func TestCreateCredentialChatVsAvatar(t *testing.T) {
|
||||
if err != nil || !strings.HasPrefix(group.ObjectKey, "perm/group/7/") {
|
||||
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 {
|
||||
t.Fatal("group avatar without group_id")
|
||||
}
|
||||
|
||||
88
internal/pkg/i18n/catalog_validate_moment.go
Normal file
88
internal/pkg/i18n/catalog_validate_moment.go
Normal 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: "朋友圈封面"},
|
||||
})
|
||||
}
|
||||
@ -123,6 +123,28 @@ const (
|
||||
KeyGroupMentionDenied = "group.mention_denied"
|
||||
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"
|
||||
NotifyKickLogout = "notify.kick.logout"
|
||||
NotifyFriendRequest = "notify.friend.request"
|
||||
|
||||
@ -361,6 +361,7 @@ tags:
|
||||
- name: messages
|
||||
- name: files
|
||||
- name: favorites
|
||||
- name: moments
|
||||
paths:
|
||||
/healthz:
|
||||
get:
|
||||
@ -1109,6 +1110,7 @@ paths:
|
||||
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid);
|
||||
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员);
|
||||
聊天按类型:`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。
|
||||
发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。
|
||||
服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。
|
||||
@ -1267,6 +1269,292 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
tags: [conversations]
|
||||
@ -1495,11 +1783,109 @@ components:
|
||||
description: 缺省 zh-CN
|
||||
UploadPurpose:
|
||||
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:
|
||||
type: string
|
||||
enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer]
|
||||
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:
|
||||
type: integer
|
||||
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
@ -2085,6 +2471,14 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 收藏 Snowflake id。
|
||||
MomentID:
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 朋友圈 Snowflake post_id。
|
||||
GroupID:
|
||||
in: path
|
||||
name: id
|
||||
|
||||
329
internal/transport/http/handler/moment/handler.go
Normal file
329
internal/transport/http/handler/moment/handler.go
Normal 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)
|
||||
}
|
||||
92
internal/transport/http/moment_test.go
Normal file
92
internal/transport/http/moment_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import (
|
||||
grouphandler "spark-server/internal/transport/http/handler/group"
|
||||
msghandler "spark-server/internal/transport/http/handler/message"
|
||||
metahandler "spark-server/internal/transport/http/handler/meta"
|
||||
momenthandler "spark-server/internal/transport/http/handler/moment"
|
||||
userhandler "spark-server/internal/transport/http/handler/user"
|
||||
"spark-server/internal/transport/http/respond"
|
||||
)
|
||||
@ -32,6 +33,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
|
||||
registerGroupRoutes(v1, deps)
|
||||
registerFileRoutes(v1, deps)
|
||||
registerFavoriteRoutes(v1, deps)
|
||||
registerMomentRoutes(v1, deps)
|
||||
registerConversationRoutes(v1, deps)
|
||||
registerMessageRoutes(v1, deps)
|
||||
}
|
||||
@ -147,6 +149,31 @@ func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||
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) {
|
||||
if deps.Sync == nil {
|
||||
return
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
friendlogic "spark-server/internal/module/friend/logic"
|
||||
grouplogic "spark-server/internal/module/group/logic"
|
||||
msglogic "spark-server/internal/module/message/logic"
|
||||
momentlogic "spark-server/internal/module/moment/logic"
|
||||
synclogic "spark-server/internal/module/sync/logic"
|
||||
uploadlogic "spark-server/internal/module/upload/logic"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
@ -45,6 +46,7 @@ type EngineDeps struct {
|
||||
Groups *grouplogic.Service
|
||||
Files *uploadlogic.Service
|
||||
Favorites *favlogic.Service
|
||||
Moments *momentlogic.Service
|
||||
Messages *msglogic.Service
|
||||
Sync *synclogic.Service
|
||||
Health Health
|
||||
|
||||
72
migrations/0015_moments.sql
Normal file
72
migrations/0015_moments.sql
Normal 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='不看他 / 不让他看。与拉黑分开';
|
||||
Loading…
Reference in New Issue
Block a user