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/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/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/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/phone_sn.go b/internal/api/phone_sn.go index 3302ef215..260c65a2f 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,15 +43,23 @@ 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 { + 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 } diff --git a/internal/api/router.go b/internal/api/router.go index 936b60531..f689bec03 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -159,6 +159,15 @@ 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) + // 根据手机号精确查找用户(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) userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) @@ -190,6 +199,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)) @@ -237,7 +247,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 { @@ -287,6 +298,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/api/user.go b/internal/api/user.go index a43766860..994575fa6 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -305,3 +305,23 @@ 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) +} + +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/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 +} diff --git a/internal/rpc/captcha/captcha.go b/internal/rpc/captcha/captcha.go index 063a0c11e..206b376e5 100644 --- a/internal/rpc/captcha/captcha.go +++ b/internal/rpc/captcha/captcha.go @@ -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/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/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/send.go b/internal/rpc/msg/send.go index a45edfe9e..f754f9057 100644 --- a/internal/rpc/msg/send.go +++ b/internal/rpc/msg/send.go @@ -158,8 +158,10 @@ func (m *msgServer) sendMsgSingleChat(ctx context.Context, req *pbmsg.SendMsgReq } isSend := true isNotification := msgprocessor.IsNotificationByMsg(req.MsgData) + log.ZInfo(ctx, "sendMsgSingleChat", "isNotification", isNotification, "msgdata", req.MsgData) + 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 +176,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/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/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index f6c3147ba..8b4d53dd0 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,37 +53,13 @@ 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 - } - if err := m.webhookBeforeSendSingleMsg(ctx, &m.config.WebhooksConfig.BeforeSendSingleMsg, data); err != nil { - return err - } - black, err := m.FriendLocalCache.IsBlack(ctx, data.MsgData.SendID, data.MsgData.RecvID) - if err != nil { - return err - } - if black { - return servererrs.ErrBlockedByPeer.Wrap() - } - 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() - } - return nil - } 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 && @@ -102,6 +79,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 { @@ -113,6 +93,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 { @@ -124,6 +107,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: @@ -183,21 +170,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/relation/friend.go b/internal/rpc/relation/friend.go index 77f42e1a6..38aa23d51 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -34,12 +34,16 @@ 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/protocol/wrapperspb" "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" ) @@ -48,12 +52,14 @@ type friendServer struct { relation.UnimplementedFriendServer db controller.FriendDatabase blackDatabase controller.BlackDatabase + globalBlackDB controller.UserGlobalBlackDatabase notificationSender *FriendNotificationSender RegisterCenter discovery.SvcDiscoveryRegistry config *Config webhookClient *webhook.Client queue *memamq.MemoryQueue userClient *rpcli.UserClient + conversationClient *rpcli.ConversationClient } type Config struct { @@ -93,6 +99,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 @@ -101,6 +112,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( @@ -125,12 +140,14 @@ 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, webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), queue: memamq.NewMemoryQueue(16, 1024*1024), userClient: userClient, + conversationClient: rpcli.NewConversationClient(conversationConn), }) return nil } @@ -287,6 +304,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 @@ -302,6 +322,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 @@ -311,6 +334,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 @@ -490,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, } } @@ -545,10 +590,55 @@ 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 } + 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) + } + } + } + + 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) @@ -570,6 +660,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/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..b20e02d36 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,10 @@ 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()) 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,12 +93,21 @@ 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() - } + inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() + 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) @@ -106,19 +115,33 @@ 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()) + 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 } 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, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err } - content := marshalSignalReq(signalReq) 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) } } @@ -136,9 +159,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() - } + + inv.RoomID = newRoomID() inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() @@ -148,15 +170,36 @@ 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 } 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 { + 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,57 +212,110 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi }, nil } -// handleAccept processes a call acceptance. +// 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 + } +} + 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 + } + + 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) + } + + // 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. 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) } } @@ -228,24 +324,34 @@ func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, // handleCancel processes a call cancellation. 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 @@ -253,29 +359,40 @@ func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, // handleHungUp processes a call hang-up. 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 @@ -283,6 +400,14 @@ func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, // handleGetTokenByRoomID returns a LiveKit token for an existing room. 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 @@ -306,7 +431,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 @@ -376,10 +510,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) @@ -447,6 +585,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() @@ -461,7 +622,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 @@ -491,15 +652,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. @@ -509,6 +675,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, @@ -521,7 +688,8 @@ 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(), + ExpireAt: now.Add(time.Duration(inv.Timeout+30) * time.Second), } if push != nil { m.OfflinePushTitle = push.Title @@ -551,8 +719,8 @@ 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. +func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string { if callerID == inv.InviterUserID { return inv.InviteeUserIDList } diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index f0788215d..66bd1d682 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -18,9 +18,11 @@ import ( "context" "errors" "math/rand" + "regexp" "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" @@ -35,6 +37,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" @@ -47,10 +50,15 @@ 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" ) +// phoneRe 仅校验手机号的基本数字格式,不强制区号/国家码前缀。 +// 规则:纯数字,长度 5-20 位,允许可选的 + 前缀(如 +86...)。 +var phoneRe = regexp.MustCompile(`^\+?\d{5,20}$`) + type userServer struct { pbuser.UnimplementedUserServer online cache.OnlineCache @@ -62,6 +70,7 @@ type userServer struct { webhookClient *webhook.Client groupClient *rpcli.GroupClient relationClient *rpcli.RelationClient + globalBlackDB controller.UserGlobalBlackDatabase } type Config struct { @@ -109,6 +118,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 +134,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) @@ -130,13 +144,71 @@ 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 } - resp.UsersInfo = convert.UsersDB2Pb(users) + 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)) + for _, b := range blocked { + bannedIDs = append(bannedIDs, b.UserID) + } + return nil, servererrs.ErrUserBlocked.WrapMsg("user is banned", "userIDs", bannedIDs) + } + + 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 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 { + log.ZError(ctx, "applyPhoneVisibility: IsFriend failed", err, + "viewerID", viewerID, "targetUserID", db.UserID) + 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) { @@ -221,6 +293,220 @@ 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 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) + 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{ + "phone_visibility": req.PhoneVisibility, + } + if req.Phone != "" { + 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) + 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 { + 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) + 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 { + 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) + 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 +} + +// 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/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/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/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/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/controller/user.go b/pkg/common/storage/controller/user.go index 3f34481a3..d1ff44101 100644 --- a/pkg/common/storage/controller/user.go +++ b/pkg/common/storage/controller/user.go @@ -37,6 +37,11 @@ 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) // 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 +140,14 @@ 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) +} + 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/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) +} 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})) +} 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/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/mgo/user.go b/pkg/common/storage/database/mgo/user.go index ee92b7554..5a2dc7e34 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -32,13 +32,20 @@ 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 { + { + 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) } return &UserMgo{coll: coll}, nil @@ -75,6 +82,18 @@ 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}) +} + 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/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/database/user.go b/pkg/common/storage/database/user.go index 4ddc8285f..2682bc780 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -29,6 +29,9 @@ 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) PageFindUserWithKeyword(ctx context.Context, level1 int64, level2 int64, userID, nickName string, pagination pagination.Pagination) (count int64, users []*model.User, err error) 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 表示永久 } 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/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. 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"` +} 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 +} 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")