Merge pull request #8 from sok-im/feature/user_setting

Feature/user setting
pull/3727/head
haoyunlt 1 month ago committed by GitHub
commit 2033b1db48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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)
}

@ -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)
}

@ -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)
}

@ -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
}

@ -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
{

@ -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)
}

@ -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
}

@ -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
}

@ -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,
}
}

@ -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
}

@ -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 {

@ -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
}

@ -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
}

@ -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))

@ -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

@ -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 {

@ -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)

@ -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
}

@ -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) {

@ -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 {

@ -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,
}
})
}

@ -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
}

@ -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

@ -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")

@ -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)
}

@ -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)
}

@ -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)
}

@ -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)
}

@ -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}))
}

@ -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 DateMongoDB 后台每 60s 扫描一次并自动删除过期文档。
// 覆盖场景:被叫网络断开、主叫 App 被杀、任何异常中断导致没有 Cancel/Reject/HungUp 的情况。
{
Keys: bson.D{{Key: "expire_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
},
})
if err != nil {
return nil, err

@ -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})
}

@ -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)
}

@ -21,4 +21,5 @@ const (
PhoneSNInfoName = "phone_sn_info"
SignalInvitationName = "signal_invitation"
SignalRecordName = "signal_record"
SpamReportName = "spam_report"
)

@ -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)
}

@ -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)

@ -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 表示永久
}

@ -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"`
}

@ -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.

@ -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"`
}

@ -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 {

@ -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
}

@ -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")

Loading…
Cancel
Save