diff --git a/.cursor/skills/spark-im-v3/enums.md b/.cursor/skills/spark-im-v3/enums.md index 999edea..b31647f 100644 --- a/.cursor/skills/spark-im-v3/enums.md +++ b/.cursor/skills/spark-im-v3/enums.md @@ -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. diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index 4ad78e0..92038a6 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -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: diff --git a/README.md b/README.md index b4712b1..3f32feb 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程 仓库: 完整设计:[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。 diff --git a/docs/moments.md b/docs/moments.md new file mode 100644 index 0000000..a1f6f0b --- /dev/null +++ b/docs/moments.md @@ -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`,产品要时再开。 diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index b110fdf..94085b5 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -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() { diff --git a/internal/module/friend/entity/relation.go b/internal/module/friend/entity/relation.go index 86d3f45..66d9e35 100644 --- a/internal/module/friend/entity/relation.go +++ b/internal/module/friend/entity/relation.go @@ -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. diff --git a/internal/module/friend/logic/friends.go b/internal/module/friend/logic/friends.go index f49c50d..3b695ad 100644 --- a/internal/module/friend/logic/friends.go +++ b/internal/module/friend/logic/friends.go @@ -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 diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go index db59c4b..2e1c3f1 100644 --- a/internal/module/friend/logic/service.go +++ b/internal/module/friend/logic/service.go @@ -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 diff --git a/internal/module/friend/model/friend_relation.go b/internal/module/friend/model/friend_relation.go index 8292a60..207f38d 100644 --- a/internal/module/friend/model/friend_relation.go +++ b/internal/module/friend/model/friend_relation.go @@ -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 { diff --git a/internal/module/friend/model/memory.go b/internal/module/friend/model/memory.go index 4ab8dd8..ccf26d2 100644 --- a/internal/module/friend/model/memory.go +++ b/internal/module/friend/model/memory.go @@ -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() diff --git a/internal/module/meta/entity/enum.go b/internal/module/meta/entity/enum.go index a3851ec..afa4c52 100644 --- a/internal/module/meta/entity/enum.go +++ b/internal/module/meta/entity/enum.go @@ -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"` } diff --git a/internal/module/meta/logic/enums.go b/internal/module/meta/logic/enums.go index fcc6493..a1d4283 100644 --- a/internal/module/meta/logic/enums.go +++ b/internal/module/meta/logic/enums.go @@ -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), } } diff --git a/internal/module/meta/logic/enums_moment.go b/internal/module/meta/logic/enums_moment.go new file mode 100644 index 0000000..8c9eeea --- /dev/null +++ b/internal/module/meta/logic/enums_moment.go @@ -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)), + } +} diff --git a/internal/module/meta/logic/enums_test.go b/internal/module/meta/logic/enums_test.go index 8a58089..a86191f 100644 --- a/internal/module/meta/logic/enums_test.go +++ b/internal/module/meta/logic/enums_test.go @@ -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") diff --git a/internal/module/meta/logic/enums_wire.go b/internal/module/meta/logic/enums_wire.go index 5c3bd89..81bf0a0 100644 --- a/internal/module/meta/logic/enums_wire.go +++ b/internal/module/meta/logic/enums_wire.go @@ -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, ) } diff --git a/internal/module/moment/entity/content.go b/internal/module/moment/entity/content.go new file mode 100644 index 0000000..ec264e9 --- /dev/null +++ b/internal/module/moment/entity/content.go @@ -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) +} diff --git a/internal/module/moment/entity/kind.go b/internal/module/moment/entity/kind.go new file mode 100644 index 0000000..913a606 --- /dev/null +++ b/internal/module/moment/entity/kind.go @@ -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 +} diff --git a/internal/module/moment/entity/post.go b/internal/module/moment/entity/post.go new file mode 100644 index 0000000..0ab9e74 --- /dev/null +++ b/internal/module/moment/entity/post.go @@ -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} +} diff --git a/internal/module/moment/logic/album.go b/internal/module/moment/logic/album.go new file mode 100644 index 0000000..c669485 --- /dev/null +++ b/internal/module/moment/logic/album.go @@ -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) +} diff --git a/internal/module/moment/logic/attach.go b/internal/module/moment/logic/attach.go new file mode 100644 index 0000000..8371052 --- /dev/null +++ b/internal/module/moment/logic/attach.go @@ -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 +} diff --git a/internal/module/moment/logic/comment.go b/internal/module/moment/logic/comment.go new file mode 100644 index 0000000..ad177c9 --- /dev/null +++ b/internal/module/moment/logic/comment.go @@ -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 +} diff --git a/internal/module/moment/logic/delete.go b/internal/module/moment/logic/delete.go new file mode 100644 index 0000000..478230e --- /dev/null +++ b/internal/module/moment/logic/delete.go @@ -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) + } +} diff --git a/internal/module/moment/logic/feed.go b/internal/module/moment/logic/feed.go new file mode 100644 index 0000000..841b132 --- /dev/null +++ b/internal/module/moment/logic/feed.go @@ -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 +} diff --git a/internal/module/moment/logic/like.go b/internal/module/moment/logic/like.go new file mode 100644 index 0000000..04d65f4 --- /dev/null +++ b/internal/module/moment/logic/like.go @@ -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 +} diff --git a/internal/module/moment/logic/limit.go b/internal/module/moment/logic/limit.go new file mode 100644 index 0000000..8db6118 --- /dev/null +++ b/internal/module/moment/logic/limit.go @@ -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 +} diff --git a/internal/module/moment/logic/moment_test.go b/internal/module/moment/logic/moment_test.go new file mode 100644 index 0000000..4ea1a0e --- /dev/null +++ b/internal/module/moment/logic/moment_test.go @@ -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) + } +} diff --git a/internal/module/moment/logic/notice.go b/internal/module/moment/logic/notice.go new file mode 100644 index 0000000..4a87315 --- /dev/null +++ b/internal/module/moment/logic/notice.go @@ -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} +} diff --git a/internal/module/moment/logic/publish.go b/internal/module/moment/logic/publish.go new file mode 100644 index 0000000..8334d32 --- /dev/null +++ b/internal/module/moment/logic/publish.go @@ -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 +} diff --git a/internal/module/moment/logic/service.go b/internal/module/moment/logic/service.go new file mode 100644 index 0000000..1453d80 --- /dev/null +++ b/internal/module/moment/logic/service.go @@ -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 +} diff --git a/internal/module/moment/logic/settings.go b/internal/module/moment/logic/settings.go new file mode 100644 index 0000000..cc0b394 --- /dev/null +++ b/internal/module/moment/logic/settings.go @@ -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) +} diff --git a/internal/module/moment/logic/view.go b/internal/module/moment/logic/view.go new file mode 100644 index 0000000..e41110e --- /dev/null +++ b/internal/module/moment/logic/view.go @@ -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) +} diff --git a/internal/module/moment/logic/visible.go b/internal/module/moment/logic/visible.go new file mode 100644 index 0000000..47f67de --- /dev/null +++ b/internal/module/moment/logic/visible.go @@ -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 +} diff --git a/internal/module/moment/model/memory.go b/internal/module/moment/model/memory.go new file mode 100644 index 0000000..90c2728 --- /dev/null +++ b/internal/module/moment/model/memory.go @@ -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 +} diff --git a/internal/module/moment/model/memory_write.go b/internal/module/moment/model/memory_write.go new file mode 100644 index 0000000..78f67d7 --- /dev/null +++ b/internal/module/moment/model/memory_write.go @@ -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 +} diff --git a/internal/module/moment/model/query.go b/internal/module/moment/model/query.go new file mode 100644 index 0000000..ad62ea0 --- /dev/null +++ b/internal/module/moment/model/query.go @@ -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 +} diff --git a/internal/module/moment/model/repo.go b/internal/module/moment/model/repo.go new file mode 100644 index 0000000..0b317a8 --- /dev/null +++ b/internal/module/moment/model/repo.go @@ -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) +} diff --git a/internal/module/moment/model/row.go b/internal/module/moment/model/row.go new file mode 100644 index 0000000..fd59374 --- /dev/null +++ b/internal/module/moment/model/row.go @@ -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} +} diff --git a/internal/module/moment/model/write.go b/internal/module/moment/model/write.go new file mode 100644 index 0000000..f9ba8c1 --- /dev/null +++ b/internal/module/moment/model/write.go @@ -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) +} diff --git a/internal/module/upload/entity/purpose.go b/internal/module/upload/entity/purpose.go index a86c511..4704396 100644 --- a/internal/module/upload/entity/purpose.go +++ b/internal/module/upload/entity/purpose.go @@ -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", diff --git a/internal/module/upload/entity/purpose_test.go b/internal/module/upload/entity/purpose_test.go index 935b635..8f1fb47 100644 --- a/internal/module/upload/entity/purpose_test.go +++ b/internal/module/upload/entity/purpose_test.go @@ -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) { diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go index 6ea7ed7..df816ea 100644 --- a/internal/module/upload/logic/credential.go +++ b/internal/module/upload/logic/credential.go @@ -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"` diff --git a/internal/module/upload/logic/credential_test.go b/internal/module/upload/logic/credential_test.go index 62a24c7..f082e19 100644 --- a/internal/module/upload/logic/credential_test.go +++ b/internal/module/upload/logic/credential_test.go @@ -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") } diff --git a/internal/pkg/i18n/catalog_validate_moment.go b/internal/pkg/i18n/catalog_validate_moment.go new file mode 100644 index 0000000..889225d --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_moment.go @@ -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: "朋友圈封面"}, + }) +} diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index 2d7e935..0982fd4 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -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" diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index e54cd4d..8dd2cd4 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -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 diff --git a/internal/transport/http/handler/moment/handler.go b/internal/transport/http/handler/moment/handler.go new file mode 100644 index 0000000..da40d03 --- /dev/null +++ b/internal/transport/http/handler/moment/handler.go @@ -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) +} diff --git a/internal/transport/http/moment_test.go b/internal/transport/http/moment_test.go new file mode 100644 index 0000000..c2edf10 --- /dev/null +++ b/internal/transport/http/moment_test.go @@ -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) + } +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index c96d3b7..0b0183b 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -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 diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 4c69827..1e36732 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -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 diff --git a/migrations/0015_moments.sql b/migrations/0015_moments.sql new file mode 100644 index 0000000..7aeee66 --- /dev/null +++ b/migrations/0015_moments.sql @@ -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='不看他 / 不让他看。与拉黑分开';