From 542d4798297b6929e8e1cff9ca17004a3ad484e4 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Tue, 12 May 2026 17:05:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E5=92=8C=E9=9D=9E=E5=A5=BD?= =?UTF-8?q?=E5=8F=8B=E9=9D=99=E9=9F=B3=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/redpacket-api.md | 518 +++++++++++++++++++ internal/api/friend.go | 8 + internal/api/router.go | 2 + internal/rpc/msg/server.go | 6 + internal/rpc/msg/verify.go | 41 +- internal/rpc/relation/friend.go | 45 ++ pkg/common/storage/controller/user_mute.go | 44 ++ pkg/common/storage/database/mgo/user_mute.go | 95 ++++ pkg/common/storage/database/name.go | 1 + pkg/common/storage/database/user_mute.go | 19 + pkg/common/storage/model/user_mute.go | 12 + protocol | 2 +- 12 files changed, 778 insertions(+), 15 deletions(-) create mode 100644 docs/redpacket-api.md create mode 100644 pkg/common/storage/controller/user_mute.go create mode 100644 pkg/common/storage/database/mgo/user_mute.go create mode 100644 pkg/common/storage/database/user_mute.go create mode 100644 pkg/common/storage/model/user_mute.go diff --git a/docs/redpacket-api.md b/docs/redpacket-api.md new file mode 100644 index 000000000..1eddaf9ae --- /dev/null +++ b/docs/redpacket-api.md @@ -0,0 +1,518 @@ +# 红包 API 接口文档 + +**Base URL:** `/redpacket` +**协议:** HTTP POST,`Content-Type: application/json` +**认证:** 请求头携带 `token: `(标注需要登录的接口) + +> **统一响应结构** +> +> ```json +> { +> "errCode": 0, +> "errMsg": "ok", +> "errDlt": "", +> "data": { } +> } +> ``` +> +> `errCode` 为 `0` 表示成功,非 0 表示错误。 + +--- + +## 1. 创建红包订单 + +**POST** `/redpacket/create_order` +需要登录。创建一条待上链的红包订单,返回业务 ID(bizID)供后续链上交易关联。 + +### 请求体 + +```json +{ + "chainType": "EVM", + "chainID": 1, + "contractAddress": "0xAbCd...", + "creatorWallet": "0x1234...", + "groupID": "group_001", + "scopeType": "GROUP", + "receiverUserID": "", + "receiverUserIDs": [], + "packetType": 0, + "token": "0x0000000000000000000000000000000000000000", + "totalAmount": "1000000000000000000", + "totalShares": 10, + "expiryAt": 1800000000, + "remark": "新年快乐" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chainType` | string | **必填** | 链类型,仅支持 `"EVM"` 或 `"TRON"` | +| `chainID` | int64 | 可选 | 链 ID;为 0 时从链客户端自动获取 | +| `contractAddress` | string | 可选 | 合约地址;为空时从链客户端自动获取 | +| `creatorWallet` | string | **必填** | 创建者钱包地址 | +| `scopeType` | string | 可选 | 范围类型:`GROUP`(群组)/`DIRECT`(定向)/`PUBLIC`(公开),默认 `PUBLIC` | +| `groupID` | string | **GROUP 时必填** | 群组 ID(`scopeType=GROUP` 时必须提供) | +| `receiverUserID` | string | **transfer 时必填** | 接收者用户 ID(`packetType=2` 且 `scopeType=DIRECT` 时必须提供) | +| `receiverUserIDs` | []string | **DIRECT + fixed/random 时必填** | 多接收者用户 ID 列表(`scopeType=DIRECT` 且 `packetType=0/1` 时使用) | +| `packetType` | int32 | **必填** | 红包类型:`0`=均分红包,`1`=随机红包,`2`=转账红包 | +| `token` | string | 可选 | ERC20 代币合约地址;为空表示原生代币 | +| `totalAmount` | string | **必填** | 总金额(最小单位整数字符串,如 wei),必须为正整数 | +| `totalShares` | int32 | **必填(packetType 0/1)** | 红包份数;均分/随机红包 >0 且 ≤10000;转账红包固定为 1 | +| `expiryAt` | int64 | 可选 | 过期时间(Unix 时间戳秒);0 表示不过期;必须为将来时间 | +| `remark` | string | 可选 | 备注 | + +**packetType 规则说明:** + +- `0`(均分):`scopeType` 必须为 `GROUP`,`totalAmount` 必须被 `totalShares` 整除 +- `1`(随机):`scopeType` 必须为 `GROUP`,`totalAmount` ≥ `totalShares` +- `2`(转账):`scopeType` 必须为 `DIRECT`,`totalShares` 必须为 1,`receiverUserID` 必须提供,不能转给自己 + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "bizID": "550e8400-e29b-41d4-a716-446655440000" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `bizID` | string | 业务订单 ID,用于链上交易完成后提交回调 | + +--- + +## 2. 红包创建回调(链上确认) + +**POST** `/redpacket/created_callback` +需要登录。链上交易广播后,由创建者提交交易哈希以激活红包。仅创建者可调用。 + +### 请求体 + +```json +{ + "bizID": "550e8400-e29b-41d4-a716-446655440000", + "txHash": "0xabc123...", + "packetID": "", + "groupID": "", + "scopeType": "", + "receiverUserID": "", + "receiverUserIDs": [] +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `bizID` | string | **必填** | 创建订单时返回的业务 ID | +| `txHash` | string | **必填** | 链上交易哈希 | +| `packetID` | string | 条件必填 | 链上红包 ID;链客户端离线时必须手动提供 | +| `groupID` | string | 可选 | 覆盖订单的群组 ID(不填则继承订单值) | +| `scopeType` | string | 可选 | 覆盖订单的范围类型 | +| `receiverUserID` | string | 可选 | 覆盖单一接收者 | +| `receiverUserIDs` | []string | 可选 | 覆盖多接收者列表 | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": {} +} +``` + +--- + +## 3. 查询红包详情 + +**POST** `/redpacket/detail` + +### 请求体 + +```json +{ + "packetID": "12345" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `packetID` | string | **必填** | 链上红包 ID | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "record": { + "bizID": "550e8400-...", + "chainType": "EVM", + "packetID": "12345", + "chainID": 1, + "contractAddress": "0xAbCd...", + "creatorUserID": "user_001", + "creatorWallet": "0x1234...", + "groupID": "group_001", + "scopeType": "GROUP", + "receiverUserID": "", + "receiverUserIDs": [], + "packetType": 0, + "token": "0x0000000000000000000000000000000000000000", + "totalAmount": "1000000000000000000", + "totalShares": 10, + "claimedAmount": "300000000000000000", + "claimedShares": 3, + "expiryAt": 1800000000, + "txHash": "0xabc123...", + "status": "ACTIVE", + "createdAt": 1715500000, + "updatedAt": 1715500100 + }, + "claims": [ + { + "packetID": "12345", + "userID": "user_002", + "claimerWallet": "0x5678...", + "authNonce": "1715500050000000000", + "claimTxHash": "0xdef456...", + "claimedAmount": "100000000000000000", + "blockNumber": 19000000, + "status": "CONFIRMED", + "createdAt": 1715500050, + "updatedAt": 1715500060 + } + ] + } +} +``` + +**record 字段说明:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `status` | string | 红包状态:`PENDING` / `ACTIVE` / `COMPLETED` / `REFUNDED` / `EXPIRED` | +| `totalAmount` | string | 总金额(最小单位) | +| `claimedAmount` | string | 已领金额 | +| `totalShares` / `claimedShares` | int32 | 总份数 / 已领份数 | + +--- + +## 4. 申请领取签名 + +**POST** `/redpacket/issue_claim_sign` +需要登录。领取红包前,先获取服务端签名用于链上验证。会校验领取资格(是否群成员/好友、是否已领取、红包状态等)。 + +### 请求体 + +```json +{ + "packetID": "12345", + "claimer": "0x5678...", + "randomSeed": "" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `packetID` | string | **必填** | 链上红包 ID | +| `claimer` | string | **必填** | 领取者钱包地址 | +| `randomSeed` | string | 可选 | 随机种子(十进制整数字符串);为空或 `"0"` 时服务端自动生成 | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "authNonce": "1715500050000000000", + "deadline": 1715500350, + "signature": "0xaabbcc...", + "randomSeed": "8765309000000" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `authNonce` | string | 认证随机数(传给合约) | +| `deadline` | int64 | 签名过期时间(Unix 时间戳,约 5 分钟后) | +| `signature` | string | 服务端签名(0x 前缀十六进制,65 字节) | +| `randomSeed` | string | 使用的随机种子 | + +--- + +## 5. 提交领取结果 + +**POST** `/redpacket/claim_result` +需要登录。链上领取交易广播后提交,服务端解析链上事件并更新领取记录。 + +### 请求体 + +```json +{ + "packetID": "12345", + "claimer": "0x5678...", + "txHash": "0xdef456..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `packetID` | string | **必填** | 链上红包 ID | +| `claimer` | string | **必填** | 领取者钱包地址 | +| `txHash` | string | **必填** | 领取交易哈希 | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": {} +} +``` + +--- + +## 6. 申请退款 + +**POST** `/redpacket/request_refund` +需要登录。仅创建者可调用,红包到期后提交链上退款交易。 + +### 请求体 + +```json +{ + "packetID": "12345" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `packetID` | string | **必填** | 链上红包 ID(红包必须已到期) | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "txHash": "0xghi789...", + "status": "PENDING" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `txHash` | string | 退款交易哈希 | +| `status` | string | `PENDING`(退款交易已提交)或 `REFUNDED`(已退款) | + +--- + +## 7. 查询退款记录 + +**POST** `/redpacket/get_refund` + +### 请求体 + +```json +{ + "packetID": "12345" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `packetID` | string | **必填** | 链上红包 ID | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "packetID": "12345", + "refundTo": "0x1234...", + "txHash": "0xghi789...", + "amount": "700000000000000000", + "createdAt": 1715600000 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `refundTo` | string | 退款目标钱包地址 | +| `amount` | string | 退款金额(最小单位) | +| `createdAt` | int64 | 退款记录创建时间(Unix 时间戳) | + +--- + +## 8. 发起钱包绑定挑战 + +**POST** `/redpacket/wallet_bind/challenge` +需要登录。生成一条待用户签名的消息,用于将钱包地址绑定到当前用户账户(EVM 使用 EIP-4361 SIWE,TRON 使用 signMessageV2)。 + +### 请求体 + +```json +{ + "chainType": "EVM", + "chainID": 1, + "walletAddress": "0x5678...", + "domain": "myapp.example.com", + "uri": "https://myapp.example.com/wallet-bind" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chainType` | string | **必填** | 链类型,`"EVM"` 或 `"TRON"` | +| `walletAddress` | string | **必填** | 待绑定的钱包地址 | +| `chainID` | int64 | 可选 | 链 ID(EVM 时建议提供) | +| `domain` | string | 可选 | 应用域名,默认 `"redpacket"` | +| `uri` | string | 可选 | 应用 URI,默认 `"https://redpacket.local/wallet-bind"` | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "challengeID": "aaaa-bbbb-cccc-dddd", + "userID": "user_001", + "chainType": "EVM", + "chainID": 1, + "wallet": "0x5678...", + "protocol": "siwe-eip4361", + "signMethod": "personal_sign", + "nonce": "xxxx-yyyy-zzzz", + "message": "myapp.example.com wants you to sign in with your Ethereum account:\n0x5678...\n\nBind wallet ...", + "issuedAt": "2026-05-12T08:39:00Z", + "expiresAt": "2026-05-12T08:49:00Z" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `challengeID` | string | 挑战 ID,确认绑定时使用 | +| `message` | string | 待签名的完整消息文本 | +| `protocol` | string | EVM: `siwe-eip4361`;TRON: `tron-signmessagev2` | +| `signMethod` | string | EVM: `personal_sign`;TRON: `signMessageV2` | +| `expiresAt` | string | 挑战过期时间(RFC3339,10 分钟有效期) | + +--- + +## 9. 确认钱包绑定 + +**POST** `/redpacket/wallet_bind/confirm` +提交用户对挑战消息的签名,服务端验证后完成钱包绑定。挑战有效期 10 分钟。 + +### 请求体 + +```json +{ + "challengeID": "aaaa-bbbb-cccc-dddd", + "signature": "0xaabbccdd..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `challengeID` | string | **必填** | 挑战 ID(来自 `/wallet_bind/challenge`) | +| `signature` | string | **必填** | 钱包签名(0x 前缀十六进制,65 字节) | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "userID": "user_001", + "chainType": "EVM", + "chainID": 1, + "walletAddress": "0x5678...", + "status": "ACTIVE", + "verifiedAt": "2026-05-12T08:42:00Z" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `status` | string | 绑定状态,成功时为 `ACTIVE` | +| `verifiedAt` | string | 绑定验证时间(RFC3339) | + +--- + +## 10. 查询钱包绑定信息 + +**POST** `/redpacket/wallet_bind/detail` +需要登录。查询当前登录用户在指定链上的活跃钱包绑定。 + +### 请求体 + +```json +{ + "chainType": "EVM", + "walletAddress": "0x5678..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chainType` | string | **必填** | 链类型,`"EVM"` 或 `"TRON"` | +| `walletAddress` | string | 可选 | 钱包地址过滤条件 | + +### 响应体 + +```json +{ + "errCode": 0, + "errMsg": "ok", + "data": { + "userID": "user_001", + "chainType": "EVM", + "chainID": 1, + "walletAddress": "0x5678...", + "status": "ACTIVE", + "challengeID": "aaaa-bbbb-cccc-dddd", + "verifiedAt": "2026-05-12T08:42:00Z" + } +} +``` + +--- + +## 附录:公共枚举值 + +| 枚举 | 可选值 | 说明 | +|------|--------|------| +| `chainType` | `EVM` / `TRON` | 区块链类型 | +| `scopeType` | `GROUP` / `DIRECT` / `PUBLIC` | 红包范围 | +| `packetType` | `0` / `1` / `2` | 均分 / 随机 / 转账 | +| 红包 `status` | `PENDING` / `ACTIVE` / `COMPLETED` / `REFUNDED` / `EXPIRED` | 红包状态 | +| 领取 `status` | `PENDING` / `CONFIRMED` / `FAILED` | 领取记录状态 | +| 钱包绑定 `status` | `ACTIVE` | 绑定状态(查询时只返回活跃绑定) | + +--- + +## 实现参考 + +- 路由:`internal/api/router.go`(`/redpacket` 分组) +- HTTP 处理:`internal/api/redpacket.go` +- Proto 定义:`protocol/redpacket/redpacket.proto` +- RPC 实现:`internal/rpc/redpacket/service.go`、`internal/rpc/redpacket/wallet.go` diff --git a/internal/api/friend.go b/internal/api/friend.go index 7a7538f0a..95605b79d 100644 --- a/internal/api/friend.go +++ b/internal/api/friend.go @@ -126,3 +126,11 @@ func (o *FriendApi) GetPinnedFriendIDs(c *gin.Context) { func (o *FriendApi) AddOnewayFriend(c *gin.Context) { a2r.Call(c, relation.FriendClient.AddOnewayFriend, o.Client) } + +func (o *FriendApi) SetMute(c *gin.Context) { + a2r.Call(c, relation.FriendClient.SetMute, o.Client) +} + +func (o *FriendApi) GetMute(c *gin.Context) { + a2r.Call(c, relation.FriendClient.GetMute, o.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index 809dcecde..7eb7f1419 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -221,6 +221,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co friendRouterGroup.POST("/get_self_unhandled_apply_count", f.GetSelfUnhandledApplyCount) friendRouterGroup.POST("/get_pinned_friend_ids", f.GetPinnedFriendIDs) friendRouterGroup.POST("/add_oneway_friend", f.AddOnewayFriend) + friendRouterGroup.POST("/set_mute", f.SetMute) + friendRouterGroup.POST("/get_mute", f.GetMute) } g := NewGroupApi(group.NewGroupClient(groupConn)) diff --git a/internal/rpc/msg/server.go b/internal/rpc/msg/server.go index 3408a32b6..8e212e47a 100644 --- a/internal/rpc/msg/server.go +++ b/internal/rpc/msg/server.go @@ -72,6 +72,7 @@ type msgServer struct { conversationClient *rpcli.ConversationClient spamReportDB database.SpamReport globalBlackDB controller.UserGlobalBlackDatabase + userMuteDB controller.UserMuteDatabase msgBurnDeadlineDB database.MsgBurnDeadline } @@ -137,6 +138,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + userMuteMgo, err := mgo.NewUserMuteMongo(mgocli.GetDB()) + if err != nil { + return err + } s := &msgServer{ MsgDatabase: msgDatabase, RegisterCenter: client, @@ -149,6 +154,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg conversationClient: conversationClient, spamReportDB: spamReportDB, globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), + userMuteDB: controller.NewUserMuteDatabase(userMuteMgo), msgBurnDeadlineDB: msgBurnDeadlineDB, } diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index 1f5c45661..e0028e10b 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -304,25 +304,38 @@ func (m *msgServer) modifyMessageByUserMessageReceiveOpt(ctx context.Context, us // 第三优先级:会话级接收偏好 singleOpt, err := m.ConversationLocalCache.GetSingleConversationRecvMsgOpt(ctx, userID, conversationID) - if errs.ErrRecordNotFound.Is(err) { - return true, nil - } else if err != nil { + if err != nil && !errs.ErrRecordNotFound.Is(err) { return false, err } - switch singleOpt { - case constant.ReceiveMessage: - return true, nil - case constant.NotReceiveMessage: - if datautil.Contain(int(pb.MsgData.ContentType), ExcludeContentType...) { + if err == nil { + switch singleOpt { + case constant.NotReceiveMessage: + if datautil.Contain(int(pb.MsgData.ContentType), ExcludeContentType...) { + return true, nil + } + return false, nil + case constant.ReceiveNotNotifyMessage: + if pb.MsgData.Options == nil { + pb.MsgData.Options = make(map[string]bool, 10) + } + datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false) return true, nil } - return false, nil - case constant.ReceiveNotNotifyMessage: - if pb.MsgData.Options == nil { - pb.MsgData.Options = make(map[string]bool, 10) + } + + // 第四优先级:用户静音设置(user_mute 集合,支持好友与非好友) + // 无论会话记录是否存在均检查,以支持对非好友的静音 + if m.userMuteDB != nil { + muted, err := m.userMuteDB.IsMuted(ctx, userID, pb.MsgData.SendID) + if err != nil { + return false, err + } + if muted { + if pb.MsgData.Options == nil { + pb.MsgData.Options = make(map[string]bool, 10) + } + datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false) } - datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false) - return true, nil } return true, nil } diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index 770158882..71bdfd6e7 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -16,6 +16,7 @@ package relation import ( "context" + "time" "github.com/openimsdk/open-im-server/v3/pkg/notification/common_user" "github.com/openimsdk/open-im-server/v3/pkg/rpcli" @@ -56,6 +57,7 @@ type friendServer struct { db controller.FriendDatabase blackDatabase controller.BlackDatabase globalBlackDB controller.UserGlobalBlackDatabase + userMuteDB controller.UserMuteDatabase notificationSender *FriendNotificationSender RegisterCenter discovery.SvcDiscoveryRegistry config *Config @@ -107,6 +109,11 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg return err } + userMuteMongoDB, err := mgo.NewUserMuteMongo(mgocli.GetDB()) + if err != nil { + return err + } + userConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.User) if err != nil { return err @@ -144,6 +151,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg redis.NewBlackCacheRedis(rdb, &config.LocalCacheConfig, blackMongoDB, redis.GetRocksCacheOptions()), ), globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMongoDB), + userMuteDB: controller.NewUserMuteDatabase(userMuteMongoDB), notificationSender: notificationSender, RegisterCenter: client, config: config, @@ -718,6 +726,43 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT return &relation.ApplyToAddFriendResp{}, nil } +func (s *friendServer) SetMute(ctx context.Context, req *relation.SetMuteReq) (*relation.SetMuteResp, error) { + if err := authverify.CheckAccessV3(ctx, req.OwnerUserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if req.Duration == 0 { + return &relation.SetMuteResp{}, s.userMuteDB.Delete(ctx, req.OwnerUserID, req.TargetUserID) + } + var muteEndTime int64 + if req.Duration != -1 { + muteEndTime = time.Now().Unix() + req.Duration + } + return &relation.SetMuteResp{}, s.userMuteDB.Upsert(ctx, &model.UserMute{ + OwnerUserID: req.OwnerUserID, + MutedUserID: req.TargetUserID, + MuteEndTime: muteEndTime, + CreateTime: time.Now(), + }) +} + +func (s *friendServer) GetMute(ctx context.Context, req *relation.GetMuteReq) (*relation.GetMuteResp, error) { + if err := authverify.CheckAccessV3(ctx, req.OwnerUserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + rec, err := s.userMuteDB.Get(ctx, req.OwnerUserID, req.TargetUserID) + if err != nil { + return nil, err + } + if rec == nil { + return &relation.GetMuteResp{Muted: false, MuteEndTime: 0}, nil + } + now := time.Now().Unix() + if rec.MuteEndTime != 0 && rec.MuteEndTime <= now { + return &relation.GetMuteResp{Muted: false, MuteEndTime: 0}, nil + } + return &relation.GetMuteResp{Muted: true, MuteEndTime: rec.MuteEndTime}, nil +} + func (s *friendServer) getCommonUserMap(ctx context.Context, userIDs []string) (map[string]common_user.CommonUser, error) { users, err := s.userClient.GetUsersInfo(ctx, userIDs) if err != nil { diff --git a/pkg/common/storage/controller/user_mute.go b/pkg/common/storage/controller/user_mute.go new file mode 100644 index 000000000..c4c692cd6 --- /dev/null +++ b/pkg/common/storage/controller/user_mute.go @@ -0,0 +1,44 @@ +package controller + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" +) + +// UserMuteDatabase 用户静音业务接口 +type UserMuteDatabase interface { + // Upsert 新增或更新静音记录 + Upsert(ctx context.Context, mute *model.UserMute) error + // Delete 取消静音 + Delete(ctx context.Context, ownerUserID, mutedUserID string) error + // IsMuted 检查 ownerUserID 是否对 mutedUserID 设置了有效静音 + IsMuted(ctx context.Context, ownerUserID, mutedUserID string) (bool, error) + // Get 查询静音记录;不存在则 (nil, nil) + Get(ctx context.Context, ownerUserID, mutedUserID string) (*model.UserMute, error) +} + +type userMuteDatabase struct { + db database.UserMute +} + +func NewUserMuteDatabase(db database.UserMute) UserMuteDatabase { + return &userMuteDatabase{db: db} +} + +func (u *userMuteDatabase) Upsert(ctx context.Context, mute *model.UserMute) error { + return u.db.Upsert(ctx, mute) +} + +func (u *userMuteDatabase) Delete(ctx context.Context, ownerUserID, mutedUserID string) error { + return u.db.Delete(ctx, ownerUserID, mutedUserID) +} + +func (u *userMuteDatabase) IsMuted(ctx context.Context, ownerUserID, mutedUserID string) (bool, error) { + return u.db.IsMuted(ctx, ownerUserID, mutedUserID) +} + +func (u *userMuteDatabase) Get(ctx context.Context, ownerUserID, mutedUserID string) (*model.UserMute, error) { + return u.db.Get(ctx, ownerUserID, mutedUserID) +} diff --git a/pkg/common/storage/database/mgo/user_mute.go b/pkg/common/storage/database/mgo/user_mute.go new file mode 100644 index 000000000..5c22c43b4 --- /dev/null +++ b/pkg/common/storage/database/mgo/user_mute.go @@ -0,0 +1,95 @@ +package mgo + +import ( + "context" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/errs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewUserMuteMongo(db *mongo.Database) (database.UserMute, error) { + coll := db.Collection(database.UserMuteName) + _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{ + {Key: "owner_user_id", Value: 1}, + {Key: "muted_user_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }) + if err != nil { + return nil, errs.Wrap(err) + } + return &UserMuteMgo{coll: coll}, nil +} + +type UserMuteMgo struct { + coll *mongo.Collection +} + +func (u *UserMuteMgo) Upsert(ctx context.Context, mute *model.UserMute) error { + if mute.CreateTime.IsZero() { + mute.CreateTime = time.Now() + } + filter := bson.M{ + "owner_user_id": mute.OwnerUserID, + "muted_user_id": mute.MutedUserID, + } + update := bson.M{ + "$set": bson.M{ + "mute_end_time": mute.MuteEndTime, + }, + "$setOnInsert": bson.M{ + "owner_user_id": mute.OwnerUserID, + "muted_user_id": mute.MutedUserID, + "create_time": mute.CreateTime, + }, + } + _, err := u.coll.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) + return errs.Wrap(err) +} + +func (u *UserMuteMgo) Delete(ctx context.Context, ownerUserID, mutedUserID string) error { + _, err := u.coll.DeleteOne(ctx, bson.M{ + "owner_user_id": ownerUserID, + "muted_user_id": mutedUserID, + }) + return errs.Wrap(err) +} + +func (u *UserMuteMgo) IsMuted(ctx context.Context, ownerUserID, mutedUserID string) (bool, error) { + now := time.Now().Unix() + // mute_end_time == 0 means permanent; mute_end_time > now means still active + filter := bson.M{ + "owner_user_id": ownerUserID, + "muted_user_id": mutedUserID, + "$or": bson.A{ + bson.M{"mute_end_time": 0}, + bson.M{"mute_end_time": bson.M{"$gt": now}}, + }, + } + count, err := u.coll.CountDocuments(ctx, filter) + if err != nil { + return false, errs.Wrap(err) + } + return count > 0, nil +} + +func (u *UserMuteMgo) Get(ctx context.Context, ownerUserID, mutedUserID string) (*model.UserMute, error) { + var out model.UserMute + err := u.coll.FindOne(ctx, bson.M{ + "owner_user_id": ownerUserID, + "muted_user_id": mutedUserID, + }).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, errs.Wrap(err) + } + return &out, nil +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 7d74142ce..a906d3f72 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -25,4 +25,5 @@ const ( SpamReportName = "spam_report" MsgBurnDeadlineName = "msg_burn_deadline" UserOfflineRecordName = "user_offline_record" + UserMuteName = "user_mute" ) diff --git a/pkg/common/storage/database/user_mute.go b/pkg/common/storage/database/user_mute.go new file mode 100644 index 000000000..24442f09d --- /dev/null +++ b/pkg/common/storage/database/user_mute.go @@ -0,0 +1,19 @@ +package database + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" +) + +// UserMute 用户静音持久化接口(支持好友与非好友) +type UserMute interface { + // Upsert 新增或更新静音记录 + Upsert(ctx context.Context, mute *model.UserMute) error + // Delete 取消静音(删除记录) + Delete(ctx context.Context, ownerUserID, mutedUserID string) error + // IsMuted 检查 ownerUserID 是否对 mutedUserID 设置了有效的静音 + IsMuted(ctx context.Context, ownerUserID, mutedUserID string) (bool, error) + // Get 按 owner + muted 查询一条记录;不存在则 (nil, nil) + Get(ctx context.Context, ownerUserID, mutedUserID string) (*model.UserMute, error) +} diff --git a/pkg/common/storage/model/user_mute.go b/pkg/common/storage/model/user_mute.go new file mode 100644 index 000000000..b780edd69 --- /dev/null +++ b/pkg/common/storage/model/user_mute.go @@ -0,0 +1,12 @@ +package model + +import "time" + +// UserMute records a mute relationship: OwnerUserID has muted MutedUserID. +// Works for both friends and strangers. MuteEndTime == 0 means permanent mute. +type UserMute struct { + OwnerUserID string `bson:"owner_user_id"` // who set the mute + MutedUserID string `bson:"muted_user_id"` // who is muted + MuteEndTime int64 `bson:"mute_end_time"` // Unix seconds; 0 = permanent + CreateTime time.Time `bson:"create_time"` +} diff --git a/protocol b/protocol index 24c722593..ab69a8df5 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 24c72259373e9080ab005bb10385524753d4dfc3 +Subproject commit ab69a8df51841e7e70e637033c3f05b7e40bb747