From 90629c893fa49388673865c91bca05a01f6ceed7 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:42:02 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E9=9F=B3=E8=A7=86=E9=A2=91=E9=80=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/phone_sn.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/api/phone_sn.go b/internal/api/phone_sn.go index 3302ef215..8746cb6ba 100644 --- a/internal/api/phone_sn.go +++ b/internal/api/phone_sn.go @@ -12,6 +12,7 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" "github.com/openimsdk/tools/apiresp" "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" ) type PhoneSNApi struct { @@ -42,16 +43,19 @@ func (a *PhoneSNApi) GetSNInfo(c *gin.Context) { var req phoneGetSNInfoReq if err := c.ShouldBindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + log.ZError(c, "GetSNInfo", err) return } phone := strings.TrimSpace(req.Phone) if phone == "" { apiresp.GinError(c, errs.ErrArgs.WrapMsg("phone is empty")) + log.ZError(c, "GetSNInfo", errs.ErrArgs.WrapMsg("phone is empty")) return } info, err := a.db.GetByPhone(c, phone) if err != nil { apiresp.GinError(c, err) + log.ZError(c, "GetSNInfo", err) return } resp := phoneGetSNInfoResp{IsSnd: false, UserID: 0} From fa338b692cad6d8f8791b1733718399287be88ef Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:14:35 +0800 Subject: [PATCH 02/15] =?UTF-8?q?sn=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/phone_sn.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/phone_sn.go b/internal/api/phone_sn.go index 8746cb6ba..260c65a2f 100644 --- a/internal/api/phone_sn.go +++ b/internal/api/phone_sn.go @@ -54,8 +54,13 @@ func (a *PhoneSNApi) GetSNInfo(c *gin.Context) { } info, err := a.db.GetByPhone(c, phone) if err != nil { - apiresp.GinError(c, err) + if errs.ErrRecordNotFound.Is(err) { + apiresp.GinSuccess(c, phoneGetSNInfoResp{IsSnd: false, UserID: 0}) + log.ZDebug(c, "GetSNInfo", "phone not found", phone) + return + } log.ZError(c, "GetSNInfo", err) + apiresp.GinError(c, err) return } resp := phoneGetSNInfoResp{IsSnd: false, UserID: 0} From 7859fa2f2a1b3f0a82ded14e42c0d9c391fe1510 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:26:29 +0800 Subject: [PATCH 03/15] =?UTF-8?q?sn=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rtc-signaling.md | 288 +++++++++++++++++++++++++++++++ internal/rpc/captcha/captcha.go | 4 +- scripts/test/captcha_api_test.sh | 59 ++----- 3 files changed, 306 insertions(+), 45 deletions(-) create mode 100644 docs/rtc-signaling.md diff --git a/docs/rtc-signaling.md b/docs/rtc-signaling.md new file mode 100644 index 000000000..e29921bdf --- /dev/null +++ b/docs/rtc-signaling.md @@ -0,0 +1,288 @@ +# OpenIM 音视频(RTC)信令与媒体 — 技术说明 + +## 1. 职责边界 + +| 维度 | 说明 | +|------|------| +| OpenIM | 呼叫信令编排、邀请状态(Mongo)、LiveKit 房间创建/删除、进房 JWT、通过消息链路把信令投递到对端(在线 WebSocket + 离线推送)。 | +| LiveKit | WebRTC 媒体面(客户端持 Token 连接 `externalAddress`)。 | +| 协议 | `protocol/rtc/rtc.proto`;通知类 `ContentType` 见 `protocol/constant/rtc.go`。 | + +--- + +## 2. 服务与配置 + +| 项 | 位置 | +|----|------| +| RTC 进程入口 | `pkg/common/cmd/rpc_rtc.go` → `internal/rpc/rtc.Start` | +| 实现 | `internal/rpc/rtc/server.go`、`internal/rpc/rtc/signal.go` | +| 注册名 | `config.Share.RpcRegisterName.Rtc` | +| LiveKit 配置 | `pkg/common/config/config.go`:`LiveKit`(`internalAddress`、`externalAddress`、`apiKey`、`apiSecret`、`tokenExpiry`) | + +--- + +## 3. 接入方式(概览) + +### 3.1 WebSocket + +1. `internal/msggateway/ws_server.go`:注入 `RtcServiceClient`。 +2. `internal/msggateway/client.go`:`ReqIdentifier == WSSendSignalMsg`(**1004**)。 +3. `internal/msggateway/message_handler.go`:`SendSignalMessage` → `SignalMessageAssemble`(体为 `SignalMessageAssembleReq` 或裸 `SignalReq`)。 +4. 返回:`SignalResp` 的 protobuf 二进制放在 `Resp.Data`。 + +### 3.2 HTTP(`/rtc`) + +路由:`internal/api/router.go`;封装:`internal/api/rtc.go`(`a2r.Call` 将 HTTP 体映射到同名 gRPC)。Prometheus 发现:`GET /prometheus_discovery/rtc`。 + +**详细接口与链路见第 4 节。** + +--- + +## 4. 接口清单与各接口调用链路 + +对外暴露形态包括:**gRPC 方法名**(服务 `openim.rtc.RtcService`)、**HTTP**(OpenIM API 网关)、以及 **WebSocket**(仅映射到 `SignalMessageAssemble`)。以下链路按代码真实调用顺序描述。 + +### 4.1 接口总表 + +| # | gRPC 方法 | HTTP 路径 | WebSocket | +|---|-----------|-----------|-----------| +| 1 | `SignalMessageAssemble` | `POST /rtc/signal_message_assemble` | `ReqIdentifier=1004`(`WSSendSignalMsg`) | +| 2 | `SignalGetRoomByGroupID` | `POST /rtc/signal_get_room_by_group_id` | — | +| 3 | `SignalGetTokenByRoomID` | `POST /rtc/signal_get_token_by_room_id` | — | +| 4 | `SignalGetRooms` | `POST /rtc/signal_get_rooms` | — | +| 5 | `GetSignalInvitationInfo` | `POST /rtc/get_signal_invitation_info` | — | +| 6 | `GetSignalInvitationInfoStartApp` | `POST /rtc/get_signal_invitation_info_start_app` | — | +| 7 | `SignalSendCustomSignal` | `POST /rtc/signal_send_custom_signal` | — | +| 8 | `GetSignalInvitationRecords` | `POST /rtc/get_signal_invitation_records` | — | +| 9 | `DeleteSignalRecords` | `POST /rtc/delete_signal_records` | — | + +说明:`SignalReq` 内嵌的 **`getTokenByRoomID`** 与独立 RPC **`SignalGetTokenByRoomID`** 在服务端均落到 `genToken` + 返回 `liveURL`,前者经 `SignalMessageAssemble` 分发,后者经 HTTP 直达同名 gRPC。 + +--- + +### 4.2 `SignalMessageAssemble` + +**作用**:处理一路信令请求,返回 `SignalResp`;部分分支会写 Mongo、调 LiveKit、并通过 Msg 服务发 1601 通知。 + +**入口 A — WebSocket** + +1. 客户端发送二进制帧 → `internal/msggateway/client.go` 按 `ReqIdentifier==1004` 分支。 +2. `LongConnServer.SendSignalMessage` → `GrpcHandler.SendSignalMessage`(`internal/msggateway/message_handler.go`)。 +3. `proto.Unmarshal`:`SignalMessageAssembleReq`;若失败则解 `SignalReq` 并填入 `assembleReq.SignalReq`。 +4. `RtcServiceClient.SignalMessageAssemble(ctx, assembleReq)`(gRPC 至 **rtc 进程**)。 +5. `internal/rpc/rtc/signal.go`:`rtcServer.SignalMessageAssemble` → `switch req.SignalReq.Payload` → `handleInvite` / `handleInviteInGroup` / `handleCancel` / `handleAccept` / `handleHungUp` / `handleReject` / `handleGetTokenByRoomID`。 +6. 返回 `SignalMessageAssembleResp` → 网关将 `SignalResp` `proto.Marshal` → `Resp.Data` 回客户端。 + +**入口 B — HTTP** + +1. `POST /rtc/signal_message_assemble` → `internal/api/rtc.go`:`RtcApi.SignalMessageAssemble`。 +2. `github.com/openimsdk/tools/a2r.Call`:解析 Gin 请求体 → 调用 `RtcServiceClient.SignalMessageAssemble`。 +3. 后续与步骤 5–6 相同(响应经 HTTP 返回,而非 WS `Resp`)。 + +**分支内典型下游(仅当对应 payload 触发时)** + +| 子逻辑 | LiveKit | Mongo(`controller.RtcDatabase` → `mgo/signal`) | Msg(`rpcli.MsgClient.SendMsg`) | +|--------|---------|---------------------------------------------------|----------------------------------| +| `handleInvite` | `CreateRoom` | `CreateInvitation` | 对每个被叫 `sendSignalingNotification`(1601) | +| `handleInviteInGroup` | `CreateRoom` | `CreateInvitation` | 同上(`SessionType` 为群) | +| `handleAccept` | — | — | 通知主叫 1601 | +| `handleReject` | — | `DeleteInvitation` / `RemoveInvitee` | 通知主叫 1601 | +| `handleCancel` | — | `DeleteInvitation` | 通知被叫 1601 | +| `handleHungUp` | `DeleteRoom` | `DeleteInvitation` | 通知对端 1601 | +| `handleGetTokenByRoomID` | — | — | — | + +**若发生 `SendMsg`(1601)**,后续链路见 **第 7 节**(Kafka → msg_transfer → push → 网关 `WSPushMsg` 2001)。 + +--- + +### 4.3 `SignalGetRoomByGroupID` + +**作用**:按群 ID 查当前(或最近)邀请信息,返回 `InvitationInfo` 与 `roomID`。 + +**HTTP 链路** + +1. `POST /rtc/signal_get_room_by_group_id` → `RtcApi.SignalGetRoomByGroupID` → `a2r.Call` → gRPC。 +2. `internal/rpc/rtc/signal.go`:`SignalGetRoomByGroupID` → `db.GetInvitationByGroupID` → Mongo `signal_invitation`(`mgo/signal.go`)。 +3. `modelToInvitationInfo` 填响应返回。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +### 4.4 `SignalGetTokenByRoomID`(独立 RPC) + +**作用**:已有房间时,仅为指定用户签发 LiveKit JWT,并返回 `liveURL`(`ExternalAddress`)。 + +**HTTP 链路** + +1. `POST /rtc/signal_get_token_by_room_id` → `RtcApi.SignalGetTokenByRoomID` → `a2r.Call` → gRPC。 +2. `internal/rpc/rtc/signal.go`:`SignalGetTokenByRoomID`(与 `handleGetTokenByRoomID` 同源逻辑)→ `genToken(roomID, userID)`。 +3. 返回 `SignalGetTokenByRoomIDResp`。 + +**不经过**:Mongo(不校验邀请是否存在)、Msg、Kafka。 + +--- + +### 4.5 `SignalGetRooms` + +**作用**:批量 `roomID` 查询邀请信息列表。 + +**HTTP 链路** + +1. `POST /rtc/signal_get_rooms` → `RtcApi.SignalGetRooms` → `a2r.Call` → gRPC。 +2. `SignalGetRooms` → `db.GetInvitationsByRoomIDs` → Mongo。 +3. 组装 `[]*SignalGetRoomByGroupIDResp` 返回。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +### 4.6 `GetSignalInvitationInfo` + +**作用**:按 **roomID** 查邀请详情及离线推送字段。 + +**HTTP 链路** + +1. `POST /rtc/get_signal_invitation_info` → `RtcApi.GetSignalInvitationInfo` → `a2r.Call` → gRPC。 +2. `GetSignalInvitationInfo` → `db.GetInvitationByRoomID` → Mongo。 +3. 填充 `InvitationInfo`、`OfflinePushInfo` 返回。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +### 4.7 `GetSignalInvitationInfoStartApp` + +**作用**:按 **被叫 userID** 查其相关待处理邀请(冷启动拉铃场景)。 + +**HTTP 链路** + +1. `POST /rtc/get_signal_invitation_info_start_app` → `RtcApi.GetSignalInvitationInfoStartApp` → `a2r.Call` → gRPC。 +2. `GetSignalInvitationInfoStartApp` → `db.GetInvitationByInviteeUserID` → Mongo(`invitee_user_id_list` 查询)。 +3. 返回邀请与 `OfflinePushInfo`。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +### 4.8 `SignalSendCustomSignal` + +**作用**:向房间内除操作者外的参与者广播 **自定义信令**(系统消息 **1605**)。 + +**HTTP 链路** + +1. `POST /rtc/signal_send_custom_signal` → `RtcApi.SignalSendCustomSignal` → `a2r.Call` → gRPC。 +2. `SignalSendCustomSignal` → `db.GetInvitationByRoomID`(取邀请内 `InviteeUserIDList` + `InviterUserID`)。 +3. `mcontext.GetOpUserID(ctx)` 排除发送者自己。 +4. 对每个目标用户 `sendCustomSignalNotification` → `MsgClient.SendMsg`(`ContentType=CustomSignalNotification`,JSON body)。 +5. 若第 2 步查无邀请:打日志后返回空成功(不报错)。 + +**若发生 `SendMsg`**:后续同第 7 节(1605 走消息总线与推送)。 + +--- + +### 4.9 `GetSignalInvitationRecords` + +**作用**:分页查询通话/信令话单(`signal_record`)。 + +**HTTP 链路** + +1. `POST /rtc/get_signal_invitation_records` → `RtcApi.GetSignalInvitationRecords` → `a2r.Call` → gRPC。 +2. `GetSignalInvitationRecords` → `db.SearchRecords`(`sendID` / `recvID` / `sessionType` / 时间范围 / 分页)→ Mongo `signal_record`。 +3. 映射为 `[]*rtc.SignalRecord` 返回。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +### 4.10 `DeleteSignalRecords` + +**作用**:按话单主键 `SID` 列表删除记录。 + +**HTTP 链路** + +1. `POST /rtc/delete_signal_records` → `RtcApi.DeleteSignalRecords` → `a2r.Call` → gRPC。 +2. `DeleteSignalRecords` → `db.DeleteRecords(sIDs)` → Mongo。 + +**不经过**:LiveKit、Msg、Kafka。 + +--- + +## 5. `SignalMessageAssemble` 行为摘要(payload 与副作用) + +(实现:`internal/rpc/rtc/signal.go`) + +| 动作 | LiveKit | Mongo | 通知 | +|------|---------|-------|------| +| Invite | CreateRoom | CreateInvitation | 向被叫发 1601 | +| InviteInGroup | CreateRoom | CreateInvitation | 向被叫发 1601(群 SessionType) | +| Accept | — | — | 通知主叫 1601 | +| Reject | — | DeleteInvitation / RemoveInvitee | 通知主叫 | +| Cancel | — | DeleteInvitation | 通知被叫 | +| HungUp | **DeleteRoom** | DeleteInvitation | 通知对端 | +| GetTokenByRoomID(嵌在 SignalReq) | — | — | — | + +Token:`github.com/livekit/protocol/auth`,`VideoGrant`(`RoomJoin` + `Room` + `Identity`),有效期由配置决定。 + +--- + +## 6. Mongo + +- 集合:`signal_invitation`、`signal_record`(`pkg/common/storage/database/name.go`)。 +- 模型:`pkg/common/storage/model/signal.go`。 +- DAO:`pkg/common/storage/database/mgo/signal.go`。 +- 控制器:`pkg/common/storage/controller/rtc.go`。 + +话单 `SignalRecord` 的写入需结合业务;`GetSignalInvitationRecords` 依赖该集合已有数据。 + +--- + +## 7. 信令进消息链路(`SendMsg` 之后) + +适用于:`sendSignalingNotification`(1601)、`sendCustomSignalNotification`(1605)。 + +1. `MsgClient.SendMsg` → `internal/rpc/msg/send.go`(按 `SessionType` 走单聊/群聊分支)。 +2. `MsgToMQ` → Kafka **`toRedisTopic`**(key:单聊为 `GenConversationUniqueKeyForSingle`;群为 `GroupID`)。 +3. `msg_transfer`(`internal/msgtransfer/online_history_msg_handler.go`)消费 → Redis seq → **`toMongoTopic`** → **`toPushTopic`**。 +4. `push`(`internal/push/push_handler.go`)消费 `toPushTopic` → `Push2User` / `Push2Group` → 网关 RPC。 +5. 网关 `Client.PushMessage`(`internal/msggateway/client.go`):**`ReqIdentifier = WSPushMsg`(2001)**,`Data` 为 `sdkws.PushMessages` 的 protobuf。 + +离线推送:`SignalingNotification` 可走离线;`RoomParticipantsConnected/Disconnected`(1602/1603)在 push 逻辑中默认不触发离线推。 + +--- + +## 8. `MsgData.Options` 与会话 ID(缺省行为) + +`pkg/msgprocessor/options.go`:`Options.Is(key)` 在 **key 未设置时视为 true**。 + +RTC 侧 `sendSignalingNotification` 使用 `make(map[string]bool)` 空 map,故 `IsHistory` / `IsNotNotification` 等表现为 true,信令在 transfer 中多走**落库 + 带 seq 后推送**路径。 + +网关下行使用 `GetConversationIDByMsg`:单聊信令默认挂在 **`si_*`** 的 `PushMessages.Msgs` 中(`IsNotification` 为 false 的前缀规则)。 + +--- + +## 9. 已知风险与排查 + +- **群通话信令**:当前构造通知时若未设置 `MsgData.GroupID`,`sendMsgGroupChat` 的 Kafka key 与 `Push2Group(ctx, groupID, ...)` 可能异常;建议在发群信令时写入与 `InvitationInfo.group_id` 一致的 `GroupID`。 +- **常量 1602–1604**:协议与 push 有特殊分支,但 `internal/rpc/rtc` 主路径主要发 1601/1605;若产品需要房间成员/流状态通知,需在扩展路径发送。 + +--- + +## 10. 常量(节选) + +| 值 | 含义 | +|----|------| +| 1601 | `SignalingNotification` | +| 1605 | `CustomSignalNotification` | +| 1602–1604 | 房间参与者/流变更等(push 对 1602/1603 限制离线推) | + +--- + +## 11. 端到端链路(简图) + +```text +客户端 → [WS 1004 或 HTTP /rtc] → rtc RPC → LiveKit + Mongo + → msg SendMsg → Kafka(toRedis) → msg_transfer → Kafka(toPush) + → push → msg_gateway → WS 2001 (PushMessages) +客户端 ← LiveKit(media) + OpenIM(信令推送) +``` diff --git a/internal/rpc/captcha/captcha.go b/internal/rpc/captcha/captcha.go index 063a0c11e..fe5ffa9c6 100644 --- a/internal/rpc/captcha/captcha.go +++ b/internal/rpc/captcha/captcha.go @@ -83,7 +83,7 @@ func Start(ctx context.Context, cfg *Config, _ discovery.SvcDiscoveryRegistry, g s.conf.ExpireSeconds = 120 } if s.conf.VerifyPadding <= 0 { - s.conf.VerifyPadding = 8 + s.conf.VerifyPadding = 32 } pbcaptcha.RegisterCaptchaServer(grpcServer, s) return nil @@ -161,7 +161,7 @@ func (s *server) VerifyCaptcha(ctx context.Context, req *pbcaptcha.VerifyCaptcha } success := slide.Validate(int(req.X), int(req.Y), doc.X, doc.Y, s.conf.VerifyPadding) if !success { - log.ZWarn(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", req.X, "y", req.Y) + log.ZError(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", req.X, "y", req.Y, "docX", doc.X, "docY", doc.Y) } return &pbcaptcha.VerifyCaptchaResp{Success: success}, nil } diff --git a/scripts/test/captcha_api_test.sh b/scripts/test/captcha_api_test.sh index 58531d25f..ee3ae057c 100755 --- a/scripts/test/captcha_api_test.sh +++ b/scripts/test/captcha_api_test.sh @@ -11,6 +11,7 @@ # chmod +x captcha_api_test.sh # ./captcha_api_test.sh # ./captcha_api_test.sh --host http://127.0.0.1:10002 +# HOST=http://api.example.com:10002 ./captcha_api_test.sh # ============================================================ set -euo pipefail @@ -18,17 +19,13 @@ set -euo pipefail # ────────────────────────────────────────────── # 可配置参数(可通过环境变量覆盖) # ────────────────────────────────────────────── +# 说明:/captcha/* 在 GinParseToken 白名单中,无需 Header token。 HOST="${HOST:-http://127.0.0.1:10002}" -ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" -ADMIN_SECRET="${ADMIN_SECRET:-openIM123}" -PLATFORM_ID="${PLATFORM_ID:-1}" # 1=iOS 2=Android 3=Windows ... # 命令行参数解析 while [[ $# -gt 0 ]]; do case "$1" in --host) HOST="$2"; shift 2 ;; - --admin-user-id) ADMIN_USER_ID="$2"; shift 2 ;; - --admin-secret) ADMIN_SECRET="$2"; shift 2 ;; *) echo "未知参数: $1"; exit 1 ;; esac done @@ -108,35 +105,12 @@ assert_err_nonzero() { } # ────────────────────────────────────────────── -# 前置:获取 Admin Token -# ────────────────────────────────────────────── -section "前置:获取 Admin Token" - -TOKEN_RESP=$(curl -s -X POST \ - -H "Content-Type: application/json" \ - -H "operationID: $(new_op_id)" \ - -d "{\"secret\":\"${ADMIN_SECRET}\",\"platformID\":${PLATFORM_ID},\"userID\":\"${ADMIN_USER_ID}\"}" \ - "${HOST}/auth/get_admin_token") - -info "Token 响应: $TOKEN_RESP" - -ERR_CODE=$(echo "$TOKEN_RESP" | jq -r '.errCode // "null"') -if [[ "$ERR_CODE" != "0" ]]; then - echo -e "${RED}[ERROR]${NC} 获取 Admin Token 失败 (errCode=$ERR_CODE),中止测试" - exit 1 -fi - -TOKEN=$(echo "$TOKEN_RESP" | jq -r '.data.token') -info "获取到 token: ${TOKEN:0:40}..." - -# ────────────────────────────────────────────── -# 用例 1:生成验证码 —— 正常流程 +# 用例 1:生成验证码 —— 正常流程(无需 token,白名单) # ────────────────────────────────────────────── section "用例 1 / POST /captcha/generate —— 正常生成验证码" GEN_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d '{}' \ "${HOST}/captcha/generate") @@ -165,9 +139,9 @@ else fi # ────────────────────────────────────────────── -# 用例 2:生成验证码 —— 不携带 Token +# 用例 2:生成验证码 —— 不携带 Token(白名单,应与用例 1 一致成功) # ────────────────────────────────────────────── -section "用例 2 / POST /captcha/generate —— 无 Token 应被鉴权中间件拦截" +section "用例 2 / POST /captcha/generate —— 无 Token(白名单)仍应成功" NO_TOKEN_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ @@ -175,8 +149,13 @@ NO_TOKEN_RESP=$(curl -s -X POST \ -d '{}' \ "${HOST}/captcha/generate") -info "响应: $NO_TOKEN_RESP" -assert_err_nonzero "$NO_TOKEN_RESP" "无 Token 被鉴权中间件拦截" +info "响应摘要: $(echo "${NO_TOKEN_RESP}" | jq -c '{errCode,errMsg,data:{captchaID:.data.captchaID}}' 2>/dev/null || echo "$NO_TOKEN_RESP")" +NO_TOKEN_ERR=$(echo "${NO_TOKEN_RESP}" | jq -r '.errCode // "null"') +if [[ "${NO_TOKEN_ERR}" == "500" ]]; then + info "与用例 1 相同:若 captcha 资源未就绪可能为 500,此处不强制 PASS/FAIL" +else + assert_err_code "${NO_TOKEN_RESP}" "0" "无 Token 调用 generate errCode 应为 0(白名单)" +fi # ────────────────────────────────────────────── # 用例 3:验证验证码 —— 坐标错误(x=999, y=999) @@ -188,7 +167,6 @@ if [[ -z "${CAPTCHA_ID}" ]]; then else VERIFY_WRONG_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":999,\"y\":999}" \ "${HOST}/captcha/verify") @@ -209,7 +187,6 @@ if [[ -z "${CAPTCHA_ID}" ]]; then else VERIFY_REUSE_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":0,\"y\":0}" \ "${HOST}/captcha/verify") @@ -224,7 +201,6 @@ section "用例 5 / POST /captcha/verify —— captchaID 不存在,应返回 VERIFY_NOTFOUND_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d '{"captchaID":"00000000-0000-0000-0000-000000000000","x":10,"y":10}' \ "${HOST}/captcha/verify") @@ -239,7 +215,6 @@ section "用例 6 / POST /captcha/verify —— captchaID 为空字符串,应 VERIFY_EMPTY_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d '{"captchaID":"","x":10,"y":10}' \ "${HOST}/captcha/verify") @@ -248,18 +223,18 @@ info "响应: $VERIFY_EMPTY_RESP" assert_err_nonzero "$VERIFY_EMPTY_RESP" "captchaID 为空时返回错误" # ────────────────────────────────────────────── -# 用例 7:验证验证码 —— 不携带 Token +# 用例 7:验证验证码 —— 不携带 Token(白名单,应到达业务层而非 token 拦截) # ────────────────────────────────────────────── -section "用例 7 / POST /captcha/verify —— 无 Token 应被鉴权中间件拦截" +section "用例 7 / POST /captcha/verify —— 无 Token(白名单)随机 captchaID 应返回业务错误" VERIFY_NOTOKEN_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ -H "operationID: $(new_op_id)" \ - -d "{\"captchaID\":\"${CAPTCHA_ID:-00000000-0000-0000-0000-000000000000}\",\"x\":10,\"y\":10}" \ + -d "{\"captchaID\":\"11111111-1111-1111-1111-111111111111\",\"x\":10,\"y\":10}" \ "${HOST}/captcha/verify") info "响应: $VERIFY_NOTOKEN_RESP" -assert_err_nonzero "$VERIFY_NOTOKEN_RESP" "无 Token 被鉴权中间件拦截" +assert_err_nonzero "$VERIFY_NOTOKEN_RESP" "无 Token 时无效 captchaID 仍返回业务层 errCode!=0(非鉴权拦截)" # ────────────────────────────────────────────── # 用例 8:完整正向链路 —— 新生成 + 用偏差坐标验证 @@ -271,7 +246,6 @@ section "用例 8 / 完整正向链路 —— 新生成验证码 → 坐标偏 GEN_RESP2=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d '{}' \ "${HOST}/captcha/generate") @@ -298,7 +272,6 @@ else VERIFY_LINK_RESP=$(curl -s -X POST \ -H "Content-Type: application/json" \ - -H "token: ${TOKEN}" \ -H "operationID: $(new_op_id)" \ -d "{\"captchaID\":\"${CAPTCHA_ID2}\",\"x\":0,\"y\":0}" \ "${HOST}/captcha/verify") From a3bda263a7ef91b1a9c36a6d5b340ce0f70ca3fb Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:26:27 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E7=BD=AE=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/friend.go | 4 +++ internal/api/router.go | 1 + internal/rpc/relation/friend.go | 36 +++++++++++++++++++++++ pkg/common/storage/controller/friend.go | 6 ++++ pkg/common/storage/database/friend.go | 2 ++ pkg/common/storage/database/mgo/friend.go | 18 ++++++++++++ 6 files changed, 67 insertions(+) diff --git a/internal/api/friend.go b/internal/api/friend.go index 0943e8a5d..023ae28c8 100644 --- a/internal/api/friend.go +++ b/internal/api/friend.go @@ -118,3 +118,7 @@ func (o *FriendApi) GetFullFriendUserIDs(c *gin.Context) { func (o *FriendApi) GetSelfUnhandledApplyCount(c *gin.Context) { a2r.Call(c, relation.FriendClient.GetSelfUnhandledApplyCount, o.Client) } + +func (o *FriendApi) GetPinnedFriendIDs(c *gin.Context) { + a2r.Call(c, relation.FriendClient.GetPinnedFriendIDs, o.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index 936b60531..46437a020 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -190,6 +190,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co friendRouterGroup.POST("/get_incremental_friends", f.GetIncrementalFriends) friendRouterGroup.POST("/get_full_friend_user_ids", f.GetFullFriendUserIDs) friendRouterGroup.POST("/get_self_unhandled_apply_count", f.GetSelfUnhandledApplyCount) + friendRouterGroup.POST("/get_pinned_friend_ids", f.GetPinnedFriendIDs) } g := NewGroupApi(group.NewGroupClient(groupConn)) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index 77f42e1a6..b32a8c50d 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -34,12 +34,15 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/convert" "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller" + "github.com/openimsdk/open-im-server/v3/pkg/util/conversationutil" "github.com/openimsdk/protocol/constant" + conversationpb "github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/relation" "github.com/openimsdk/protocol/sdkws" "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/utils/datautil" "google.golang.org/grpc" ) @@ -54,6 +57,7 @@ type friendServer struct { webhookClient *webhook.Client queue *memamq.MemoryQueue userClient *rpcli.UserClient + conversationClient *rpcli.ConversationClient } type Config struct { @@ -101,6 +105,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + conversationConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Conversation) + if err != nil { + return err + } userClient := rpcli.NewUserClient(userConn) database := controller.NewFriendDatabase( @@ -131,6 +139,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), queue: memamq.NewMemoryQueue(16, 1024*1024), userClient: userClient, + conversationClient: rpcli.NewConversationClient(conversationConn), }) return nil } @@ -549,6 +558,22 @@ func (s *friendServer) UpdateFriends( return nil, err } + if req.IsPinned != nil { + for _, friendUserID := range req.FriendUserIDs { + convID := conversationutil.GenConversationIDForSingle(req.OwnerUserID, friendUserID) + if err := s.conversationClient.SetConversations(ctx, []string{req.OwnerUserID}, + &conversationpb.ConversationReq{ + ConversationID: convID, + ConversationType: constant.SingleChatType, + UserID: friendUserID, + IsPinned: req.IsPinned, + }); err != nil { + log.ZWarn(ctx, "sync conversation isPinned failed", err, + "ownerUserID", req.OwnerUserID, "friendUserID", friendUserID) + } + } + } + resp := &relation.UpdateFriendsResp{} s.notificationSender.FriendsInfoUpdateNotification(ctx, req.OwnerUserID, req.FriendUserIDs) @@ -570,6 +595,17 @@ func (s *friendServer) GetSelfUnhandledApplyCount(ctx context.Context, req *rela }, nil } +func (s *friendServer) GetPinnedFriendIDs(ctx context.Context, req *relation.GetPinnedFriendIDsReq) (*relation.GetPinnedFriendIDsResp, error) { + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + ids, err := s.db.GetPinnedFriendIDs(ctx, req.UserID) + if err != nil { + return nil, err + } + return &relation.GetPinnedFriendIDsResp{FriendUserIDs: ids}, 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/friend.go b/pkg/common/storage/controller/friend.go index b2ae3e732..db0b34216 100644 --- a/pkg/common/storage/controller/friend.go +++ b/pkg/common/storage/controller/friend.go @@ -90,6 +90,8 @@ type FriendDatabase interface { OwnerIncrVersion(ctx context.Context, ownerUserID string, friendUserIDs []string, state int32) error GetUnhandledCount(ctx context.Context, userID string, ts int64) (int64, error) + + GetPinnedFriendIDs(ctx context.Context, ownerUserID string) ([]string, error) } type friendDatabase struct { @@ -402,3 +404,7 @@ func (f *friendDatabase) OwnerIncrVersion(ctx context.Context, ownerUserID strin func (f *friendDatabase) GetUnhandledCount(ctx context.Context, userID string, ts int64) (int64, error) { return f.friendRequest.GetUnhandledCount(ctx, userID, ts) } + +func (f *friendDatabase) GetPinnedFriendIDs(ctx context.Context, ownerUserID string) ([]string, error) { + return f.friend.FindPinnedFriendUserIDs(ctx, ownerUserID) +} diff --git a/pkg/common/storage/database/friend.go b/pkg/common/storage/database/friend.go index b596411fc..d89b18cb2 100644 --- a/pkg/common/storage/database/friend.go +++ b/pkg/common/storage/database/friend.go @@ -57,4 +57,6 @@ type Friend interface { FindOwnerFriendUserIds(ctx context.Context, ownerUserID string, limit int) ([]string, error) IncrVersion(ctx context.Context, ownerUserID string, friendUserIDs []string, state int32) error + + FindPinnedFriendUserIDs(ctx context.Context, ownerUserID string) ([]string, error) } diff --git a/pkg/common/storage/database/mgo/friend.go b/pkg/common/storage/database/mgo/friend.go index 76c82bac2..6344eecb5 100644 --- a/pkg/common/storage/database/mgo/friend.go +++ b/pkg/common/storage/database/mgo/friend.go @@ -47,6 +47,17 @@ func NewFriendMongo(db *mongo.Database) (database.Friend, error) { if err != nil { return nil, err } + // Compound index to support efficient sorted pagination: pinned friends first, then by _id. + _, err = coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{ + {Key: "owner_user_id", Value: 1}, + {Key: "is_pinned", Value: -1}, + {Key: "_id", Value: 1}, + }, + }) + if err != nil { + return nil, err + } owner, err := NewVersionLog(db.Collection(database.FriendVersionName)) if err != nil { return nil, err @@ -268,3 +279,10 @@ func (f *FriendMgo) IsUpdateIsPinned(data map[string]any) bool { _, ok := data["is_pinned"] return ok } + +func (f *FriendMgo) FindPinnedFriendUserIDs(ctx context.Context, ownerUserID string) ([]string, error) { + return mongoutil.Find[string](ctx, f.coll, bson.M{ + "owner_user_id": ownerUserID, + "is_pinned": true, + }, options.Find().SetProjection(bson.M{"_id": 0, "friend_user_id": 1})) +} From 7e65b21c5ec19487a53e6312d8fb99afba71ab7e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:38:30 +0800 Subject: [PATCH 05/15] =?UTF-8?q?=E4=B8=BE=E6=8A=A5=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/msg.go | 12 ++ internal/api/router.go | 3 + internal/rpc/msg/report.go | 143 ++++++++++++++++++ internal/rpc/msg/server.go | 7 + .../storage/database/mgo/spam_report.go | 110 ++++++++++++++ pkg/common/storage/database/name.go | 1 + pkg/common/storage/database/spam_report.go | 35 +++++ pkg/common/storage/model/spam_report.go | 53 +++++++ 8 files changed, 364 insertions(+) create mode 100644 internal/rpc/msg/report.go create mode 100644 pkg/common/storage/database/mgo/spam_report.go create mode 100644 pkg/common/storage/database/spam_report.go create mode 100644 pkg/common/storage/model/spam_report.go diff --git a/internal/api/msg.go b/internal/api/msg.go index 4fe950ffa..ee07a47c4 100644 --- a/internal/api/msg.go +++ b/internal/api/msg.go @@ -379,3 +379,15 @@ func (m *MessageApi) GetStreamMsg(c *gin.Context) { func (m *MessageApi) AppendStreamMsg(c *gin.Context) { a2r.Call(c, msg.MsgClient.GetServerTime, m.Client) } + +func (m *MessageApi) ReportSpam(c *gin.Context) { + a2r.Call(c, msg.MsgClient.ReportSpam, m.Client) +} + +func (m *MessageApi) GetSpamReports(c *gin.Context) { + a2r.Call(c, msg.MsgClient.GetSpamReports, m.Client) +} + +func (m *MessageApi) HandleSpamReport(c *gin.Context) { + a2r.Call(c, msg.MsgClient.HandleSpamReport, m.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index 46437a020..06e08dde3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -288,6 +288,9 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co msgGroup.POST("/batch_send_msg", m.BatchSendMsg) msgGroup.POST("/check_msg_is_send_success", m.CheckMsgIsSendSuccess) msgGroup.POST("/get_server_time", m.GetServerTime) + msgGroup.POST("/report_spam", m.ReportSpam) + msgGroup.POST("/get_spam_reports", m.GetSpamReports) + msgGroup.POST("/handle_spam_report", m.HandleSpamReport) } // Conversation { diff --git a/internal/rpc/msg/report.go b/internal/rpc/msg/report.go new file mode 100644 index 000000000..07bf5cf99 --- /dev/null +++ b/internal/rpc/msg/report.go @@ -0,0 +1,143 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msg + +import ( + "context" + "crypto/rand" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/authverify" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/protocol/msg" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/mcontext" + "github.com/openimsdk/tools/utils/datautil" +) + +func genReportID() string { + const dataLen = 12 + data := make([]byte, dataLen) + rand.Read(data) + chars := []byte("0123456789abcdefghijklmnopqrstuvwxyz") + for i := 0; i < len(data); i++ { + data[i] = chars[data[i]%byte(len(chars))] + } + return string(data) +} + +func (m *msgServer) ReportSpam(ctx context.Context, req *msg.ReportSpamReq) (*msg.ReportSpamResp, error) { + if req.ReportedUserID == "" { + return nil, errs.ErrArgs.WrapMsg("reportedUserID is required") + } + if req.ReasonType <= 0 { + return nil, errs.ErrArgs.WrapMsg("reasonType must be positive") + } + + reporterUserID := mcontext.GetOpUserID(ctx) + + report := &model.SpamReport{ + ReporterUserID: reporterUserID, + ReportedUserID: req.ReportedUserID, + ConversationID: req.ConversationID, + ClientMsgID: req.ClientMsgID, + Seq: req.Seq, + ReasonType: req.ReasonType, + Reason: req.Reason, + Status: model.SpamReportStatusPending, + CreateTime: time.Now(), + Ex: req.Ex, + } + + // Generate a unique reportID. + for i := 0; i < 20; i++ { + id := genReportID() + existing, err := m.spamReportDB.Get(ctx, id) + if err == nil && existing != nil { + continue + } + report.ReportID = id + break + } + if report.ReportID == "" { + return nil, errs.ErrInternalServer.WrapMsg("failed to generate report ID") + } + + if err := m.spamReportDB.Create(ctx, report); err != nil { + return nil, err + } + return &msg.ReportSpamResp{ReportID: report.ReportID}, nil +} + +func (m *msgServer) GetSpamReports(ctx context.Context, req *msg.GetSpamReportsReq) (*msg.GetSpamReportsResp, error) { + if err := authverify.CheckAdmin(ctx, m.config.Share.IMAdminUserID); err != nil { + return nil, err + } + + var start, end time.Time + if req.StartTime > 0 { + start = time.UnixMilli(req.StartTime) + } + if req.EndTime > 0 { + end = time.UnixMilli(req.EndTime) + } + + total, reports, err := m.spamReportDB.Find(ctx, req.Status, req.ReportedUserID, req.ReporterUserID, + start, end, req.Pagination) + if err != nil { + return nil, err + } + + pbReports := datautil.Slice(reports, func(r *model.SpamReport) *msg.SpamReportInfo { + return &msg.SpamReportInfo{ + ReportID: r.ReportID, + ReporterUserID: r.ReporterUserID, + ReportedUserID: r.ReportedUserID, + ConversationID: r.ConversationID, + ClientMsgID: r.ClientMsgID, + Seq: r.Seq, + ReasonType: r.ReasonType, + Reason: r.Reason, + Status: r.Status, + CreateTime: r.CreateTime.UnixMilli(), + HandleTime: r.HandleTime.UnixMilli(), + HandlerUserID: r.HandlerUserID, + Ex: r.Ex, + } + }) + + return &msg.GetSpamReportsResp{ + Reports: pbReports, + Total: uint32(total), + }, nil +} + +func (m *msgServer) HandleSpamReport(ctx context.Context, req *msg.HandleSpamReportReq) (*msg.HandleSpamReportResp, error) { + if err := authverify.CheckAdmin(ctx, m.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if req.ReportID == "" { + return nil, errs.ErrArgs.WrapMsg("reportID is required") + } + if req.Status != model.SpamReportStatusHandled && req.Status != model.SpamReportStatusIgnored { + return nil, errs.ErrArgs.WrapMsg("status must be 1 (handled) or 2 (ignored)") + } + + handlerUserID := mcontext.GetOpUserID(ctx) + if err := m.spamReportDB.UpdateStatus(ctx, req.ReportID, req.Status, handlerUserID, time.Now()); err != nil { + return nil, err + } + return &msg.HandleSpamReportResp{}, nil +} diff --git a/internal/rpc/msg/server.go b/internal/rpc/msg/server.go index df5a72075..2b91c4405 100644 --- a/internal/rpc/msg/server.go +++ b/internal/rpc/msg/server.go @@ -22,6 +22,7 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/config" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/cache/redis" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo" "github.com/openimsdk/open-im-server/v3/pkg/common/webhook" "github.com/openimsdk/protocol/sdkws" @@ -69,6 +70,7 @@ type msgServer struct { config *Config // Global configuration settings. webhookClient *webhook.Client conversationClient *rpcli.ConversationClient + spamReportDB database.SpamReport } func (m *msgServer) addInterceptorHandler(interceptorFunc ...MessageInterceptorFunc) { @@ -121,6 +123,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg return err } conversationClient := rpcli.NewConversationClient(conversationConn) + spamReportDB, err := mgo.NewSpamReportMongo(mgocli.GetDB()) + if err != nil { + return err + } s := &msgServer{ MsgDatabase: msgDatabase, RegisterCenter: client, @@ -131,6 +137,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg config: config, webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), conversationClient: conversationClient, + spamReportDB: spamReportDB, } s.notificationSender = notification.NewNotificationSender(&config.NotificationConfig, notification.WithLocalSendMsg(s.SendMsg)) diff --git a/pkg/common/storage/database/mgo/spam_report.go b/pkg/common/storage/database/mgo/spam_report.go new file mode 100644 index 000000000..5c8802e48 --- /dev/null +++ b/pkg/common/storage/database/mgo/spam_report.go @@ -0,0 +1,110 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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/db/mongoutil" + "github.com/openimsdk/tools/db/pagination" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewSpamReportMongo(db *mongo.Database) (database.SpamReport, error) { + coll := db.Collection(database.SpamReportName) + _, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{ + { + Keys: bson.D{{Key: "report_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: "reporter_user_id", Value: 1}, + {Key: "create_time", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "reported_user_id", Value: 1}, + {Key: "create_time", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "status", Value: 1}, + {Key: "create_time", Value: -1}, + }, + }, + }) + if err != nil { + return nil, err + } + return &SpamReportMgo{coll: coll}, nil +} + +type SpamReportMgo struct { + coll *mongo.Collection +} + +func (s *SpamReportMgo) Create(ctx context.Context, report *model.SpamReport) error { + return mongoutil.InsertOne(ctx, s.coll, report) +} + +func (s *SpamReportMgo) Find(ctx context.Context, status int32, reportedUserID, reporterUserID string, + start, end time.Time, pagination pagination.Pagination) (int64, []*model.SpamReport, error) { + filter := bson.M{} + if status >= 0 { + filter["status"] = status + } + if reportedUserID != "" { + filter["reported_user_id"] = reportedUserID + } + if reporterUserID != "" { + filter["reporter_user_id"] = reporterUserID + } + if !start.IsZero() || !end.IsZero() { + timeFilter := bson.M{} + if !start.IsZero() { + timeFilter["$gte"] = start + } + if !end.IsZero() { + timeFilter["$lte"] = end + } + filter["create_time"] = timeFilter + } + return mongoutil.FindPage[*model.SpamReport](ctx, s.coll, filter, pagination, + options.Find().SetSort(bson.D{{Key: "create_time", Value: -1}})) +} + +func (s *SpamReportMgo) UpdateStatus(ctx context.Context, reportID string, status int32, handlerUserID string, handleTime time.Time) error { + return mongoutil.UpdateOne(ctx, s.coll, + bson.M{"report_id": reportID}, + bson.M{"$set": bson.M{ + "status": status, + "handler_user_id": handlerUserID, + "handle_time": handleTime, + }}, + false, + ) +} + +func (s *SpamReportMgo) Get(ctx context.Context, reportID string) (*model.SpamReport, error) { + return mongoutil.FindOne[*model.SpamReport](ctx, s.coll, bson.M{"report_id": reportID}) +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 8f6241e49..100e6d112 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -21,4 +21,5 @@ const ( PhoneSNInfoName = "phone_sn_info" SignalInvitationName = "signal_invitation" SignalRecordName = "signal_record" + SpamReportName = "spam_report" ) diff --git a/pkg/common/storage/database/spam_report.go b/pkg/common/storage/database/spam_report.go new file mode 100644 index 000000000..ccaec7798 --- /dev/null +++ b/pkg/common/storage/database/spam_report.go @@ -0,0 +1,35 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "context" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/pagination" +) + +type SpamReport interface { + // Create inserts a new spam report record. + Create(ctx context.Context, report *model.SpamReport) error + // Find queries spam reports with optional filters, returns total count and records. + Find(ctx context.Context, status int32, reportedUserID, reporterUserID string, + start, end time.Time, pagination pagination.Pagination) (int64, []*model.SpamReport, error) + // UpdateStatus updates the handling status of a spam report. + UpdateStatus(ctx context.Context, reportID string, status int32, handlerUserID string, handleTime time.Time) error + // Get retrieves a single spam report by its reportID. + Get(ctx context.Context, reportID string) (*model.SpamReport, error) +} diff --git a/pkg/common/storage/model/spam_report.go b/pkg/common/storage/model/spam_report.go new file mode 100644 index 000000000..644798c01 --- /dev/null +++ b/pkg/common/storage/model/spam_report.go @@ -0,0 +1,53 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// SpamReport status constants. +const ( + SpamReportStatusPending int32 = 0 // 待处理 + SpamReportStatusHandled int32 = 1 // 已处理 + SpamReportStatusIgnored int32 = 2 // 已忽略 +) + +// SpamReport reason type constants. +const ( + SpamReasonTypeSpam int32 = 1 // 垃圾消息 + SpamReasonTypePorn int32 = 2 // 色情内容 + SpamReasonTypeIllegal int32 = 3 // 违法内容 + SpamReasonTypeOther int32 = 4 // 其他 +) + +type SpamReport struct { + ID primitive.ObjectID `bson:"_id"` + ReportID string `bson:"report_id"` + ReporterUserID string `bson:"reporter_user_id"` + ReportedUserID string `bson:"reported_user_id"` + ConversationID string `bson:"conversation_id"` // 举报具体消息时填写 + ClientMsgID string `bson:"client_msg_id"` // 举报具体消息时填写 + Seq int64 `bson:"seq"` + ReasonType int32 `bson:"reason_type"` // 1垃圾 2色情 3违法 4其他 + Reason string `bson:"reason"` + Status int32 `bson:"status"` // 0待处理 1已处理 2已忽略 + CreateTime time.Time `bson:"create_time"` + HandleTime time.Time `bson:"handle_time"` + HandlerUserID string `bson:"handler_user_id"` + Ex string `bson:"ex"` +} From e9e15b9e5d64fb3da412b20be4d35af8078a0ad7 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:44:22 +0800 Subject: [PATCH 06/15] banned user --- internal/rpc/relation/friend.go | 32 +++++++++++++++++++ internal/rpc/user/user.go | 16 ++++++++++ .../storage/controller/user_global_black.go | 6 ++++ 3 files changed, 54 insertions(+) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index b32a8c50d..a0e5c63e9 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -51,6 +51,7 @@ type friendServer struct { relation.UnimplementedFriendServer db controller.FriendDatabase blackDatabase controller.BlackDatabase + globalBlackDB controller.UserGlobalBlackDatabase notificationSender *FriendNotificationSender RegisterCenter discovery.SvcDiscoveryRegistry config *Config @@ -97,6 +98,11 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg return err } + globalBlackMongoDB, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return err + } + userConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.User) if err != nil { return err @@ -133,6 +139,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg blackMongoDB, redis.NewBlackCacheRedis(rdb, &config.LocalCacheConfig, blackMongoDB, redis.GetRocksCacheOptions()), ), + globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMongoDB), notificationSender: notificationSender, RegisterCenter: client, config: config, @@ -296,6 +303,9 @@ func (s *friendServer) GetFriendInfo(ctx context.Context, req *relation.GetFrien if err := authverify.CheckAccessV3(ctx, req.OwnerUserID, s.config.Share.IMAdminUserID); err != nil { return nil, err } + if err := s.checkUsersNotGlobalBlocked(ctx, req.FriendUserIDs); err != nil { + return nil, err + } friends, err := s.db.FindFriendsWithError(ctx, req.OwnerUserID, req.FriendUserIDs) if err != nil { return nil, err @@ -311,6 +321,9 @@ func (s *friendServer) GetDesignatedFriends(ctx context.Context, req *relation.G if err := authverify.CheckAccessV3(ctx, req.OwnerUserID, s.config.Share.IMAdminUserID); err != nil { return nil, err } + if err := s.checkUsersNotGlobalBlocked(ctx, req.FriendUserIDs); err != nil { + return nil, err + } friends, err := s.getFriend(ctx, req.OwnerUserID, req.FriendUserIDs) if err != nil { return nil, err @@ -320,6 +333,25 @@ func (s *friendServer) GetDesignatedFriends(ctx context.Context, req *relation.G }, nil } +// checkUsersNotGlobalBlocked returns ErrUserBlocked if any of the given userIDs are in the global blacklist. +func (s *friendServer) checkUsersNotGlobalBlocked(ctx context.Context, userIDs []string) error { + if len(userIDs) == 0 { + return nil + } + blocked, err := s.globalBlackDB.FindBlocked(ctx, userIDs) + if err != nil { + return err + } + if len(blocked) == 0 { + return nil + } + bannedIDs := make([]string, 0, len(blocked)) + for _, b := range blocked { + bannedIDs = append(bannedIDs, b.UserID) + } + return servererrs.ErrUserBlocked.WrapMsg("user is banned", "userIDs", bannedIDs) +} + func (s *friendServer) getFriend(ctx context.Context, ownerUserID string, friendUserIDs []string) ([]*sdkws.FriendInfo, error) { if len(friendUserIDs) == 0 { return nil, nil diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index f0788215d..777538925 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -62,6 +62,7 @@ type userServer struct { webhookClient *webhook.Client groupClient *rpcli.GroupClient relationClient *rpcli.RelationClient + globalBlackDB controller.UserGlobalBlackDatabase } type Config struct { @@ -109,6 +110,10 @@ func Start(ctx context.Context, config *Config, client registry.SvcDiscoveryRegi msgClient := rpcli.NewMsgClient(msgConn) userCache := redis.NewUserCacheRedis(rdb, &config.LocalCacheConfig, userDB, redis.GetRocksCacheOptions()) database := controller.NewUserDatabase(userDB, userCache, mgocli.GetTx()) + globalBlackMgo, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return err + } localcache.InitLocalCache(&config.LocalCacheConfig) u := &userServer{ online: redis.NewUserOnline(rdb), @@ -121,6 +126,7 @@ func Start(ctx context.Context, config *Config, client registry.SvcDiscoveryRegi groupClient: rpcli.NewGroupClient(groupConn), relationClient: rpcli.NewRelationClient(friendConn), + globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), } pbuser.RegisterUserServer(server, u) return u.db.InitOnce(context.Background(), users) @@ -133,6 +139,16 @@ func (s *userServer) GetDesignateUsers(ctx context.Context, req *pbuser.GetDesig return nil, err } + if blocked, err := s.globalBlackDB.FindBlocked(ctx, req.UserIDs); err != nil { + return nil, err + } else if len(blocked) > 0 { + bannedIDs := make([]string, 0, len(blocked)) + for _, b := range blocked { + bannedIDs = append(bannedIDs, b.UserID) + } + return nil, servererrs.ErrUserBlocked.WrapMsg("user is banned", "userIDs", bannedIDs) + } + resp.UsersInfo = convert.UsersDB2Pb(users) return resp, nil } diff --git a/pkg/common/storage/controller/user_global_black.go b/pkg/common/storage/controller/user_global_black.go index ba1448237..2a6d114d4 100644 --- a/pkg/common/storage/controller/user_global_black.go +++ b/pkg/common/storage/controller/user_global_black.go @@ -16,6 +16,8 @@ type UserGlobalBlackDatabase interface { RemoveBlack(ctx context.Context, userIDs []string) error // IsBlocked 检查用户是否在全局黑名单 IsBlocked(ctx context.Context, userID string) (bool, error) + // FindBlocked 批量查询哪些 userID 在全局黑名单中,返回被封禁的记录 + FindBlocked(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // GetBlackList 分页获取黑名单列表 GetBlackList(ctx context.Context, pagination pagination.Pagination) (count int64, blacks []*model.UserGlobalBlack, err error) } @@ -43,3 +45,7 @@ func (u *userGlobalBlackDatabase) IsBlocked(ctx context.Context, userID string) func (u *userGlobalBlackDatabase) GetBlackList(ctx context.Context, pagination pagination.Pagination) (int64, []*model.UserGlobalBlack, error) { return u.db.Page(ctx, pagination) } + +func (u *userGlobalBlackDatabase) FindBlocked(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) { + return u.db.Find(ctx, userIDs) +} From 24d15d5e36bfc31ffab6af3b5c6b8d014e902f0b Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:54:44 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E9=9D=99=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/relation/friend.go | 33 ++++++++++++++++++++++++++++++ pkg/common/convert/friend.go | 6 ++++++ pkg/common/storage/model/friend.go | 3 +++ 3 files changed, 42 insertions(+) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index a0e5c63e9..38aa23d51 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -39,6 +39,7 @@ import ( conversationpb "github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/relation" "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/protocol/wrapperspb" "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/errs" @@ -531,6 +532,9 @@ func (s *friendServer) GetSpecifiedFriendsInfo(ctx context.Context, req *relatio OperatorUserID: friend.OperatorUserID, Ex: friend.Ex, IsPinned: friend.IsPinned, + IsMute: friend.IsMuted, + MuteDuration: friend.MuteDuration, + MuteEndTime: friend.MuteEndTime, } } @@ -586,6 +590,15 @@ func (s *friendServer) UpdateFriends( if req.Ex != nil { val["ex"] = req.Ex.Value } + if req.IsMute != nil { + val["is_muted"] = req.IsMute.Value + } + if req.MuteDuration != nil { + val["mute_duration"] = req.MuteDuration.Value + } + if req.MuteEndTime != nil { + val["mute_end_time"] = req.MuteEndTime.Value + } if err = s.db.UpdateFriends(ctx, req.OwnerUserID, req.FriendUserIDs, val); err != nil { return nil, err } @@ -606,6 +619,26 @@ func (s *friendServer) UpdateFriends( } } + if req.IsMute != nil { + recvMsgOpt := int32(constant.ReceiveNotNotifyMessage) + if !req.IsMute.Value { + recvMsgOpt = constant.ReceiveMessage + } + for _, friendUserID := range req.FriendUserIDs { + convID := conversationutil.GenConversationIDForSingle(req.OwnerUserID, friendUserID) + if err := s.conversationClient.SetConversations(ctx, []string{req.OwnerUserID}, + &conversationpb.ConversationReq{ + ConversationID: convID, + ConversationType: constant.SingleChatType, + UserID: friendUserID, + RecvMsgOpt: &wrapperspb.Int32Value{Value: recvMsgOpt}, + }); err != nil { + log.ZWarn(ctx, "sync conversation recvMsgOpt failed", err, + "ownerUserID", req.OwnerUserID, "friendUserID", friendUserID) + } + } + } + resp := &relation.UpdateFriendsResp{} s.notificationSender.FriendsInfoUpdateNotification(ctx, req.OwnerUserID, req.FriendUserIDs) diff --git a/pkg/common/convert/friend.go b/pkg/common/convert/friend.go index e783ecb24..994c6d7d5 100644 --- a/pkg/common/convert/friend.go +++ b/pkg/common/convert/friend.go @@ -80,6 +80,9 @@ func FriendsDB2Pb(ctx context.Context, friendsDB []*model.Friend, getUsers func( friendPb.FriendUser.Ex = users[friend.FriendUserID].Ex friendPb.CreateTime = friend.CreateTime.Unix() friendPb.IsPinned = friend.IsPinned + friendPb.IsMute = friend.IsMuted + friendPb.MuteDuration = friend.MuteDuration + friendPb.MuteEndTime = friend.MuteEndTime friendsPb = append(friendsPb, friendPb) } return friendsPb, nil @@ -96,6 +99,9 @@ func FriendOnlyDB2PbOnly(friendsDB []*model.Friend) []*relation.FriendInfoOnly { OperatorUserID: f.OperatorUserID, Ex: f.Ex, IsPinned: f.IsPinned, + IsMute: f.IsMuted, + MuteDuration: f.MuteDuration, + MuteEndTime: f.MuteEndTime, } }) } diff --git a/pkg/common/storage/model/friend.go b/pkg/common/storage/model/friend.go index abcca2f2b..7ba5dcb61 100644 --- a/pkg/common/storage/model/friend.go +++ b/pkg/common/storage/model/friend.go @@ -30,4 +30,7 @@ type Friend struct { OperatorUserID string `bson:"operator_user_id"` Ex string `bson:"ex"` IsPinned bool `bson:"is_pinned"` + IsMuted bool `bson:"is_muted"` + MuteDuration int64 `bson:"mute_duration"` // 单位:秒 + MuteEndTime int64 `bson:"mute_end_time"` // Unix 毫秒时间戳,0 表示永久 } From 33e2a713626371a48fb9a38a653190087cd0d95e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:57:17 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=E5=89=94=E9=99=A4=E8=AE=BE=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/auth.go | 8 +++++++ internal/api/router.go | 3 ++- internal/rpc/auth/auth.go | 47 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/api/auth.go b/internal/api/auth.go index 92d911b71..3892b140e 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -43,3 +43,11 @@ func (o *AuthApi) ParseToken(c *gin.Context) { func (o *AuthApi) ForceLogout(c *gin.Context) { a2r.Call(c, auth.AuthClient.ForceLogout, o.Client) } + +func (o *AuthApi) GetActiveDevices(c *gin.Context) { + a2r.Call(c, auth.AuthClient.GetActiveDevices, o.Client) +} + +func (o *AuthApi) KickDevice(c *gin.Context) { + a2r.Call(c, auth.AuthClient.KickDevice, o.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index 06e08dde3..b71be4ba6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -238,7 +238,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co authRouterGroup.POST("/get_user_token", a.GetUserToken) authRouterGroup.POST("/parse_token", a.ParseToken) authRouterGroup.POST("/force_logout", a.ForceLogout) - + authRouterGroup.POST("/get_active_devices", a.GetActiveDevices) + authRouterGroup.POST("/kick_device", a.KickDevice) } // Third service { diff --git a/internal/rpc/auth/auth.go b/internal/rpc/auth/auth.go index 9a909c520..bb7a95ce1 100644 --- a/internal/rpc/auth/auth.go +++ b/internal/rpc/auth/auth.go @@ -294,3 +294,50 @@ func (s *authServer) KickTokens(ctx context.Context, req *pbauth.KickTokensReq) } return &pbauth.KickTokensResp{}, nil } + +// GetActiveDevices returns all platforms that have at least one valid (non-kicked) token for the user. +// Only the user themselves or an admin can call this. +func (s *authServer) GetActiveDevices(ctx context.Context, req *pbauth.GetActiveDevicesReq) (*pbauth.GetActiveDevicesResp, error) { + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + + var devices []*pbauth.DeviceInfo + for platformID, platformName := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + m, err := s.authDatabase.GetTokensWithoutError(ctx, req.UserID, platformID) + if err != nil { + return nil, err + } + for _, state := range m { + if state == constant.NormalToken { + devices = append(devices, &pbauth.DeviceInfo{ + PlatformID: int32(platformID), + PlatformName: platformName, + }) + break + } + } + } + return &pbauth.GetActiveDevicesResp{Devices: devices}, nil +} + +// KickDevice kicks the specified platform device offline for the given user. +// Only the user themselves or an admin can call this. +func (s *authServer) KickDevice(ctx context.Context, req *pbauth.KickDeviceReq) (*pbauth.KickDeviceResp, error) { + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if req.PlatformID == constant.AdminPlatformID { + return nil, errs.ErrArgs.WrapMsg("cannot kick admin platform") + } + if _, ok := constant.PlatformID2Name[int(req.PlatformID)]; !ok { + return nil, errs.ErrArgs.WrapMsg("invalid platformID", "platformID", req.PlatformID) + } + if err := s.forceKickOff(ctx, req.UserID, req.PlatformID); err != nil { + return nil, err + } + return &pbauth.KickDeviceResp{}, nil +} From 42064d31a7501766d0d62bf85f3e2d4c91a473c0 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:50:38 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 5 ++ internal/api/user.go | 12 +++ internal/rpc/group/convert.go | 4 + internal/rpc/group/db_map.go | 28 +++++++ internal/rpc/group/group.go | 44 +++++++++-- internal/rpc/msg/verify.go | 36 ++++++++- internal/rpc/rtc/server.go | 31 +++++--- internal/rpc/rtc/signal.go | 45 +++++++++++ internal/rpc/user/user.go | 119 ++++++++++++++++++++++++++++- pkg/common/convert/user.go | 16 ++++ pkg/common/servererrs/code.go | 1 + pkg/common/servererrs/predefine.go | 1 + pkg/common/storage/model/group.go | 15 ++++ pkg/common/storage/model/user.go | 32 ++++++++ pkg/rpcli/relation.go | 12 +++ 15 files changed, 379 insertions(+), 22 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index b71be4ba6..7e3c6a669 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -159,6 +159,11 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/update_notification_account", u.UpdateNotificationAccountInfo) userRouterGroup.POST("/search_notification_account", u.SearchNotificationAccount) + // 手机号可见性设置(所有人/仅好友/隐藏) + userRouterGroup.POST("/set_phone_visibility", u.SetPhoneVisibility) + userRouterGroup.POST("/set_call_accept_setting", u.SetCallAcceptSetting) + userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) + // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index a43766860..8482bf59f 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -305,3 +305,15 @@ func (u *UserApi) UpdateNotificationAccountInfo(c *gin.Context) { func (u *UserApi) SearchNotificationAccount(c *gin.Context) { a2r.Call(c, user.UserClient.SearchNotificationAccount, u.Client) } + +func (u *UserApi) SetPhoneVisibility(c *gin.Context) { + a2r.Call(c, user.UserClient.SetPhoneVisibility, u.Client) +} + +func (u *UserApi) SetCallAcceptSetting(c *gin.Context) { + a2r.Call(c, user.UserClient.SetCallAcceptSetting, u.Client) +} + +func (u *UserApi) SetMsgReceiveSetting(c *gin.Context) { + a2r.Call(c, user.UserClient.SetMsgReceiveSetting, u.Client) +} diff --git a/internal/rpc/group/convert.go b/internal/rpc/group/convert.go index 8026430c3..e1236bc0f 100644 --- a/internal/rpc/group/convert.go +++ b/internal/rpc/group/convert.go @@ -38,6 +38,10 @@ func (s *groupServer) groupDB2PB(group *model.Group, ownerUserID string, memberC ApplyMemberFriend: group.ApplyMemberFriend, NotificationUpdateTime: group.NotificationUpdateTime.UnixMilli(), NotificationUserID: group.NotificationUserID, + AllowSendMsg: group.AllowSendMsg, + AllowPinMsg: group.AllowPinMsg, + AllowAddMember: group.AllowAddMember, + AllowEditGroupInfo: group.AllowEditGroupInfo, } } diff --git a/internal/rpc/group/db_map.go b/internal/rpc/group/db_map.go index 7504bc851..36bb1de16 100644 --- a/internal/rpc/group/db_map.go +++ b/internal/rpc/group/db_map.go @@ -53,6 +53,18 @@ func UpdateGroupInfoMap(ctx context.Context, group *sdkws.GroupInfoForSet) map[s if group.Ex != nil { m["ex"] = group.Ex.Value } + if group.AllowSendMsg != nil { + m["allow_send_msg"] = group.AllowSendMsg.Value + } + if group.AllowPinMsg != nil { + m["allow_pin_msg"] = group.AllowPinMsg.Value + } + if group.AllowAddMember != nil { + m["allow_add_member"] = group.AllowAddMember.Value + } + if group.AllowEditGroupInfo != nil { + m["allow_edit_group_info"] = group.AllowEditGroupInfo.Value + } return m } @@ -100,6 +112,22 @@ func UpdateGroupInfoExMap(ctx context.Context, group *pbgroup.SetGroupInfoExReq) m["ex"] = group.Ex.Value normalFlag = true } + if group.AllowSendMsg != nil { + m["allow_send_msg"] = group.AllowSendMsg.Value + normalFlag = true + } + if group.AllowPinMsg != nil { + m["allow_pin_msg"] = group.AllowPinMsg.Value + normalFlag = true + } + if group.AllowAddMember != nil { + m["allow_add_member"] = group.AllowAddMember.Value + normalFlag = true + } + if group.AllowEditGroupInfo != nil { + m["allow_edit_group_info"] = group.AllowEditGroupInfo.Value + normalFlag = true + } return m, normalFlag, groupNameFlag, notificationFlag, nil } diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go index c7880d360..acb816831 100644 --- a/internal/rpc/group/group.go +++ b/internal/rpc/group/group.go @@ -458,6 +458,11 @@ func (s *groupServer) InviteUserToGroup(ctx context.Context, req *pbgroup.Invite if err := s.PopulateGroupMember(ctx, groupMember); err != nil { return nil, err } + // AllowAddMember == 1 时仅群主/管理员可拉人 + isOwnerOrAdmin := groupMember.RoleLevel == constant.GroupOwner || groupMember.RoleLevel == constant.GroupAdmin + if group.AllowAddMember == model.GroupPermAdminOnly && !isOwnerOrAdmin { + return nil, errs.ErrNoPermission.WrapMsg("only owner or admin can add members to this group") + } } else { opUserID = mcontext.GetOpUserID(ctx) } @@ -1098,8 +1103,22 @@ func (s *groupServer) SetGroupInfo(ctx context.Context, req *pbgroup.SetGroupInf if err != nil { return nil, err } - if !(opMember.RoleLevel == constant.GroupOwner || opMember.RoleLevel == constant.GroupAdmin) { - return nil, errs.ErrNoPermission.WrapMsg("no group owner or admin") + isOwnerOrAdmin := opMember.RoleLevel == constant.GroupOwner || opMember.RoleLevel == constant.GroupAdmin + requestsPermField := req.GroupInfoForSet.AllowSendMsg != nil || + req.GroupInfoForSet.AllowPinMsg != nil || + req.GroupInfoForSet.AllowAddMember != nil || + req.GroupInfoForSet.AllowEditGroupInfo != nil + if requestsPermField && !isOwnerOrAdmin { + return nil, errs.ErrNoPermission.WrapMsg("only owner or admin can change group permission settings") + } + if !isOwnerOrAdmin { + grp, err := s.db.TakeGroup(ctx, req.GroupInfoForSet.GroupID) + if err != nil { + return nil, err + } + if grp.AllowEditGroupInfo == model.GroupPermAdminOnly { + return nil, errs.ErrNoPermission.WrapMsg("only owner or admin can edit group info") + } } if err := s.PopulateGroupMember(ctx, opMember); err != nil { return nil, err @@ -1193,9 +1212,24 @@ func (s *groupServer) SetGroupInfoEx(ctx context.Context, req *pbgroup.SetGroupI if err != nil { return nil, err } - - if !(opMember.RoleLevel == constant.GroupOwner || opMember.RoleLevel == constant.GroupAdmin) { - return nil, errs.ErrNoPermission.WrapMsg("no group owner or admin") + isOwnerOrAdmin := opMember.RoleLevel == constant.GroupOwner || opMember.RoleLevel == constant.GroupAdmin + // 4个群权限字段始终只有群主/管理员可修改 + requestsPermField := req.AllowSendMsg != nil || + req.AllowPinMsg != nil || + req.AllowAddMember != nil || + req.AllowEditGroupInfo != nil + if requestsPermField && !isOwnerOrAdmin { + return nil, errs.ErrNoPermission.WrapMsg("only owner or admin can change group permission settings") + } + // 其他字段:按 AllowEditGroupInfo 决定是否允许普通成员操作 + if !isOwnerOrAdmin { + grp, err := s.db.TakeGroup(ctx, req.GroupID) + if err != nil { + return nil, err + } + if grp.AllowEditGroupInfo == model.GroupPermAdminOnly { + return nil, errs.ErrNoPermission.WrapMsg("only owner or admin can edit group info") + } } if err := s.PopulateGroupMember(ctx, opMember); err != nil { diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index f6c3147ba..d6e1ea6a2 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -59,9 +59,7 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe data.MsgData.ContentType >= constant.NotificationBegin { return nil } - if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { - return err - } + // 先做本地轻量级拦截(黑名单 + 消息接收权限),避免不必要的 webhook 触发 black, err := m.FriendLocalCache.IsBlack(ctx, data.MsgData.SendID, data.MsgData.RecvID) if err != nil { return err @@ -69,6 +67,33 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe if black { return servererrs.ErrBlockedByPeer.Wrap() } + // 校验接收方消息接收权限(MsgReceiveSetting) + // 0=所有人可发送,1=仅好友可发送,2=所有人不可发送 + recvUserInfo, err := m.UserLocalCache.GetUserInfo(ctx, data.MsgData.RecvID) + if err != nil { + return err + } + switch recvUserInfo.MsgReceiveSetting { + case 2: // MsgReceiveSettingNobody + return servererrs.ErrMsgReceiveNotAllowed.Wrap() + case 1: // MsgReceiveSettingFriends + isFriend, err := m.FriendLocalCache.IsFriend(ctx, data.MsgData.RecvID, data.MsgData.SendID) + if err != nil { + return err + } + if !isFriend { + return servererrs.ErrMsgReceiveNotAllowed.Wrap() + } + // 已确认是好友,触发 webhook 后放行,不做 FriendVerify 冗余查询 + if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { + return err + } + return nil + } + // MsgReceiveSetting==0(所有人可发),触发 webhook,再按全局 FriendVerify 兜底 + if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { + return err + } if m.config.RpcConfig.FriendVerify { friend, err := m.FriendLocalCache.IsFriend(ctx, data.MsgData.SendID, data.MsgData.RecvID) if err != nil { @@ -77,7 +102,6 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe if !friend { return servererrs.ErrNotPeersFriend.Wrap() } - return nil } return nil case constant.ReadGroupChatType: @@ -124,6 +148,10 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe if groupInfo.Status == constant.GroupStatusMuted && groupMemberInfo.RoleLevel != constant.GroupAdmin { return servererrs.ErrMutedGroup.Wrap() } + // AllowSendMsg == 1 时仅群主/管理员可发消息 + if groupInfo.AllowSendMsg == 1 && groupMemberInfo.RoleLevel != constant.GroupAdmin { + return servererrs.ErrNoPermission.WrapMsg("only owner or admin can send messages in this group") + } } return nil default: diff --git a/internal/rpc/rtc/server.go b/internal/rpc/rtc/server.go index 3512afa4f..2877817d2 100644 --- a/internal/rpc/rtc/server.go +++ b/internal/rpc/rtc/server.go @@ -39,12 +39,13 @@ type Config struct { type rtcServer struct { rtc.UnimplementedRtcServiceServer - config *Config - db controller.RtcDatabase - roomClient *lksdk.RoomServiceClient - msgClient *rpcli.MsgClient - userClient *rpcli.UserClient - tokenExpiry time.Duration + config *Config + db controller.RtcDatabase + roomClient *lksdk.RoomServiceClient + msgClient *rpcli.MsgClient + userClient *rpcli.UserClient + relationClient *rpcli.RelationClient + tokenExpiry time.Duration } // Start initialises the RTC gRPC service and registers it with the gRPC server. @@ -69,6 +70,11 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist return err } + friendConn, err := client.GetConn(ctx, cfg.Share.RpcRegisterName.Friend) + if err != nil { + return err + } + lk := cfg.RpcConfig.LiveKit roomClient := lksdk.NewRoomServiceClient(lk.InternalAddress, lk.APIKey, lk.APISecret) @@ -78,12 +84,13 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist } s := &rtcServer{ - config: cfg, - db: controller.NewRtcDatabase(signalDB), - roomClient: roomClient, - msgClient: rpcli.NewMsgClient(msgConn), - userClient: rpcli.NewUserClient(userConn), - tokenExpiry: tokenExpiry, + config: cfg, + db: controller.NewRtcDatabase(signalDB), + roomClient: roomClient, + msgClient: rpcli.NewMsgClient(msgConn), + userClient: rpcli.NewUserClient(userConn), + relationClient: rpcli.NewRelationClient(friendConn), + tokenExpiry: tokenExpiry, } rtc.RegisterRtcServiceServer(server, s) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index 02d7c077f..69551d57f 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -99,6 +99,18 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() + // 校验每位被邀请者的通话接受权限,1-to-1 场景:有任一被邀请者拒绝则直接返错 + for _, inviteeID := range inv.InviteeUserIDList { + allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID) + if err != nil { + log.ZError(ctx, "handleInvite: isCallAllowed failed", err, "inviteeID", inviteeID) + return nil, err + } + if !allowed { + return nil, errs.ErrNoPermission.WrapMsg("the invitee does not accept calls from you", "inviteeID", inviteeID) + } + } + if _, err := s.roomClient.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: inv.RoomID}); err != nil { log.ZError(ctx, "handleInvite", err, "r", err.Error()) return nil, errs.WrapMsg(err, "LiveKit CreateRoom failed", "roomID", inv.RoomID) @@ -157,6 +169,15 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi content := marshalSignalReq(signalReq) for _, inviteeID := range inv.InviteeUserIDList { + allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID) + if err != nil { + log.ZWarn(ctx, "handleInviteInGroup: isCallAllowed failed, skipping invitee", err, "inviteeID", inviteeID) + continue + } + if !allowed { + log.ZInfo(ctx, "handleInviteInGroup: skipping invitee (call setting blocked)", "inviteeID", inviteeID) + continue + } if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, int32(constant.ReadGroupChatType), req.OfflinePushInfo, content); err != nil { log.ZWarn(ctx, "sendSignalingNotification to group invitee failed", err, "inviteeID", inviteeID) } @@ -169,6 +190,30 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi }, nil } +// isCallAllowed 判断 inviterID 是否被允许向 inviteeID 发起音视频通话。 +// 规则: +// - CallAcceptSettingPublic(0) → 所有人均可 +// - CallAcceptSettingFriends(1) → 仅当 inviterID 在 inviteeID 好友列表中 +// - CallAcceptSettingNobody(2) → 任何人均不可 +func (s *rtcServer) isCallAllowed(ctx context.Context, inviterID, inviteeID string) (bool, error) { + userInfo, err := s.userClient.GetUserInfo(ctx, inviteeID) + if err != nil { + return false, err + } + switch userInfo.CallAcceptSetting { + case model.CallAcceptSettingNobody: + return false, nil + case model.CallAcceptSettingFriends: + isFriend, err := s.relationClient.IsFriend(ctx, inviteeID, inviterID) + if err != nil { + return false, err + } + return isFriend, nil + default: // CallAcceptSettingPublic + return true, nil + } +} + // handleAccept processes a call acceptance. func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, signalReq *rtc.SignalReq) (*rtc.SignalAcceptResp, error) { inv := req.Invitation diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index 777538925..d84b27d3a 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -35,6 +35,7 @@ import ( "github.com/openimsdk/protocol/group" friendpb "github.com/openimsdk/protocol/relation" "github.com/openimsdk/tools/db/redisutil" + "github.com/openimsdk/tools/mcontext" "github.com/openimsdk/open-im-server/v3/pkg/authverify" "github.com/openimsdk/open-im-server/v3/pkg/common/convert" @@ -149,10 +150,50 @@ func (s *userServer) GetDesignateUsers(ctx context.Context, req *pbuser.GetDesig return nil, servererrs.ErrUserBlocked.WrapMsg("user is banned", "userIDs", bannedIDs) } - resp.UsersInfo = convert.UsersDB2Pb(users) + pbUsers := convert.UsersDB2Pb(users) + viewerID := mcontext.GetOpUserID(ctx) + if err := s.applyPhoneVisibility(ctx, viewerID, pbUsers, users); err != nil { + return nil, err + } + resp.UsersInfo = pbUsers return resp, nil } +// applyPhoneVisibility 根据 phone_visibility 和好友关系决定是否下发明文手机号。 +// pbUsers 与 dbUsers 下标一一对应。 +func (s *userServer) applyPhoneVisibility(ctx context.Context, viewerID string, pbUsers []*sdkws.UserInfo, dbUsers []*tablerelation.User) error { + for i, db := range dbUsers { + pb := pbUsers[i] + if db.Phone == "" { + // 未设置手机号,直接跳过 + continue + } + switch db.PhoneVisibility { + case tablerelation.PhoneVisibilityPublic: + // 所有人可见,保留 phone 字段(已由 UserDB2Pb 填充) + case tablerelation.PhoneVisibilityHidden: + // 完全隐藏:即使本人也不通过此接口暴露,客户端自行从个人设置接口获取 + pb.Phone = "" + case tablerelation.PhoneVisibilityFriends: + // 仅好友可见 + if viewerID == db.UserID { + // 本人始终可见 + break + } + isFriend, err := s.relationClient.IsFriend(ctx, viewerID, db.UserID) + if err != nil { + return err + } + if !isFriend { + pb.Phone = "" + } + default: + pb.Phone = "" + } + } + return nil +} + // deprecated: // UpdateUserInfo func (s *userServer) UpdateUserInfo(ctx context.Context, req *pbuser.UpdateUserInfoReq) (resp *pbuser.UpdateUserInfoResp, err error) { @@ -237,6 +278,82 @@ func (s *userServer) SetGlobalRecvMessageOpt(ctx context.Context, req *pbuser.Se return resp, nil } +// SetPhoneVisibility 设置手机号及其可见性(0=所有人,1=仅好友,2=隐藏)。 +// 只允许本人或管理员操作。 +func (s *userServer) SetPhoneVisibility(ctx context.Context, req *pbuser.SetPhoneVisibilityReq) (*pbuser.SetPhoneVisibilityResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.PhoneVisibility < 0 || req.PhoneVisibility > 2 { + return nil, errs.ErrArgs.WrapMsg("phoneVisibility must be 0, 1 or 2") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + return nil, err + } + m := map[string]any{ + "phone_visibility": req.PhoneVisibility, + } + if req.Phone != "" { + m["phone"] = req.Phone + } + if err := s.db.UpdateByMap(ctx, req.UserID, m); err != nil { + return nil, err + } + s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + return &pbuser.SetPhoneVisibilityResp{}, nil +} + +// SetCallAcceptSetting 设置音视频通话接受权限(0=所有人,1=仅好友,2=不接受任何通话)。 +// 只允许本人或管理员操作。 +func (s *userServer) SetCallAcceptSetting(ctx context.Context, req *pbuser.SetCallAcceptSettingReq) (*pbuser.SetCallAcceptSettingResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.CallAcceptSetting < 0 || req.CallAcceptSetting > 2 { + return nil, errs.ErrArgs.WrapMsg("callAcceptSetting must be 0, 1 or 2") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + return nil, err + } + if err := s.db.UpdateByMap(ctx, req.UserID, map[string]any{ + "call_accept_setting": req.CallAcceptSetting, + }); err != nil { + return nil, err + } + s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + return &pbuser.SetCallAcceptSettingResp{}, nil +} + +// SetMsgReceiveSetting 设置会话消息接收权限(0=所有人,1=仅好友,2=所有人不可发送)。 +// 只允许本人或管理员操作。 +func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMsgReceiveSettingReq) (*pbuser.SetMsgReceiveSettingResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.MsgReceiveSetting < 0 || req.MsgReceiveSetting > 2 { + return nil, errs.ErrArgs.WrapMsg("msgReceiveSetting must be 0, 1 or 2") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + return nil, err + } + if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + return nil, err + } + if err := s.db.UpdateByMap(ctx, req.UserID, map[string]any{ + "msg_receive_setting": req.MsgReceiveSetting, + }); err != nil { + return nil, err + } + s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + return &pbuser.SetMsgReceiveSettingResp{}, nil +} + func (s *userServer) AccountCheck(ctx context.Context, req *pbuser.AccountCheckReq) (resp *pbuser.AccountCheckResp, err error) { resp = &pbuser.AccountCheckResp{} if datautil.Duplicate(req.CheckUserIDs) { diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index d824fa68e..1130d81e2 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -31,6 +31,10 @@ func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { CreateTime: user.CreateTime.UnixMilli(), AppMangerLevel: user.AppMangerLevel, GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, + Phone: user.Phone, + PhoneVisibility: user.PhoneVisibility, + CallAcceptSetting: user.CallAcceptSetting, + MsgReceiveSetting: user.MsgReceiveSetting, } } @@ -90,6 +94,18 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.GlobalRecvMsgOpt != nil { val["global_recv_msg_opt"] = user.GlobalRecvMsgOpt.Value } + if user.Phone != nil { + val["phone"] = user.Phone.Value + } + if user.PhoneVisibility != nil { + val["phone_visibility"] = user.PhoneVisibility.Value + } + if user.CallAcceptSetting != nil { + val["call_accept_setting"] = user.CallAcceptSetting.Value + } + if user.MsgReceiveSetting != nil { + val["msg_receive_setting"] = user.MsgReceiveSetting.Value + } return val } diff --git a/pkg/common/servererrs/code.go b/pkg/common/servererrs/code.go index 9e4fe9129..7c8fcf616 100644 --- a/pkg/common/servererrs/code.go +++ b/pkg/common/servererrs/code.go @@ -76,6 +76,7 @@ const ( MutedInGroup = 1402 // Member muted in the group MutedGroup = 1403 // Group is muted MsgAlreadyRevoke = 1404 // Message already revoked + MsgReceiveNotAllowed = 1405 // Recipient does not allow receiving messages from this sender // Token error codes. TokenExpiredError = 1501 diff --git a/pkg/common/servererrs/predefine.go b/pkg/common/servererrs/predefine.go index ed44817a2..08debfb86 100644 --- a/pkg/common/servererrs/predefine.go +++ b/pkg/common/servererrs/predefine.go @@ -59,6 +59,7 @@ var ( ErrMutedInGroup = errs.NewCodeError(MutedInGroup, "MutedInGroup") ErrMutedGroup = errs.NewCodeError(MutedGroup, "MutedGroup") ErrMsgAlreadyRevoke = errs.NewCodeError(MsgAlreadyRevoke, "MsgAlreadyRevoke") + ErrMsgReceiveNotAllowed = errs.NewCodeError(MsgReceiveNotAllowed, "MsgReceiveNotAllowed") ErrConnOverMaxNumLimit = errs.NewCodeError(ConnOverMaxNumLimit, "ConnOverMaxNumLimit") diff --git a/pkg/common/storage/model/group.go b/pkg/common/storage/model/group.go index 714fcc782..b34cabd83 100644 --- a/pkg/common/storage/model/group.go +++ b/pkg/common/storage/model/group.go @@ -18,6 +18,13 @@ import ( "time" ) +// GroupPermission 群组操作权限枚举。 +// 0=全员可操作(默认),1=仅群主/管理员可操作 +const ( + GroupPermAllMember = int32(0) // 全员均可 + GroupPermAdminOnly = int32(1) // 仅群主/管理员 +) + type Group struct { GroupID string `bson:"group_id"` GroupName string `bson:"group_name"` @@ -34,4 +41,12 @@ type Group struct { ApplyMemberFriend int32 `bson:"apply_member_friend"` NotificationUpdateTime time.Time `bson:"notification_update_time"` NotificationUserID string `bson:"notification_user_id"` + // AllowSendMsg 0=全员可发消息 1=仅群主/管理员可发消息 + AllowSendMsg int32 `bson:"allow_send_msg"` + // AllowPinMsg 0=全员可置顶消息 1=仅群主/管理员可置顶消息 + AllowPinMsg int32 `bson:"allow_pin_msg"` + // AllowAddMember 0=全员可拉人入群 1=仅群主/管理员可拉人入群 + AllowAddMember int32 `bson:"allow_add_member"` + // AllowEditGroupInfo 0=全员可编辑群资料 1=仅群主/管理员可编辑群资料 + AllowEditGroupInfo int32 `bson:"allow_edit_group_info"` } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index f64d09e79..3903316e0 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -18,6 +18,30 @@ import ( "time" ) +// PhoneVisibility 手机号可见性枚举。 +// 0=所有人可见, 1=仅好友可见, 2=完全隐藏 +const ( + PhoneVisibilityPublic int32 = 0 + PhoneVisibilityFriends int32 = 1 + PhoneVisibilityHidden int32 = 2 +) + +// CallAcceptSetting 音视频通话接受权限枚举。 +// 0=所有人可发起, 1=仅好友可发起, 2=不接受任何通话 +const ( + CallAcceptSettingPublic int32 = 0 + CallAcceptSettingFriends int32 = 1 + CallAcceptSettingNobody int32 = 2 +) + +// MsgReceiveSetting 会话消息接收权限枚举。 +// 0=所有人可发送, 1=仅好友可发送, 2=所有人不可发送 +const ( + MsgReceiveSettingPublic int32 = 0 + MsgReceiveSettingFriends int32 = 1 + MsgReceiveSettingNobody int32 = 2 +) + type User struct { UserID string `bson:"user_id"` Nickname string `bson:"nickname"` @@ -26,6 +50,14 @@ type User struct { AppMangerLevel int32 `bson:"app_manger_level"` GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` CreateTime time.Time `bson:"create_time"` + // Phone 用户手机号(明文,仅服务端留存,下发时按 PhoneVisibility 过滤) + Phone string `bson:"phone"` + // PhoneVisibility 0=所有人可见 1=仅好友可见 2=隐藏 + PhoneVisibility int32 `bson:"phone_visibility"` + // CallAcceptSetting 0=所有人可发起 1=仅好友可发起 2=不接受任何通话 + CallAcceptSetting int32 `bson:"call_accept_setting"` + // MsgReceiveSetting 0=所有人可发送 1=仅好友可发送 2=所有人不可发送 + MsgReceiveSetting int32 `bson:"msg_receive_setting"` } func (u *User) GetNickname() string { diff --git a/pkg/rpcli/relation.go b/pkg/rpcli/relation.go index dce0e7165..ac4d60ef2 100644 --- a/pkg/rpcli/relation.go +++ b/pkg/rpcli/relation.go @@ -21,3 +21,15 @@ func (x *RelationClient) GetFriendsInfo(ctx context.Context, ownerUserID string, req := &relation.GetFriendInfoReq{OwnerUserID: ownerUserID, FriendUserIDs: friendUserIDs} return extractField(ctx, x.FriendClient.GetFriendInfo, req, (*relation.GetFriendInfoResp).GetFriendInfos) } + +// IsFriend checks whether userID2 is in userID1's friend list. +func (x *RelationClient) IsFriend(ctx context.Context, ownerUserID, friendUserID string) (bool, error) { + resp, err := x.FriendClient.IsFriend(ctx, &relation.IsFriendReq{ + UserID1: ownerUserID, + UserID2: friendUserID, + }) + if err != nil { + return false, err + } + return resp.InUser1Friends, nil +} From e9d75d8e7864d40520d783e20f471be66e5db6f4 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:13:04 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/msg/send.go | 41 +++++----- internal/rpc/msg/verify.go | 160 +++++++++++++++++++++++-------------- internal/rpc/user/user.go | 30 +++++++ 3 files changed, 149 insertions(+), 82 deletions(-) diff --git a/internal/rpc/msg/send.go b/internal/rpc/msg/send.go index a45edfe9e..98a9f7616 100644 --- a/internal/rpc/msg/send.go +++ b/internal/rpc/msg/send.go @@ -153,13 +153,12 @@ func (m *msgServer) sendMsgNotification(ctx context.Context, req *pbmsg.SendMsgR } func (m *msgServer) sendMsgSingleChat(ctx context.Context, req *pbmsg.SendMsgReq) (resp *pbmsg.SendMsgResp, err error) { - if err := m.messageVerification(ctx, req); err != nil { - return nil, err - } - isSend := true isNotification := msgprocessor.IsNotificationByMsg(req.MsgData) + log.ZInfo(ctx, "sendMsgSingleChat", "isNotification", isNotification, "msgdata", req.MsgData) + + isSend := true if !isNotification { - log.ZInfo(ctx, "sendMsgSingleChat", "isNotification", isNotification, "msgdata", req.MsgData) + // 非通知类消息:执行发送权限校验 + 接收偏好校验(含 blacklist / MsgReceiveSetting / webhook / FriendVerify / globalOpt / convOpt) isSend, err = m.modifyMessageByUserMessageReceiveOpt( ctx, req.MsgData.RecvID, @@ -174,23 +173,21 @@ func (m *msgServer) sendMsgSingleChat(ctx context.Context, req *pbmsg.SendMsgReq if !isSend { prommetrics.SingleChatMsgProcessFailedCounter.Inc() return nil, nil - } else { - if err := m.webhookBeforeMsgModify(ctx, &m.config.WebhooksConfig.BeforeMsgModify, req); err != nil { - return nil, err - } - - log.ZInfo(ctx, "sendMsgSingleChat", "isNotification", isNotification, "msgdata", req.MsgData) + } - if err := m.MsgDatabase.MsgToMQ(ctx, conversationutil.GenConversationUniqueKeyForSingle(req.MsgData.SendID, req.MsgData.RecvID), req.MsgData); err != nil { - prommetrics.SingleChatMsgProcessFailedCounter.Inc() - return nil, err - } - m.webhookAfterSendSingleMsg(ctx, &m.config.WebhooksConfig.AfterSendSingleMsg, req) - prommetrics.SingleChatMsgProcessSuccessCounter.Inc() - return &pbmsg.SendMsgResp{ - ServerMsgID: req.MsgData.ServerMsgID, - ClientMsgID: req.MsgData.ClientMsgID, - SendTime: req.MsgData.SendTime, - }, nil + if err := m.webhookBeforeMsgModify(ctx, &m.config.WebhooksConfig.BeforeMsgModify, req); err != nil { + return nil, err + } + log.ZInfo(ctx, "sendMsgSingleChat after modify", "isNotification", isNotification, "msgdata", req.MsgData) + if err := m.MsgDatabase.MsgToMQ(ctx, conversationutil.GenConversationUniqueKeyForSingle(req.MsgData.SendID, req.MsgData.RecvID), req.MsgData); err != nil { + prommetrics.SingleChatMsgProcessFailedCounter.Inc() + return nil, err } + m.webhookAfterSendSingleMsg(ctx, &m.config.WebhooksConfig.AfterSendSingleMsg, req) + prommetrics.SingleChatMsgProcessSuccessCounter.Inc() + return &pbmsg.SendMsgResp{ + ServerMsgID: req.MsgData.ServerMsgID, + ClientMsgID: req.MsgData.ClientMsgID, + SendTime: req.MsgData.SendTime, + }, nil } diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index d6e1ea6a2..574cc79c7 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -16,18 +16,19 @@ package msg import ( "context" - "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" - "github.com/openimsdk/tools/utils/datautil" - "github.com/openimsdk/tools/utils/encrypt" - "github.com/openimsdk/tools/utils/timeutil" "math/rand" "strconv" "time" + "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/sdkws" "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" + "github.com/openimsdk/tools/utils/datautil" + "github.com/openimsdk/tools/utils/encrypt" + "github.com/openimsdk/tools/utils/timeutil" ) var ExcludeContentType = []int{constant.HasReadReceipt} @@ -52,61 +53,14 @@ type MessageRevoked struct { func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgReq) error { switch data.MsgData.SessionType { case constant.SingleChatType: - if datautil.Contain(data.MsgData.SendID, m.config.Share.IMAdminUserID...) { - return nil - } - if data.MsgData.ContentType <= constant.NotificationEnd && - data.MsgData.ContentType >= constant.NotificationBegin { - return nil - } - // 先做本地轻量级拦截(黑名单 + 消息接收权限),避免不必要的 webhook 触发 - black, err := m.FriendLocalCache.IsBlack(ctx, data.MsgData.SendID, data.MsgData.RecvID) - if err != nil { - return err - } - if black { - return servererrs.ErrBlockedByPeer.Wrap() - } - // 校验接收方消息接收权限(MsgReceiveSetting) - // 0=所有人可发送,1=仅好友可发送,2=所有人不可发送 - recvUserInfo, err := m.UserLocalCache.GetUserInfo(ctx, data.MsgData.RecvID) - if err != nil { - return err - } - switch recvUserInfo.MsgReceiveSetting { - case 2: // MsgReceiveSettingNobody - return servererrs.ErrMsgReceiveNotAllowed.Wrap() - case 1: // MsgReceiveSettingFriends - isFriend, err := m.FriendLocalCache.IsFriend(ctx, data.MsgData.RecvID, data.MsgData.SendID) - if err != nil { - return err - } - if !isFriend { - return servererrs.ErrMsgReceiveNotAllowed.Wrap() - } - // 已确认是好友,触发 webhook 后放行,不做 FriendVerify 冗余查询 - if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { - return err - } - return nil - } - // MsgReceiveSetting==0(所有人可发),触发 webhook,再按全局 FriendVerify 兜底 - if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { - return err - } - if m.config.RpcConfig.FriendVerify { - friend, err := m.FriendLocalCache.IsFriend(ctx, data.MsgData.SendID, data.MsgData.RecvID) - if err != nil { - return err - } - if !friend { - return servererrs.ErrNotPeersFriend.Wrap() - } - } + // 单聊发送权限校验已迁移至 modifyMessageByUserMessageReceiveOpt return nil case constant.ReadGroupChatType: groupInfo, err := m.GroupLocalCache.GetGroupInfo(ctx, data.MsgData.GroupID) if err != nil { + log.ZError(ctx, "messageVerification group: GetGroupInfo failed", err, + "groupID", data.MsgData.GroupID, "sendID", data.MsgData.SendID, + "contentType", data.MsgData.ContentType, "clientMsgID", data.MsgData.ClientMsgID) return err } if groupInfo.Status == constant.GroupStatusDismissed && @@ -126,6 +80,9 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe } memberIDs, err := m.GroupLocalCache.GetGroupMemberIDMap(ctx, data.MsgData.GroupID) if err != nil { + log.ZError(ctx, "messageVerification group: GetGroupMemberIDMap failed", err, + "groupID", data.MsgData.GroupID, "sendID", data.MsgData.SendID, + "contentType", data.MsgData.ContentType, "clientMsgID", data.MsgData.ClientMsgID) return err } if _, ok := memberIDs[data.MsgData.SendID]; !ok { @@ -137,6 +94,9 @@ func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgRe if errs.ErrRecordNotFound.Is(err) { return servererrs.ErrNotInGroupYet.WrapMsg(err.Error()) } + log.ZError(ctx, "messageVerification group: GetGroupMember failed", err, + "groupID", data.MsgData.GroupID, "sendID", data.MsgData.SendID, + "contentType", data.MsgData.ContentType, "clientMsgID", data.MsgData.ClientMsgID) return err } if groupMemberInfo.RoleLevel == constant.GroupOwner { @@ -211,21 +171,101 @@ func GetMsgID(sendID string) string { } func (m *msgServer) modifyMessageByUserMessageReceiveOpt(ctx context.Context, userID, conversationID string, sessionType int, pb *msg.SendMsgReq) (bool, error) { + // 第一优先级:接收方全局接收设置 + // NotReceiveMessage 直接丢弃,无需执行后续任何权限或偏好查询 opt, err := m.UserLocalCache.GetUserGlobalMsgRecvOpt(ctx, userID) if err != nil { return false, err } - switch opt { - case constant.ReceiveMessage: - case constant.NotReceiveMessage: + if opt == constant.NotReceiveMessage { return false, nil - case constant.ReceiveNotNotifyMessage: + } + if opt == 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 + // 全局静音:仅关闭离线推送,仍需继续执行发送权限校验 + 会话级偏好校验 + } + + // 第二优先级:单聊发送权限校验(从 messageVerification 迁移) + // 仅对非通知类消息生效(调用方已通过 !isNotification 做过前置过滤) + if sessionType == constant.SingleChatType { + // 管理员跳过发送权限拦截,直接进入接收偏好校验 + if !datautil.Contain(pb.MsgData.SendID, m.config.Share.IMAdminUserID...) { + // 黑名单拦截 + black, err := m.FriendLocalCache.IsBlack(ctx, pb.MsgData.SendID, pb.MsgData.RecvID) + if err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: IsBlack failed", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + if black { + return false, servererrs.ErrBlockedByPeer.Wrap() + } + + // 接收方消息接收权限(MsgReceiveSetting) + // 0=所有人可发送,1=仅好友可发送,2=所有人不可发送 + recvUserInfo, err := m.UserLocalCache.GetUserInfo(ctx, pb.MsgData.RecvID) + if err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: GetUserInfo(recv) failed", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + + // skipFriendVerify: MsgReceiveSetting=1 已确认好友关系,无需再做 FriendVerify 重复查询 + skipFriendVerify := false + switch recvUserInfo.MsgReceiveSetting { + case 2: // MsgReceiveSettingNobody + return false, servererrs.ErrMsgReceiveNotAllowed.Wrap() + case 1: // MsgReceiveSettingFriends + isFriend, err := m.FriendLocalCache.IsFriend(ctx, pb.MsgData.RecvID, pb.MsgData.SendID) + if err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: IsFriend failed (MsgReceiveSetting)", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + if !isFriend { + return false, servererrs.ErrMsgReceiveNotAllowed.Wrap() + } + // 已确认好友关系,触发 webhook 后跳过 FriendVerify,直接进入接收偏好校验 + if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, pb); err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: webhookBeforeSendSingleMsg failed (friends-only)", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + skipFriendVerify = true + } + + if !skipFriendVerify { + // MsgReceiveSetting==0(所有人可发),触发 webhook,再按全局 FriendVerify 兜底 + if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, pb); err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: webhookBeforeSendSingleMsg failed", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + if m.config.RpcConfig.FriendVerify { + friend, err := m.FriendLocalCache.IsFriend(ctx, pb.MsgData.SendID, pb.MsgData.RecvID) + if err != nil { + log.ZError(ctx, "modifyMessageByUserMessageReceiveOpt: IsFriend failed (FriendVerify)", err, + "sendID", pb.MsgData.SendID, "recvID", pb.MsgData.RecvID, + "contentType", pb.MsgData.ContentType, "clientMsgID", pb.MsgData.ClientMsgID) + return false, err + } + if !friend { + return false, servererrs.ErrNotPeersFriend.Wrap() + } + } + } + } } + + // 第三优先级:会话级接收偏好 singleOpt, err := m.ConversationLocalCache.GetSingleConversationRecvMsgOpt(ctx, userID, conversationID) if errs.ErrRecordNotFound.Is(err) { return true, nil diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index d84b27d3a..d45d4d995 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -48,6 +48,7 @@ import ( "github.com/openimsdk/tools/db/pagination" registry "github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/utils/datautil" "google.golang.org/grpc" ) @@ -137,10 +138,14 @@ func (s *userServer) GetDesignateUsers(ctx context.Context, req *pbuser.GetDesig resp = &pbuser.GetDesignateUsersResp{} users, err := s.db.Find(ctx, req.UserIDs) if err != nil { + log.ZError(ctx, "GetDesignateUsers: db.Find failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "reqUserCount", len(req.UserIDs)) return nil, err } if blocked, err := s.globalBlackDB.FindBlocked(ctx, req.UserIDs); err != nil { + log.ZError(ctx, "GetDesignateUsers: globalBlackDB.FindBlocked failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "reqUserCount", len(req.UserIDs)) return nil, err } else if len(blocked) > 0 { bannedIDs := make([]string, 0, len(blocked)) @@ -153,6 +158,8 @@ func (s *userServer) GetDesignateUsers(ctx context.Context, req *pbuser.GetDesig pbUsers := convert.UsersDB2Pb(users) viewerID := mcontext.GetOpUserID(ctx) if err := s.applyPhoneVisibility(ctx, viewerID, pbUsers, users); err != nil { + log.ZError(ctx, "GetDesignateUsers: applyPhoneVisibility failed", err, + "opUserID", viewerID, "userCount", len(users)) return nil, err } resp.UsersInfo = pbUsers @@ -182,6 +189,8 @@ func (s *userServer) applyPhoneVisibility(ctx context.Context, viewerID string, } isFriend, err := s.relationClient.IsFriend(ctx, viewerID, db.UserID) if err != nil { + log.ZError(ctx, "applyPhoneVisibility: IsFriend failed", err, + "viewerID", viewerID, "targetUserID", db.UserID) return err } if !isFriend { @@ -288,9 +297,13 @@ func (s *userServer) SetPhoneVisibility(ctx context.Context, req *pbuser.SetPhon return nil, errs.ErrArgs.WrapMsg("phoneVisibility must be 0, 1 or 2") } if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetPhoneVisibility: access denied", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + log.ZError(ctx, "SetPhoneVisibility: user not found or db error", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } m := map[string]any{ @@ -300,6 +313,9 @@ func (s *userServer) SetPhoneVisibility(ctx context.Context, req *pbuser.SetPhon m["phone"] = req.Phone } if err := s.db.UpdateByMap(ctx, req.UserID, m); err != nil { + log.ZError(ctx, "SetPhoneVisibility: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "phoneVisibility", req.PhoneVisibility, "hasPhoneUpdate", req.Phone != "") return nil, err } s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) @@ -316,14 +332,21 @@ func (s *userServer) SetCallAcceptSetting(ctx context.Context, req *pbuser.SetCa return nil, errs.ErrArgs.WrapMsg("callAcceptSetting must be 0, 1 or 2") } if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetCallAcceptSetting: access denied", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + log.ZError(ctx, "SetCallAcceptSetting: user not found or db error", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } if err := s.db.UpdateByMap(ctx, req.UserID, map[string]any{ "call_accept_setting": req.CallAcceptSetting, }); err != nil { + log.ZError(ctx, "SetCallAcceptSetting: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "callAcceptSetting", req.CallAcceptSetting) return nil, err } s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) @@ -340,14 +363,21 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs return nil, errs.ErrArgs.WrapMsg("msgReceiveSetting must be 0, 1 or 2") } if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetMsgReceiveSetting: access denied", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } if _, err := s.db.FindWithError(ctx, []string{req.UserID}); err != nil { + log.ZError(ctx, "SetMsgReceiveSetting: user not found or db error", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) return nil, err } if err := s.db.UpdateByMap(ctx, req.UserID, map[string]any{ "msg_receive_setting": req.MsgReceiveSetting, }); err != nil { + log.ZError(ctx, "SetMsgReceiveSetting: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "msgReceiveSetting", req.MsgReceiveSetting) return nil, err } s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) From d787d49d4868af8d74fbe69eb05a38479f198624 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:18:09 +0800 Subject: [PATCH 11/15] search phone --- config/openim-rpc-user.yml | 3 ++ internal/api/router.go | 2 + internal/api/user.go | 4 ++ internal/rpc/user/user.go | 67 +++++++++++++++++++++++++ pkg/common/config/config.go | 4 ++ pkg/common/storage/controller/user.go | 7 +++ pkg/common/storage/database/mgo/user.go | 21 +++++--- pkg/common/storage/database/user.go | 1 + 8 files changed, 103 insertions(+), 6 deletions(-) diff --git a/config/openim-rpc-user.yml b/config/openim-rpc-user.yml index 7da94ca0d..c3e14bfdf 100644 --- a/config/openim-rpc-user.yml +++ b/config/openim-rpc-user.yml @@ -15,3 +15,6 @@ prometheus: # Prometheus listening ports, must be consistent with the number of rpc.ports # It will only take effect when autoSetPorts is set to false. ports: [ 12320 ] + +# GetUserByPhone: false = ignore phone_visibility when searching by phone; true = enforce phone_visibility (hidden / friends-only). +phoneSearchVisibility: false diff --git a/internal/api/router.go b/internal/api/router.go index 7e3c6a669..5eae5b8ac 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -163,6 +163,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_phone_visibility", u.SetPhoneVisibility) userRouterGroup.POST("/set_call_accept_setting", u.SetCallAcceptSetting) userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) + // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) + userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index 8482bf59f..5bb154481 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -317,3 +317,7 @@ func (u *UserApi) SetCallAcceptSetting(c *gin.Context) { func (u *UserApi) SetMsgReceiveSetting(c *gin.Context) { a2r.Call(c, user.UserClient.SetMsgReceiveSetting, u.Client) } + +func (u *UserApi) GetUserByPhone(c *gin.Context) { + a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) +} diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index d45d4d995..cf9f55528 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -18,6 +18,7 @@ import ( "context" "errors" "math/rand" + "regexp" "strings" "sync" "time" @@ -53,6 +54,10 @@ import ( "google.golang.org/grpc" ) +// phoneRe 仅校验手机号的基本数字格式,不强制区号/国家码前缀。 +// 规则:纯数字,长度 5-20 位,允许可选的 + 前缀(如 +86...)。 +var phoneRe = regexp.MustCompile(`^\+?\d{5,20}$`) + type userServer struct { pbuser.UnimplementedUserServer online cache.OnlineCache @@ -296,6 +301,9 @@ func (s *userServer) SetPhoneVisibility(ctx context.Context, req *pbuser.SetPhon if req.PhoneVisibility < 0 || req.PhoneVisibility > 2 { return nil, errs.ErrArgs.WrapMsg("phoneVisibility must be 0, 1 or 2") } + if req.Phone != "" && !phoneRe.MatchString(req.Phone) { + return nil, errs.ErrArgs.WrapMsg("phone must contain digits only (5-20 digits), optionally prefixed with +") + } if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { log.ZWarn(ctx, "SetPhoneVisibility: access denied", err, "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) @@ -384,6 +392,65 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs return &pbuser.SetMsgReceiveSettingResp{}, nil } +// GetUserByPhone 根据精确手机号查询用户。 +// +// phoneSearchVisibility=false(默认)时忽略 phone_visibility,任何人均可搜到。 +// phoneSearchVisibility=true 时按 phone_visibility 过滤: +// - Hidden(2) → 非管理员不可搜到 +// - Friends(1) → 仅好友/管理员可搜到 +// - Public(0) → 任何人均可搜到 +// +// 返回空 userInfo 并不代表错误,调用方应以 nil userInfo 判断"未找到"。 +func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPhoneReq) (*pbuser.GetUserByPhoneResp, error) { + if req.Phone == "" { + return nil, errs.ErrArgs.WrapMsg("phone is required") + } + if !phoneRe.MatchString(req.Phone) { + return nil, errs.ErrArgs.WrapMsg("phone must contain digits only (5-20 digits), optionally prefixed with +") + } + + dbUser, err := s.db.FindByPhone(ctx, req.Phone) + if err != nil { + if errs.ErrRecordNotFound.Is(err) { + // 手机号未注册,返回空响应而非错误,避免枚举攻击 + return &pbuser.GetUserByPhoneResp{}, nil + } + log.ZError(ctx, "GetUserByPhone: FindByPhone failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "phone", req.Phone) + return nil, err + } + + // 仅在 phoneSearchVisibility=true 时才按 phone_visibility 过滤,默认跳过 + if s.config.RpcConfig.PhoneSearchVisibility { + callerID := mcontext.GetOpUserID(ctx) + isAdmin := datautil.Contain(callerID, s.config.Share.IMAdminUserID...) + + switch dbUser.PhoneVisibility { + case tablerelation.PhoneVisibilityHidden: + // 完全隐藏:非管理员无法通过手机号搜到该用户 + if !isAdmin { + return &pbuser.GetUserByPhoneResp{}, nil + } + case tablerelation.PhoneVisibilityFriends: + // 仅好友可搜索 + if !isAdmin && callerID != dbUser.UserID { + isFriend, err := s.relationClient.IsFriend(ctx, callerID, dbUser.UserID) + if err != nil { + log.ZError(ctx, "GetUserByPhone: IsFriend failed", err, + "callerID", callerID, "targetUserID", dbUser.UserID) + return nil, err + } + if !isFriend { + return &pbuser.GetUserByPhoneResp{}, nil + } + } + } + } + + pbUser := convert.UserDB2Pb(dbUser) + return &pbuser.GetUserByPhoneResp{UserInfo: pbUser}, nil +} + func (s *userServer) AccountCheck(ctx context.Context, req *pbuser.AccountCheckReq) (resp *pbuser.AccountCheckResp, err error) { resp = &pbuser.AccountCheckResp{} if datautil.Duplicate(req.CheckUserIDs) { diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 5c30dea06..05616abea 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -364,6 +364,10 @@ type User struct { Ports []int `mapstructure:"ports"` } `mapstructure:"rpc"` Prometheus Prometheus `mapstructure:"prometheus"` + // PhoneSearchVisibility 控制 GetUserByPhone 是否尊重 phone_visibility 设置。 + // false(默认):任何人均可通过手机号搜到用户,忽略 phone_visibility; + // true:按 phone_visibility 过滤(Hidden 不可搜,Friends 仅好友可搜)。 + PhoneSearchVisibility bool `mapstructure:"phoneSearchVisibility"` } type Redis struct { diff --git a/pkg/common/storage/controller/user.go b/pkg/common/storage/controller/user.go index 3f34481a3..db056ae9a 100644 --- a/pkg/common/storage/controller/user.go +++ b/pkg/common/storage/controller/user.go @@ -37,6 +37,9 @@ type UserDatabase interface { Find(ctx context.Context, userIDs []string) (users []*model.User, err error) // Find userInfo By Nickname FindByNickname(ctx context.Context, nickname string) (users []*model.User, err error) + // FindByPhone looks up a single user by exact phone number. + // Returns errs.ErrRecordNotFound if no user has the given phone. + FindByPhone(ctx context.Context, phone string) (user *model.User, err error) // Find notificationAccounts FindNotification(ctx context.Context, level int64) (users []*model.User, err error) // Create Insert multiple external guarantees that the userID is not repeated and does not exist in the storage @@ -135,6 +138,10 @@ func (u *userDatabase) FindByNickname(ctx context.Context, nickname string) (use return u.userDB.TakeByNickname(ctx, nickname) } +func (u *userDatabase) FindByPhone(ctx context.Context, phone string) (*model.User, error) { + return u.userDB.FindByPhone(ctx, phone) +} + func (u *userDatabase) FindNotification(ctx context.Context, level int64) (users []*model.User, err error) { return u.userDB.TakeNotification(ctx, level) } diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index ee92b7554..b2e437f14 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -32,13 +32,18 @@ import ( func NewUserMongo(db *mongo.Database) (database.User, error) { coll := db.Collection(database.UserName) - _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ - Keys: bson.D{ - {Key: "user_id", Value: 1}, + indexes := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), }, - Options: options.Index().SetUnique(true), - }) - if err != nil { + { + // 支持按手机号快速查找,phone 为空串的文档不参与索引 + Keys: bson.D{{Key: "phone", Value: 1}}, + Options: options.Index().SetSparse(true), + }, + } + if _, err := coll.Indexes().CreateMany(context.Background(), indexes); err != nil { return nil, errs.Wrap(err) } return &UserMgo{coll: coll}, nil @@ -75,6 +80,10 @@ func (u *UserMgo) TakeByNickname(ctx context.Context, nickname string) (user []* return mongoutil.Find[*model.User](ctx, u.coll, bson.M{"nickname": nickname}) } +func (u *UserMgo) FindByPhone(ctx context.Context, phone string) (*model.User, error) { + return mongoutil.FindOne[*model.User](ctx, u.coll, bson.M{"phone": phone}) +} + func (u *UserMgo) Page(ctx context.Context, pagination pagination.Pagination) (count int64, users []*model.User, err error) { return mongoutil.FindPage[*model.User](ctx, u.coll, bson.M{}, pagination) } diff --git a/pkg/common/storage/database/user.go b/pkg/common/storage/database/user.go index 4ddc8285f..7e7df0836 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -29,6 +29,7 @@ type User interface { Take(ctx context.Context, userID string) (user *model.User, err error) TakeNotification(ctx context.Context, level int64) (user []*model.User, err error) TakeByNickname(ctx context.Context, nickname string) (user []*model.User, err error) + FindByPhone(ctx context.Context, phone string) (user *model.User, err error) Page(ctx context.Context, pagination pagination.Pagination) (count int64, users []*model.User, err error) PageFindUser(ctx context.Context, level1 int64, level2 int64, pagination pagination.Pagination) (count int64, users []*model.User, err error) PageFindUserWithKeyword(ctx context.Context, level1 int64, level2 int64, userID, nickName string, pagination pagination.Pagination) (count int64, users []*model.User, err error) From 48e023882a93cf3b37d06732fb59f3cde1e279bd Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:28:59 +0800 Subject: [PATCH 12/15] search nickname --- internal/api/router.go | 2 + internal/api/user.go | 4 ++ internal/rpc/user/user.go | 56 +++++++++++++++++++++++++ pkg/common/storage/controller/user.go | 6 +++ pkg/common/storage/database/mgo/user.go | 12 +++++- pkg/common/storage/database/user.go | 2 + 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/api/router.go b/internal/api/router.go index 5eae5b8ac..f689bec03 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -165,6 +165,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) + // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) + userRouterGroup.POST("/get_users_by_nickname", u.GetUsersByNickname) // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index 5bb154481..994575fa6 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -321,3 +321,7 @@ func (u *UserApi) SetMsgReceiveSetting(c *gin.Context) { func (u *UserApi) GetUserByPhone(c *gin.Context) { a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) } + +func (u *UserApi) GetUsersByNickname(c *gin.Context) { + a2r.Call(c, user.UserClient.GetUsersByNickname, u.Client) +} diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index cf9f55528..66bd1d682 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -22,6 +22,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/openimsdk/open-im-server/v3/internal/rpc/relation" "github.com/openimsdk/open-im-server/v3/pkg/common/config" @@ -451,6 +452,61 @@ func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPh return &pbuser.GetUserByPhoneResp{UserInfo: pbUser}, nil } +// GetUsersByNickname 按昵称精确匹配查询普通用户(app_manger_level 与分页拉取用户一致)。 +// 全局黑名单用户会被过滤;手机号字段按 phone_visibility 与 getDesignateUsers 相同规则处理。 +func (s *userServer) GetUsersByNickname(ctx context.Context, req *pbuser.GetUsersByNicknameReq) (*pbuser.GetUsersByNicknameResp, error) { + nickname := strings.TrimSpace(req.Nickname) + if nickname == "" { + return nil, errs.ErrArgs.WrapMsg("nickname is required") + } + if n := utf8.RuneCountInString(nickname); n < 1 || n > 64 { + return nil, errs.ErrArgs.WrapMsg("nickname length must be 1-64 characters") + } + + users, err := s.db.FindOrdinaryUsersByNickname(ctx, constant.IMOrdinaryUser, constant.AppOrdinaryUsers, nickname) + if err != nil { + log.ZError(ctx, "GetUsersByNickname: FindOrdinaryUsersByNickname failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "nickname", nickname) + return nil, err + } + if len(users) == 0 { + return &pbuser.GetUsersByNicknameResp{}, nil + } + + userIDs := datautil.Slice(users, func(u *tablerelation.User) string { return u.UserID }) + blocked, err := s.globalBlackDB.FindBlocked(ctx, userIDs) + if err != nil { + log.ZError(ctx, "GetUsersByNickname: FindBlocked failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "count", len(userIDs)) + return nil, err + } + if len(blocked) > 0 { + banned := make(map[string]struct{}, len(blocked)) + for _, b := range blocked { + banned[b.UserID] = struct{}{} + } + filtered := make([]*tablerelation.User, 0, len(users)) + for _, u := range users { + if _, ok := banned[u.UserID]; !ok { + filtered = append(filtered, u) + } + } + users = filtered + } + if len(users) == 0 { + return &pbuser.GetUsersByNicknameResp{}, nil + } + + pbUsers := convert.UsersDB2Pb(users) + viewerID := mcontext.GetOpUserID(ctx) + if err := s.applyPhoneVisibility(ctx, viewerID, pbUsers, users); err != nil { + log.ZError(ctx, "GetUsersByNickname: applyPhoneVisibility failed", err, + "opUserID", viewerID, "count", len(users)) + return nil, err + } + return &pbuser.GetUsersByNicknameResp{UsersInfo: pbUsers}, nil +} + func (s *userServer) AccountCheck(ctx context.Context, req *pbuser.AccountCheckReq) (resp *pbuser.AccountCheckResp, err error) { resp = &pbuser.AccountCheckResp{} if datautil.Duplicate(req.CheckUserIDs) { diff --git a/pkg/common/storage/controller/user.go b/pkg/common/storage/controller/user.go index db056ae9a..d1ff44101 100644 --- a/pkg/common/storage/controller/user.go +++ b/pkg/common/storage/controller/user.go @@ -37,6 +37,8 @@ type UserDatabase interface { Find(ctx context.Context, userIDs []string) (users []*model.User, err error) // Find userInfo By Nickname FindByNickname(ctx context.Context, nickname string) (users []*model.User, err error) + // FindOrdinaryUsersByNickname 昵称精确匹配,仅普通用户(与分页拉取用户 level 一致) + FindOrdinaryUsersByNickname(ctx context.Context, level1 int64, level2 int64, nickname string) (users []*model.User, err error) // FindByPhone looks up a single user by exact phone number. // Returns errs.ErrRecordNotFound if no user has the given phone. FindByPhone(ctx context.Context, phone string) (user *model.User, err error) @@ -138,6 +140,10 @@ func (u *userDatabase) FindByNickname(ctx context.Context, nickname string) (use return u.userDB.TakeByNickname(ctx, nickname) } +func (u *userDatabase) FindOrdinaryUsersByNickname(ctx context.Context, level1, level2 int64, nickname string) ([]*model.User, error) { + return u.userDB.FindOrdinaryUsersByNickname(ctx, level1, level2, nickname) +} + func (u *userDatabase) FindByPhone(ctx context.Context, phone string) (*model.User, error) { return u.userDB.FindByPhone(ctx, phone) } diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index b2e437f14..5a2dc7e34 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -38,10 +38,12 @@ func NewUserMongo(db *mongo.Database) (database.User, error) { Options: options.Index().SetUnique(true), }, { - // 支持按手机号快速查找,phone 为空串的文档不参与索引 Keys: bson.D{{Key: "phone", Value: 1}}, Options: options.Index().SetSparse(true), }, + { + Keys: bson.D{{Key: "nickname", Value: 1}}, + }, } if _, err := coll.Indexes().CreateMany(context.Background(), indexes); err != nil { return nil, errs.Wrap(err) @@ -80,6 +82,14 @@ func (u *UserMgo) TakeByNickname(ctx context.Context, nickname string) (user []* return mongoutil.Find[*model.User](ctx, u.coll, bson.M{"nickname": nickname}) } +func (u *UserMgo) FindOrdinaryUsersByNickname(ctx context.Context, level1, level2 int64, nickname string) ([]*model.User, error) { + query := bson.M{ + "nickname": nickname, + "app_manger_level": bson.M{"$in": []int64{level1, level2}}, + } + return mongoutil.Find[*model.User](ctx, u.coll, query, options.Find().SetLimit(100)) +} + func (u *UserMgo) FindByPhone(ctx context.Context, phone string) (*model.User, error) { return mongoutil.FindOne[*model.User](ctx, u.coll, bson.M{"phone": phone}) } diff --git a/pkg/common/storage/database/user.go b/pkg/common/storage/database/user.go index 7e7df0836..2682bc780 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -29,6 +29,8 @@ type User interface { Take(ctx context.Context, userID string) (user *model.User, err error) TakeNotification(ctx context.Context, level int64) (user []*model.User, err error) TakeByNickname(ctx context.Context, nickname string) (user []*model.User, err error) + // FindOrdinaryUsersByNickname 按昵称精确匹配,且 app_manger_level 为普通用户范围(与分页拉取用户一致) + FindOrdinaryUsersByNickname(ctx context.Context, level1 int64, level2 int64, nickname string) (users []*model.User, err error) FindByPhone(ctx context.Context, phone string) (user *model.User, err error) Page(ctx context.Context, pagination pagination.Pagination) (count int64, users []*model.User, err error) PageFindUser(ctx context.Context, level1 int64, level2 int64, pagination pagination.Pagination) (count int64, users []*model.User, err error) From 09460ccefaaf53b2cff21afd680198826baf2f0a Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:14:18 +0800 Subject: [PATCH 13/15] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rtc-signaling.md | 288 ---------------------- internal/rpc/msg/send.go | 5 +- internal/rpc/msg/verify.go | 1 - internal/rpc/rtc/signal.go | 258 ++++++++++++++----- pkg/common/storage/database/mgo/signal.go | 6 + pkg/common/storage/model/signal.go | 5 + 6 files changed, 215 insertions(+), 348 deletions(-) delete mode 100644 docs/rtc-signaling.md diff --git a/docs/rtc-signaling.md b/docs/rtc-signaling.md deleted file mode 100644 index e29921bdf..000000000 --- a/docs/rtc-signaling.md +++ /dev/null @@ -1,288 +0,0 @@ -# OpenIM 音视频(RTC)信令与媒体 — 技术说明 - -## 1. 职责边界 - -| 维度 | 说明 | -|------|------| -| OpenIM | 呼叫信令编排、邀请状态(Mongo)、LiveKit 房间创建/删除、进房 JWT、通过消息链路把信令投递到对端(在线 WebSocket + 离线推送)。 | -| LiveKit | WebRTC 媒体面(客户端持 Token 连接 `externalAddress`)。 | -| 协议 | `protocol/rtc/rtc.proto`;通知类 `ContentType` 见 `protocol/constant/rtc.go`。 | - ---- - -## 2. 服务与配置 - -| 项 | 位置 | -|----|------| -| RTC 进程入口 | `pkg/common/cmd/rpc_rtc.go` → `internal/rpc/rtc.Start` | -| 实现 | `internal/rpc/rtc/server.go`、`internal/rpc/rtc/signal.go` | -| 注册名 | `config.Share.RpcRegisterName.Rtc` | -| LiveKit 配置 | `pkg/common/config/config.go`:`LiveKit`(`internalAddress`、`externalAddress`、`apiKey`、`apiSecret`、`tokenExpiry`) | - ---- - -## 3. 接入方式(概览) - -### 3.1 WebSocket - -1. `internal/msggateway/ws_server.go`:注入 `RtcServiceClient`。 -2. `internal/msggateway/client.go`:`ReqIdentifier == WSSendSignalMsg`(**1004**)。 -3. `internal/msggateway/message_handler.go`:`SendSignalMessage` → `SignalMessageAssemble`(体为 `SignalMessageAssembleReq` 或裸 `SignalReq`)。 -4. 返回:`SignalResp` 的 protobuf 二进制放在 `Resp.Data`。 - -### 3.2 HTTP(`/rtc`) - -路由:`internal/api/router.go`;封装:`internal/api/rtc.go`(`a2r.Call` 将 HTTP 体映射到同名 gRPC)。Prometheus 发现:`GET /prometheus_discovery/rtc`。 - -**详细接口与链路见第 4 节。** - ---- - -## 4. 接口清单与各接口调用链路 - -对外暴露形态包括:**gRPC 方法名**(服务 `openim.rtc.RtcService`)、**HTTP**(OpenIM API 网关)、以及 **WebSocket**(仅映射到 `SignalMessageAssemble`)。以下链路按代码真实调用顺序描述。 - -### 4.1 接口总表 - -| # | gRPC 方法 | HTTP 路径 | WebSocket | -|---|-----------|-----------|-----------| -| 1 | `SignalMessageAssemble` | `POST /rtc/signal_message_assemble` | `ReqIdentifier=1004`(`WSSendSignalMsg`) | -| 2 | `SignalGetRoomByGroupID` | `POST /rtc/signal_get_room_by_group_id` | — | -| 3 | `SignalGetTokenByRoomID` | `POST /rtc/signal_get_token_by_room_id` | — | -| 4 | `SignalGetRooms` | `POST /rtc/signal_get_rooms` | — | -| 5 | `GetSignalInvitationInfo` | `POST /rtc/get_signal_invitation_info` | — | -| 6 | `GetSignalInvitationInfoStartApp` | `POST /rtc/get_signal_invitation_info_start_app` | — | -| 7 | `SignalSendCustomSignal` | `POST /rtc/signal_send_custom_signal` | — | -| 8 | `GetSignalInvitationRecords` | `POST /rtc/get_signal_invitation_records` | — | -| 9 | `DeleteSignalRecords` | `POST /rtc/delete_signal_records` | — | - -说明:`SignalReq` 内嵌的 **`getTokenByRoomID`** 与独立 RPC **`SignalGetTokenByRoomID`** 在服务端均落到 `genToken` + 返回 `liveURL`,前者经 `SignalMessageAssemble` 分发,后者经 HTTP 直达同名 gRPC。 - ---- - -### 4.2 `SignalMessageAssemble` - -**作用**:处理一路信令请求,返回 `SignalResp`;部分分支会写 Mongo、调 LiveKit、并通过 Msg 服务发 1601 通知。 - -**入口 A — WebSocket** - -1. 客户端发送二进制帧 → `internal/msggateway/client.go` 按 `ReqIdentifier==1004` 分支。 -2. `LongConnServer.SendSignalMessage` → `GrpcHandler.SendSignalMessage`(`internal/msggateway/message_handler.go`)。 -3. `proto.Unmarshal`:`SignalMessageAssembleReq`;若失败则解 `SignalReq` 并填入 `assembleReq.SignalReq`。 -4. `RtcServiceClient.SignalMessageAssemble(ctx, assembleReq)`(gRPC 至 **rtc 进程**)。 -5. `internal/rpc/rtc/signal.go`:`rtcServer.SignalMessageAssemble` → `switch req.SignalReq.Payload` → `handleInvite` / `handleInviteInGroup` / `handleCancel` / `handleAccept` / `handleHungUp` / `handleReject` / `handleGetTokenByRoomID`。 -6. 返回 `SignalMessageAssembleResp` → 网关将 `SignalResp` `proto.Marshal` → `Resp.Data` 回客户端。 - -**入口 B — HTTP** - -1. `POST /rtc/signal_message_assemble` → `internal/api/rtc.go`:`RtcApi.SignalMessageAssemble`。 -2. `github.com/openimsdk/tools/a2r.Call`:解析 Gin 请求体 → 调用 `RtcServiceClient.SignalMessageAssemble`。 -3. 后续与步骤 5–6 相同(响应经 HTTP 返回,而非 WS `Resp`)。 - -**分支内典型下游(仅当对应 payload 触发时)** - -| 子逻辑 | LiveKit | Mongo(`controller.RtcDatabase` → `mgo/signal`) | Msg(`rpcli.MsgClient.SendMsg`) | -|--------|---------|---------------------------------------------------|----------------------------------| -| `handleInvite` | `CreateRoom` | `CreateInvitation` | 对每个被叫 `sendSignalingNotification`(1601) | -| `handleInviteInGroup` | `CreateRoom` | `CreateInvitation` | 同上(`SessionType` 为群) | -| `handleAccept` | — | — | 通知主叫 1601 | -| `handleReject` | — | `DeleteInvitation` / `RemoveInvitee` | 通知主叫 1601 | -| `handleCancel` | — | `DeleteInvitation` | 通知被叫 1601 | -| `handleHungUp` | `DeleteRoom` | `DeleteInvitation` | 通知对端 1601 | -| `handleGetTokenByRoomID` | — | — | — | - -**若发生 `SendMsg`(1601)**,后续链路见 **第 7 节**(Kafka → msg_transfer → push → 网关 `WSPushMsg` 2001)。 - ---- - -### 4.3 `SignalGetRoomByGroupID` - -**作用**:按群 ID 查当前(或最近)邀请信息,返回 `InvitationInfo` 与 `roomID`。 - -**HTTP 链路** - -1. `POST /rtc/signal_get_room_by_group_id` → `RtcApi.SignalGetRoomByGroupID` → `a2r.Call` → gRPC。 -2. `internal/rpc/rtc/signal.go`:`SignalGetRoomByGroupID` → `db.GetInvitationByGroupID` → Mongo `signal_invitation`(`mgo/signal.go`)。 -3. `modelToInvitationInfo` 填响应返回。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -### 4.4 `SignalGetTokenByRoomID`(独立 RPC) - -**作用**:已有房间时,仅为指定用户签发 LiveKit JWT,并返回 `liveURL`(`ExternalAddress`)。 - -**HTTP 链路** - -1. `POST /rtc/signal_get_token_by_room_id` → `RtcApi.SignalGetTokenByRoomID` → `a2r.Call` → gRPC。 -2. `internal/rpc/rtc/signal.go`:`SignalGetTokenByRoomID`(与 `handleGetTokenByRoomID` 同源逻辑)→ `genToken(roomID, userID)`。 -3. 返回 `SignalGetTokenByRoomIDResp`。 - -**不经过**:Mongo(不校验邀请是否存在)、Msg、Kafka。 - ---- - -### 4.5 `SignalGetRooms` - -**作用**:批量 `roomID` 查询邀请信息列表。 - -**HTTP 链路** - -1. `POST /rtc/signal_get_rooms` → `RtcApi.SignalGetRooms` → `a2r.Call` → gRPC。 -2. `SignalGetRooms` → `db.GetInvitationsByRoomIDs` → Mongo。 -3. 组装 `[]*SignalGetRoomByGroupIDResp` 返回。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -### 4.6 `GetSignalInvitationInfo` - -**作用**:按 **roomID** 查邀请详情及离线推送字段。 - -**HTTP 链路** - -1. `POST /rtc/get_signal_invitation_info` → `RtcApi.GetSignalInvitationInfo` → `a2r.Call` → gRPC。 -2. `GetSignalInvitationInfo` → `db.GetInvitationByRoomID` → Mongo。 -3. 填充 `InvitationInfo`、`OfflinePushInfo` 返回。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -### 4.7 `GetSignalInvitationInfoStartApp` - -**作用**:按 **被叫 userID** 查其相关待处理邀请(冷启动拉铃场景)。 - -**HTTP 链路** - -1. `POST /rtc/get_signal_invitation_info_start_app` → `RtcApi.GetSignalInvitationInfoStartApp` → `a2r.Call` → gRPC。 -2. `GetSignalInvitationInfoStartApp` → `db.GetInvitationByInviteeUserID` → Mongo(`invitee_user_id_list` 查询)。 -3. 返回邀请与 `OfflinePushInfo`。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -### 4.8 `SignalSendCustomSignal` - -**作用**:向房间内除操作者外的参与者广播 **自定义信令**(系统消息 **1605**)。 - -**HTTP 链路** - -1. `POST /rtc/signal_send_custom_signal` → `RtcApi.SignalSendCustomSignal` → `a2r.Call` → gRPC。 -2. `SignalSendCustomSignal` → `db.GetInvitationByRoomID`(取邀请内 `InviteeUserIDList` + `InviterUserID`)。 -3. `mcontext.GetOpUserID(ctx)` 排除发送者自己。 -4. 对每个目标用户 `sendCustomSignalNotification` → `MsgClient.SendMsg`(`ContentType=CustomSignalNotification`,JSON body)。 -5. 若第 2 步查无邀请:打日志后返回空成功(不报错)。 - -**若发生 `SendMsg`**:后续同第 7 节(1605 走消息总线与推送)。 - ---- - -### 4.9 `GetSignalInvitationRecords` - -**作用**:分页查询通话/信令话单(`signal_record`)。 - -**HTTP 链路** - -1. `POST /rtc/get_signal_invitation_records` → `RtcApi.GetSignalInvitationRecords` → `a2r.Call` → gRPC。 -2. `GetSignalInvitationRecords` → `db.SearchRecords`(`sendID` / `recvID` / `sessionType` / 时间范围 / 分页)→ Mongo `signal_record`。 -3. 映射为 `[]*rtc.SignalRecord` 返回。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -### 4.10 `DeleteSignalRecords` - -**作用**:按话单主键 `SID` 列表删除记录。 - -**HTTP 链路** - -1. `POST /rtc/delete_signal_records` → `RtcApi.DeleteSignalRecords` → `a2r.Call` → gRPC。 -2. `DeleteSignalRecords` → `db.DeleteRecords(sIDs)` → Mongo。 - -**不经过**:LiveKit、Msg、Kafka。 - ---- - -## 5. `SignalMessageAssemble` 行为摘要(payload 与副作用) - -(实现:`internal/rpc/rtc/signal.go`) - -| 动作 | LiveKit | Mongo | 通知 | -|------|---------|-------|------| -| Invite | CreateRoom | CreateInvitation | 向被叫发 1601 | -| InviteInGroup | CreateRoom | CreateInvitation | 向被叫发 1601(群 SessionType) | -| Accept | — | — | 通知主叫 1601 | -| Reject | — | DeleteInvitation / RemoveInvitee | 通知主叫 | -| Cancel | — | DeleteInvitation | 通知被叫 | -| HungUp | **DeleteRoom** | DeleteInvitation | 通知对端 | -| GetTokenByRoomID(嵌在 SignalReq) | — | — | — | - -Token:`github.com/livekit/protocol/auth`,`VideoGrant`(`RoomJoin` + `Room` + `Identity`),有效期由配置决定。 - ---- - -## 6. Mongo - -- 集合:`signal_invitation`、`signal_record`(`pkg/common/storage/database/name.go`)。 -- 模型:`pkg/common/storage/model/signal.go`。 -- DAO:`pkg/common/storage/database/mgo/signal.go`。 -- 控制器:`pkg/common/storage/controller/rtc.go`。 - -话单 `SignalRecord` 的写入需结合业务;`GetSignalInvitationRecords` 依赖该集合已有数据。 - ---- - -## 7. 信令进消息链路(`SendMsg` 之后) - -适用于:`sendSignalingNotification`(1601)、`sendCustomSignalNotification`(1605)。 - -1. `MsgClient.SendMsg` → `internal/rpc/msg/send.go`(按 `SessionType` 走单聊/群聊分支)。 -2. `MsgToMQ` → Kafka **`toRedisTopic`**(key:单聊为 `GenConversationUniqueKeyForSingle`;群为 `GroupID`)。 -3. `msg_transfer`(`internal/msgtransfer/online_history_msg_handler.go`)消费 → Redis seq → **`toMongoTopic`** → **`toPushTopic`**。 -4. `push`(`internal/push/push_handler.go`)消费 `toPushTopic` → `Push2User` / `Push2Group` → 网关 RPC。 -5. 网关 `Client.PushMessage`(`internal/msggateway/client.go`):**`ReqIdentifier = WSPushMsg`(2001)**,`Data` 为 `sdkws.PushMessages` 的 protobuf。 - -离线推送:`SignalingNotification` 可走离线;`RoomParticipantsConnected/Disconnected`(1602/1603)在 push 逻辑中默认不触发离线推。 - ---- - -## 8. `MsgData.Options` 与会话 ID(缺省行为) - -`pkg/msgprocessor/options.go`:`Options.Is(key)` 在 **key 未设置时视为 true**。 - -RTC 侧 `sendSignalingNotification` 使用 `make(map[string]bool)` 空 map,故 `IsHistory` / `IsNotNotification` 等表现为 true,信令在 transfer 中多走**落库 + 带 seq 后推送**路径。 - -网关下行使用 `GetConversationIDByMsg`:单聊信令默认挂在 **`si_*`** 的 `PushMessages.Msgs` 中(`IsNotification` 为 false 的前缀规则)。 - ---- - -## 9. 已知风险与排查 - -- **群通话信令**:当前构造通知时若未设置 `MsgData.GroupID`,`sendMsgGroupChat` 的 Kafka key 与 `Push2Group(ctx, groupID, ...)` 可能异常;建议在发群信令时写入与 `InvitationInfo.group_id` 一致的 `GroupID`。 -- **常量 1602–1604**:协议与 push 有特殊分支,但 `internal/rpc/rtc` 主路径主要发 1601/1605;若产品需要房间成员/流状态通知,需在扩展路径发送。 - ---- - -## 10. 常量(节选) - -| 值 | 含义 | -|----|------| -| 1601 | `SignalingNotification` | -| 1605 | `CustomSignalNotification` | -| 1602–1604 | 房间参与者/流变更等(push 对 1602/1603 限制离线推) | - ---- - -## 11. 端到端链路(简图) - -```text -客户端 → [WS 1004 或 HTTP /rtc] → rtc RPC → LiveKit + Mongo - → msg SendMsg → Kafka(toRedis) → msg_transfer → Kafka(toPush) - → push → msg_gateway → WS 2001 (PushMessages) -客户端 ← LiveKit(media) + OpenIM(信令推送) -``` diff --git a/internal/rpc/msg/send.go b/internal/rpc/msg/send.go index 98a9f7616..f754f9057 100644 --- a/internal/rpc/msg/send.go +++ b/internal/rpc/msg/send.go @@ -153,10 +153,13 @@ func (m *msgServer) sendMsgNotification(ctx context.Context, req *pbmsg.SendMsgR } func (m *msgServer) sendMsgSingleChat(ctx context.Context, req *pbmsg.SendMsgReq) (resp *pbmsg.SendMsgResp, err error) { + if err := m.messageVerification(ctx, req); err != nil { + return nil, err + } + isSend := true isNotification := msgprocessor.IsNotificationByMsg(req.MsgData) log.ZInfo(ctx, "sendMsgSingleChat", "isNotification", isNotification, "msgdata", req.MsgData) - isSend := true if !isNotification { // 非通知类消息:执行发送权限校验 + 接收偏好校验(含 blacklist / MsgReceiveSetting / webhook / FriendVerify / globalOpt / convOpt) isSend, err = m.modifyMessageByUserMessageReceiveOpt( diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index 574cc79c7..8b4d53dd0 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -53,7 +53,6 @@ type MessageRevoked struct { func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgReq) error { switch data.MsgData.SessionType { case constant.SingleChatType: - // 单聊发送权限校验已迁移至 modifyMessageByUserMessageReceiveOpt return nil case constant.ReadGroupChatType: groupInfo, err := m.GroupLocalCache.GetGroupInfo(ctx, data.MsgData.GroupID) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index 69551d57f..0aa131dc7 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -32,6 +32,7 @@ import ( "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/mcontext" "github.com/openimsdk/tools/utils/datautil" + "go.mongodb.org/mongo-driver/mongo" "google.golang.org/protobuf/proto" ) @@ -76,11 +77,11 @@ func (s *rtcServer) SignalMessageAssemble(ctx context.Context, req *rtc.SignalMe resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r} respErr = err default: - log.ZError(ctx, "SignalMessageAssemble", respErr, "r", respErr.Error()) + // Fix P0: 原代码在此调用 respErr.Error(),而 respErr 为 nil,会直接 panic return nil, errs.ErrArgs.WrapMsg("unknown signal payload type") } if respErr != nil { - log.ZError(ctx, "SignalMessageAssemble", respErr, "r", respErr.Error()) + log.ZError(ctx, "SignalMessageAssemble", respErr, "err", respErr.Error()) return nil, respErr } return &rtc.SignalMessageAssembleResp{SignalResp: &resp}, nil @@ -93,9 +94,8 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, log.ZError(ctx, "handleInvite", errs.ErrArgs, "r", "invitation is nil") return nil, errs.ErrArgs.WrapMsg("invitation is nil") } - if inv.RoomID == "" { - inv.RoomID = newRoomID() - } + // Fix P3: RoomID 统一由服务端生成,忽略客户端传入的值(客户端不应决定 RoomID) + inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() @@ -118,19 +118,38 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, token, err := s.genToken(inv.RoomID, req.UserID) if err != nil { - log.ZError(ctx, "handleInvite", err, "r", err.Error()) + // LiveKit Room 已创建,需要回滚 + if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { + log.ZWarn(ctx, "handleInvite: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID) + } return nil, err } + // Fix P1/幂等: CreateInvitation 失败分两种情况: + // - 重复 key(相同 roomID 重试)→ 认为幂等成功,直接返回 + // - 其他错误 → 回滚 LiveKit Room,返回错误 + // Fix P0: 原代码对失败仅打 warn,导致 DB 无记录、Room 泄漏、后续流程断裂 if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil { - log.ZWarn(ctx, "CreateInvitation failed", err, "roomID", inv.RoomID) + if mongo.IsDuplicateKeyError(err) { + log.ZWarn(ctx, "handleInvite: duplicate invitation (idempotent retry)", err, "roomID", inv.RoomID) + } else { + if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { + log.ZWarn(ctx, "handleInvite: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID) + } + return nil, errs.WrapMsg(err, "CreateInvitation failed", "roomID", inv.RoomID) + } } - content := marshalSignalReq(signalReq) + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + // Fix P1: 1v1 场景下,通知失败应返回错误(被叫收不到来电意味着主叫白等) for _, inviteeID := range inv.InviteeUserIDList { log.ZInfo(ctx, "sendSignalingNotification to invitee", "sendID", req.UserID, "recvID", inviteeID) if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, int32(constant.SingleChatType), req.OfflinePushInfo, content); err != nil { - log.ZWarn(ctx, "sendSignalingNotification to invitee failed", err, "inviteeID", inviteeID) + log.ZError(ctx, "sendSignalingNotification to invitee failed", err, "inviteeID", inviteeID) + return nil, errs.WrapMsg(err, "failed to notify invitee", "inviteeID", inviteeID) } } @@ -148,9 +167,8 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi if inv == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } - if inv.RoomID == "" { - inv.RoomID = newRoomID() - } + // Fix P3: RoomID 统一由服务端生成 + inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() @@ -160,14 +178,27 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi token, err := s.genToken(inv.RoomID, req.UserID) if err != nil { + if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { + log.ZWarn(ctx, "handleInviteInGroup: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID) + } return nil, err } + // Fix P0: CreateInvitation 失败需要回滚 LiveKit Room if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil { - log.ZWarn(ctx, "CreateInvitation failed", err, "roomID", inv.RoomID) + if !mongo.IsDuplicateKeyError(err) { + if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { + log.ZWarn(ctx, "handleInviteInGroup: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID) + } + return nil, errs.WrapMsg(err, "CreateInvitation failed", "roomID", inv.RoomID) + } + log.ZWarn(ctx, "handleInviteInGroup: duplicate invitation (idempotent retry)", err, "roomID", inv.RoomID) } - content := marshalSignalReq(signalReq) + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } for _, inviteeID := range inv.InviteeUserIDList { allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID) if err != nil { @@ -215,56 +246,90 @@ func (s *rtcServer) isCallAllowed(ctx context.Context, inviterID, inviteeID stri } // handleAccept processes a call acceptance. +// Fix P1(安全): 原代码完全信任客户端传入的 Invitation,未从 DB 校验邀请真实存在。 +// 攻击者可伪造任意 RoomID/InviterUserID 来获取 LiveKit Token 并加入房间。 func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, signalReq *rtc.SignalReq) (*rtc.SignalAcceptResp, error) { - inv := req.Invitation - if inv == nil { + if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } - token, err := s.genToken(inv.RoomID, req.UserID) + // 从 DB 获取权威邀请数据,验证邀请存在且 userID 在被邀请人列表中 + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID) + } + if !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) { + return nil, errs.ErrNoPermission.WrapMsg("user not in invitee list", "userID", req.UserID) + } + + token, err := s.genToken(dbInv.RoomID, req.UserID) if err != nil { return nil, err } sessionType := int32(constant.SingleChatType) - if inv.GroupID != "" { + if dbInv.GroupID != "" { sessionType = int32(constant.ReadGroupChatType) } - content := marshalSignalReq(signalReq) - if err := s.sendSignalingNotification(ctx, req.UserID, inv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil { - log.ZWarn(ctx, "sendSignalingNotification accept to inviter failed", err, "inviterID", inv.InviterUserID) + + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + // 使用 DB 中的 InviterUserID,防止客户端伪造通知目标 + if err := s.sendSignalingNotification(ctx, req.UserID, dbInv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil { + log.ZWarn(ctx, "sendSignalingNotification accept to inviter failed", err, "inviterID", dbInv.InviterUserID) + } + + // Fix P2: 1v1 通话接受后删除邀请记录,避免冷启动时重复弹出已接通的来电 + // TODO: 群通话可通过 RemoveInvitee 实现精细化状态管理 + if dbInv.GroupID == "" { + if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { + log.ZWarn(ctx, "handleAccept: DeleteInvitation failed (non-fatal)", err, "roomID", dbInv.RoomID) + } } return &rtc.SignalAcceptResp{ Token: token, - RoomID: inv.RoomID, + RoomID: dbInv.RoomID, LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, }, nil } // handleReject processes a call rejection. +// Fix P1(安全): 从 DB 验证邀请存在,并使用 DB 中的 InviterUserID,防止客户端伪造通知目标。 func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, signalReq *rtc.SignalReq) (*rtc.SignalRejectResp, error) { - inv := req.Invitation - if inv == nil { + if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID) + } + if !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) { + return nil, errs.ErrNoPermission.WrapMsg("user not in invitee list", "userID", req.UserID) + } + sessionType := int32(constant.SingleChatType) - if inv.GroupID != "" { + if dbInv.GroupID != "" { sessionType = int32(constant.ReadGroupChatType) } - content := marshalSignalReq(signalReq) - if err := s.sendSignalingNotification(ctx, req.UserID, inv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil { - log.ZWarn(ctx, "sendSignalingNotification reject to inviter failed", err, "inviterID", inv.InviterUserID) + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + if err := s.sendSignalingNotification(ctx, req.UserID, dbInv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil { + log.ZWarn(ctx, "sendSignalingNotification reject to inviter failed", err, "inviterID", dbInv.InviterUserID) } - if inv.GroupID != "" { - if err := s.db.RemoveInvitee(ctx, inv.RoomID, req.UserID); err != nil { - log.ZWarn(ctx, "RemoveInvitee failed", err, "roomID", inv.RoomID, "userID", req.UserID) + if dbInv.GroupID != "" { + if err := s.db.RemoveInvitee(ctx, dbInv.RoomID, req.UserID); err != nil { + log.ZWarn(ctx, "RemoveInvitee failed", err, "roomID", dbInv.RoomID, "userID", req.UserID) } } else { - if err := s.db.DeleteInvitation(ctx, inv.RoomID); err != nil { - log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", inv.RoomID) + if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { + log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } } @@ -272,62 +337,94 @@ func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, } // handleCancel processes a call cancellation. +// Fix P1(安全): 从 DB 验证操作者是邀请发起方,防止被叫方冒充取消通话。 func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, signalReq *rtc.SignalReq) (*rtc.SignalCancelResp, error) { - inv := req.Invitation - if inv == nil { + if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID) + } + if req.UserID != dbInv.InviterUserID { + return nil, errs.ErrNoPermission.WrapMsg("only the inviter can cancel", "userID", req.UserID, "inviterUserID", dbInv.InviterUserID) + } + sessionType := int32(constant.SingleChatType) - if inv.GroupID != "" { + if dbInv.GroupID != "" { sessionType = int32(constant.ReadGroupChatType) } - content := marshalSignalReq(signalReq) - for _, inviteeID := range inv.InviteeUserIDList { + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + for _, inviteeID := range dbInv.InviteeUserIDList { if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, sessionType, req.OfflinePushInfo, content); err != nil { log.ZWarn(ctx, "sendSignalingNotification cancel to invitee failed", err, "inviteeID", inviteeID) } } - if err := s.db.DeleteInvitation(ctx, inv.RoomID); err != nil { - log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", inv.RoomID) + if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { + log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } return &rtc.SignalCancelResp{}, nil } // handleHungUp processes a call hang-up. +// Fix P1(安全): 从 DB 验证操作者是通话参与者,防止任意用户挂断他人通话并删除 LiveKit Room。 func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) { - inv := req.Invitation - if inv == nil { + if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID) + } + if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) { + return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this call", "userID", req.UserID) + } + sessionType := int32(constant.SingleChatType) - if inv.GroupID != "" { + if dbInv.GroupID != "" { sessionType = int32(constant.ReadGroupChatType) } - content := marshalSignalReq(signalReq) - for _, peerID := range hungUpPeerIDs(inv, req.UserID) { + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + // 使用 DB 中的参与者列表,不信任客户端传入的 InviteeUserIDList + for _, peerID := range hungUpPeerIDsFromDB(dbInv, req.UserID) { if err := s.sendSignalingNotification(ctx, req.UserID, peerID, sessionType, req.OfflinePushInfo, content); err != nil { log.ZWarn(ctx, "sendSignalingNotification hungUp to peer failed", err, "peerID", peerID) } } // Terminate the LiveKit room - if _, err := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); err != nil { - log.ZWarn(ctx, "LiveKit DeleteRoom failed", err, "roomID", inv.RoomID) + if _, err := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: dbInv.RoomID}); err != nil { + log.ZWarn(ctx, "LiveKit DeleteRoom failed", err, "roomID", dbInv.RoomID) } - if err := s.db.DeleteInvitation(ctx, inv.RoomID); err != nil { - log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", inv.RoomID) + if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { + log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } return &rtc.SignalHungUpResp{}, nil } // handleGetTokenByRoomID returns a LiveKit token for an existing room. +// Fix P0(安全): 原代码无权限校验,任意已登录用户可获取任意房间的 Token,可窃听他人通话。 func (s *rtcServer) handleGetTokenByRoomID(ctx context.Context, req *rtc.SignalGetTokenByRoomIDReq) (*rtc.SignalGetTokenByRoomIDResp, error) { + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "room not found or expired", "roomID", req.RoomID) + } + if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) { + return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this room", "userID", req.UserID) + } + token, err := s.genToken(req.RoomID, req.UserID) if err != nil { return nil, err @@ -351,7 +448,16 @@ func (s *rtcServer) SignalGetRoomByGroupID(ctx context.Context, req *rtc.SignalG } // SignalGetTokenByRoomID returns a token for joining a room directly (HTTP API path). +// Fix P0(安全): 同 handleGetTokenByRoomID,添加参与者身份校验。 func (s *rtcServer) SignalGetTokenByRoomID(ctx context.Context, req *rtc.SignalGetTokenByRoomIDReq) (*rtc.SignalGetTokenByRoomIDResp, error) { + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID) + if err != nil { + return nil, errs.WrapMsg(err, "room not found or expired", "roomID", req.RoomID) + } + if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) { + return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this room", "userID", req.UserID) + } + token, err := s.genToken(req.RoomID, req.UserID) if err != nil { return nil, err @@ -421,10 +527,14 @@ func (s *rtcServer) SignalSendCustomSignal(ctx context.Context, req *rtc.SignalS return &rtc.SignalSendCustomSignalResp{}, nil } opUserID := mcontext.GetOpUserID(ctx) - content, _ := json.Marshal(map[string]any{ + // Fix P3: 处理 json.Marshal 错误 + content, err := json.Marshal(map[string]any{ "roomID": req.RoomID, "customInfo": req.CustomInfo, }) + if err != nil { + return nil, errs.WrapMsg(err, "marshal custom signal content failed") + } recipients := make([]string, 0, len(inv.InviteeUserIDList)+1) recipients = append(recipients, inv.InviteeUserIDList...) recipients = append(recipients, inv.InviterUserID) @@ -492,6 +602,29 @@ func (s *rtcServer) genToken(roomID, userID string) (string, error) { return at.ToJWT() } +// signalingMsgOptions 返回信令通知消息应设置的 Options。 +// +// Fix P2+P2(安全): 原代码传 make(map[string]bool) 空 map,导致: +// 1. IsNotificationByMsg 将信令消息误判为普通聊天消息,触发黑名单/好友关系等权限拦截 +// 2. IsHistory/IsPersistent 默认为 true,信令消息被写入历史记录占用存储 +// 3. IsUnreadCount/IsConversationUpdate 默认 true,污染未读数和会话列表 +// +// 信令消息应走 Notification 通道(对话 ID 前缀 "n_"),绕过聊天消息权限校验, +// 且不写历史、不计未读、不更新会话。离线推送根据 offlinePushInfo 控制,此处不强制关闭。 +func signalingMsgOptions() map[string]bool { + opts := make(map[string]bool, 8) + // IsNotNotification=false 表示"这是通知消息",让 IsNotificationByMsg 返回 true + // 从而跳过 modifyMessageByUserMessageReceiveOpt 中的黑名单/好友关系等校验 + datautil.SetSwitchFromOptions(opts, constant.IsNotNotification, false) + datautil.SetSwitchFromOptions(opts, constant.IsHistory, false) + datautil.SetSwitchFromOptions(opts, constant.IsPersistent, false) + datautil.SetSwitchFromOptions(opts, constant.IsUnreadCount, false) + datautil.SetSwitchFromOptions(opts, constant.IsConversationUpdate, false) + datautil.SetSwitchFromOptions(opts, constant.IsSenderConversationUpdate, false) + datautil.SetSwitchFromOptions(opts, constant.IsSenderSync, false) + return opts +} + // sendSignalingNotification sends a SignalingNotification message to a user via the msg service. func (s *rtcServer) sendSignalingNotification(ctx context.Context, sendID, recvID string, sessionType int32, offlinePush *sdkws.OfflinePushInfo, content []byte) error { now := time.Now().UnixMilli() @@ -506,7 +639,7 @@ func (s *rtcServer) sendSignalingNotification(ctx context.Context, sendID, recvI SendTime: now, ServerMsgID: uuid.New().String(), ClientMsgID: uuid.New().String(), - Options: make(map[string]bool), + Options: signalingMsgOptions(), } if offlinePush != nil { msgData.OfflinePushInfo = offlinePush @@ -536,15 +669,20 @@ func (s *rtcServer) sendCustomSignalNotification(ctx context.Context, sendID, re SendTime: now, ServerMsgID: uuid.New().String(), ClientMsgID: uuid.New().String(), - Options: make(map[string]bool), + Options: signalingMsgOptions(), } _, err := s.msgClient.MsgClient.SendMsg(ctx, &pbmsg.SendMsgReq{MsgData: msgData}) return err } -func marshalSignalReq(req *rtc.SignalReq) []byte { - b, _ := proto.Marshal(req) - return b +// marshalSignalReq serializes a SignalReq to bytes. +// Fix P2: 原代码使用 _ 吞掉错误,序列化失败时返回 nil,导致被叫收到空 Content 消息,来电通知丢失。 +func marshalSignalReq(req *rtc.SignalReq) ([]byte, error) { + b, err := proto.Marshal(req) + if err != nil { + return nil, errs.WrapMsg(err, "marshal SignalReq failed") + } + return b, nil } // newRoomID generates a unique room ID. @@ -554,6 +692,7 @@ func newRoomID() string { // invitationToModel converts a proto InvitationInfo to the database model. func invitationToModel(inv *rtc.InvitationInfo, push *sdkws.OfflinePushInfo) *model.SignalInvitation { + now := time.Now() m := &model.SignalInvitation{ RoomID: inv.RoomID, InviterUserID: inv.InviterUserID, @@ -566,7 +705,9 @@ func invitationToModel(inv *rtc.InvitationInfo, push *sdkws.OfflinePushInfo) *mo SessionType: inv.SessionType, InitiateTime: inv.InitiateTime, BusyLineUserIDList: inv.BusyLineUserIDList, - CreateTime: time.Now().UnixMilli(), + CreateTime: now.UnixMilli(), + // Fix P1(TTL): 根据 Timeout 设置过期时间,配合 MongoDB TTL 索引自动清理 + ExpireAt: now.Add(time.Duration(inv.Timeout+30) * time.Second), } if push != nil { m.OfflinePushTitle = push.Title @@ -596,8 +737,9 @@ func modelToInvitationInfo(m *model.SignalInvitation) *rtc.InvitationInfo { } } -// hungUpPeerIDs returns the IDs that should receive hang-up notification. -func hungUpPeerIDs(inv *rtc.InvitationInfo, callerID string) []string { +// hungUpPeerIDsFromDB returns IDs that should receive hang-up notification, based on authoritative DB data. +// Fix P1(安全): 原 hungUpPeerIDs 使用客户端传入的 inv,改为使用从 DB 获取的记录。 +func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string { if callerID == inv.InviterUserID { return inv.InviteeUserIDList } diff --git a/pkg/common/storage/database/mgo/signal.go b/pkg/common/storage/database/mgo/signal.go index 2ae8b3feb..4a84b2cc9 100644 --- a/pkg/common/storage/database/mgo/signal.go +++ b/pkg/common/storage/database/mgo/signal.go @@ -39,6 +39,12 @@ func NewSignalMongo(db *mongo.Database) (database.SignalDatabase, error) { { Keys: bson.D{{Key: "create_time", Value: -1}}, }, + // Fix P1(TTL): expire_at 字段为 BSON Date,MongoDB 后台每 60s 扫描一次并自动删除过期文档。 + // 覆盖场景:被叫网络断开、主叫 App 被杀、任何异常中断导致没有 Cancel/Reject/HungUp 的情况。 + { + Keys: bson.D{{Key: "expire_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, }) if err != nil { return nil, err diff --git a/pkg/common/storage/model/signal.go b/pkg/common/storage/model/signal.go index 91f241e98..1dc46c9e5 100644 --- a/pkg/common/storage/model/signal.go +++ b/pkg/common/storage/model/signal.go @@ -14,6 +14,8 @@ package model +import "time" + // SignalInvitation stores an ongoing or pending signal invitation, keyed by roomID. // It is created when a call is initiated and can be queried when the callee starts the app. type SignalInvitation struct { @@ -32,6 +34,9 @@ type SignalInvitation struct { OfflinePushDesc string `bson:"offline_push_desc"` OfflinePushEx string `bson:"offline_push_ex"` CreateTime int64 `bson:"create_time"` + // ExpireAt 是 MongoDB BSON Date 类型,供 TTL 索引自动清理过期邀请(无人响应/异常中断场景)。 + // 值 = 创建时间 + Timeout + 30s 缓冲,由 invitationToModel 负责填充。 + ExpireAt time.Time `bson:"expire_at"` } // SignalRecord stores a completed call record used for history queries. From dcc0fc2e866d9247840c4afdbff13372147c0a9e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:05:07 +0800 Subject: [PATCH 14/15] =?UTF-8?q?=E9=9F=B3=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/rtc/signal.go | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index 0aa131dc7..b20e02d36 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -77,7 +77,6 @@ func (s *rtcServer) SignalMessageAssemble(ctx context.Context, req *rtc.SignalMe resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r} respErr = err default: - // Fix P0: 原代码在此调用 respErr.Error(),而 respErr 为 nil,会直接 panic return nil, errs.ErrArgs.WrapMsg("unknown signal payload type") } if respErr != nil { @@ -94,12 +93,10 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, log.ZError(ctx, "handleInvite", errs.ErrArgs, "r", "invitation is nil") return nil, errs.ErrArgs.WrapMsg("invitation is nil") } - // Fix P3: RoomID 统一由服务端生成,忽略客户端传入的值(客户端不应决定 RoomID) inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() - // 校验每位被邀请者的通话接受权限,1-to-1 场景:有任一被邀请者拒绝则直接返错 for _, inviteeID := range inv.InviteeUserIDList { allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID) if err != nil { @@ -118,17 +115,12 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, token, err := s.genToken(inv.RoomID, req.UserID) if err != nil { - // LiveKit Room 已创建,需要回滚 if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { log.ZWarn(ctx, "handleInvite: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID) } return nil, err } - // Fix P1/幂等: CreateInvitation 失败分两种情况: - // - 重复 key(相同 roomID 重试)→ 认为幂等成功,直接返回 - // - 其他错误 → 回滚 LiveKit Room,返回错误 - // Fix P0: 原代码对失败仅打 warn,导致 DB 无记录、Room 泄漏、后续流程断裂 if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil { if mongo.IsDuplicateKeyError(err) { log.ZWarn(ctx, "handleInvite: duplicate invitation (idempotent retry)", err, "roomID", inv.RoomID) @@ -144,7 +136,7 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, if err != nil { return nil, err } - // Fix P1: 1v1 场景下,通知失败应返回错误(被叫收不到来电意味着主叫白等) + for _, inviteeID := range inv.InviteeUserIDList { log.ZInfo(ctx, "sendSignalingNotification to invitee", "sendID", req.UserID, "recvID", inviteeID) if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, int32(constant.SingleChatType), req.OfflinePushInfo, content); err != nil { @@ -167,7 +159,7 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi if inv == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") } - // Fix P3: RoomID 统一由服务端生成 + inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() @@ -184,7 +176,6 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi return nil, err } - // Fix P0: CreateInvitation 失败需要回滚 LiveKit Room if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil { if !mongo.IsDuplicateKeyError(err) { if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil { @@ -245,9 +236,6 @@ func (s *rtcServer) isCallAllowed(ctx context.Context, inviterID, inviteeID stri } } -// handleAccept processes a call acceptance. -// Fix P1(安全): 原代码完全信任客户端传入的 Invitation,未从 DB 校验邀请真实存在。 -// 攻击者可伪造任意 RoomID/InviterUserID 来获取 LiveKit Token 并加入房间。 func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, signalReq *rtc.SignalReq) (*rtc.SignalAcceptResp, error) { if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") @@ -276,12 +264,11 @@ func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, if err != nil { return nil, err } - // 使用 DB 中的 InviterUserID,防止客户端伪造通知目标 + if err := s.sendSignalingNotification(ctx, req.UserID, dbInv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil { log.ZWarn(ctx, "sendSignalingNotification accept to inviter failed", err, "inviterID", dbInv.InviterUserID) } - // Fix P2: 1v1 通话接受后删除邀请记录,避免冷启动时重复弹出已接通的来电 // TODO: 群通话可通过 RemoveInvitee 实现精细化状态管理 if dbInv.GroupID == "" { if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { @@ -297,7 +284,6 @@ func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, } // handleReject processes a call rejection. -// Fix P1(安全): 从 DB 验证邀请存在,并使用 DB 中的 InviterUserID,防止客户端伪造通知目标。 func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, signalReq *rtc.SignalReq) (*rtc.SignalRejectResp, error) { if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") @@ -337,7 +323,6 @@ func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, } // handleCancel processes a call cancellation. -// Fix P1(安全): 从 DB 验证操作者是邀请发起方,防止被叫方冒充取消通话。 func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, signalReq *rtc.SignalReq) (*rtc.SignalCancelResp, error) { if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") @@ -373,7 +358,6 @@ func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, } // handleHungUp processes a call hang-up. -// Fix P1(安全): 从 DB 验证操作者是通话参与者,防止任意用户挂断他人通话并删除 LiveKit Room。 func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) { if req.Invitation == nil { return nil, errs.ErrArgs.WrapMsg("invitation is nil") @@ -415,7 +399,6 @@ func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, } // handleGetTokenByRoomID returns a LiveKit token for an existing room. -// Fix P0(安全): 原代码无权限校验,任意已登录用户可获取任意房间的 Token,可窃听他人通话。 func (s *rtcServer) handleGetTokenByRoomID(ctx context.Context, req *rtc.SignalGetTokenByRoomIDReq) (*rtc.SignalGetTokenByRoomIDResp, error) { dbInv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID) if err != nil { @@ -706,8 +689,7 @@ func invitationToModel(inv *rtc.InvitationInfo, push *sdkws.OfflinePushInfo) *mo InitiateTime: inv.InitiateTime, BusyLineUserIDList: inv.BusyLineUserIDList, CreateTime: now.UnixMilli(), - // Fix P1(TTL): 根据 Timeout 设置过期时间,配合 MongoDB TTL 索引自动清理 - ExpireAt: now.Add(time.Duration(inv.Timeout+30) * time.Second), + ExpireAt: now.Add(time.Duration(inv.Timeout+30) * time.Second), } if push != nil { m.OfflinePushTitle = push.Title @@ -738,7 +720,6 @@ func modelToInvitationInfo(m *model.SignalInvitation) *rtc.InvitationInfo { } // hungUpPeerIDsFromDB returns IDs that should receive hang-up notification, based on authoritative DB data. -// Fix P1(安全): 原 hungUpPeerIDs 使用客户端传入的 inv,改为使用从 DB 获取的记录。 func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string { if callerID == inv.InviterUserID { return inv.InviteeUserIDList From c7ea14ba9a243e458c65ec461ef8018798cbf0aa Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:22 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/captcha/captcha.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/rpc/captcha/captcha.go b/internal/rpc/captcha/captcha.go index fe5ffa9c6..206b376e5 100644 --- a/internal/rpc/captcha/captcha.go +++ b/internal/rpc/captcha/captcha.go @@ -83,7 +83,7 @@ func Start(ctx context.Context, cfg *Config, _ discovery.SvcDiscoveryRegistry, g s.conf.ExpireSeconds = 120 } if s.conf.VerifyPadding <= 0 { - s.conf.VerifyPadding = 32 + s.conf.VerifyPadding = 8 } pbcaptcha.RegisterCaptchaServer(grpcServer, s) return nil