From e6f7ff8cb61ccd6068047b1b95e8c1e19c521e52 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:43:09 +0800 Subject: [PATCH 01/39] firtname lastname --- internal/rpc/user/user.go | 2 ++ pkg/common/convert/user.go | 11 +++++++++-- pkg/common/storage/model/user.go | 2 ++ protocol | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index 66bd1d682..f889214ad 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -598,6 +598,8 @@ func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterR CreateTime: now, AppMangerLevel: user.AppMangerLevel, GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, + FirstName: user.FirstName, + LastName: user.LastName, }) } if err := s.db.Create(ctx, users); err != nil { diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index 1130d81e2..a85e025d1 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -31,8 +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, + FirstName: user.FirstName, + LastName: user.LastName, + Phone: user.Phone, + PhoneVisibility: user.PhoneVisibility, CallAcceptSetting: user.CallAcceptSetting, MsgReceiveSetting: user.MsgReceiveSetting, } @@ -51,6 +53,8 @@ func UserPb2DB(user *sdkws.UserInfo) *relationtb.User { CreateTime: time.UnixMilli(user.CreateTime), AppMangerLevel: user.AppMangerLevel, GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, + FirstName: user.FirstName, + LastName: user.LastName, } } @@ -63,6 +67,8 @@ func UserPb2DBMap(user *sdkws.UserInfo) map[string]any { "nickname": user.Nickname, "face_url": user.FaceURL, "ex": user.Ex, + "first_name": user.FirstName, + "last_name": user.LastName, "app_manager_level": user.AppMangerLevel, "global_recv_msg_opt": user.GlobalRecvMsgOpt, } @@ -106,6 +112,7 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.MsgReceiveSetting != nil { val["msg_receive_setting"] = user.MsgReceiveSetting.Value } + // TODO: Add FirstName/LastName support to UserInfoWithEx proto when regenerated return val } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 3903316e0..f1dcd6c99 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -50,6 +50,8 @@ type User struct { AppMangerLevel int32 `bson:"app_manger_level"` GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` CreateTime time.Time `bson:"create_time"` + FirstName string `bson:"first_name"` + LastName string `bson:"last_name"` // Phone 用户手机号(明文,仅服务端留存,下发时按 PhoneVisibility 过滤) Phone string `bson:"phone"` // PhoneVisibility 0=所有人可见 1=仅好友可见 2=隐藏 diff --git a/protocol b/protocol index 90aae1d57..a186c4a07 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 90aae1d576466a1fa55eba386d1f7a38ca6062d0 +Subproject commit a186c4a0722d8ea983dacd34b6664f23e23dc18e From 4489cbbaaa0c1d49864098ee2f8c03c4c8e95901 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:23:29 +0800 Subject: [PATCH 02/39] showname --- internal/api/router.go | 8 ++++---- pkg/common/storage/model/user.go | 30 +++++++++++++----------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index f3409ed99..accaae666 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -9,10 +9,10 @@ import ( pbAuth "github.com/openimsdk/protocol/auth" pbcaptcha "github.com/openimsdk/protocol/captcha" "github.com/openimsdk/protocol/conversation" + pbcrypto "github.com/openimsdk/protocol/crypto" "github.com/openimsdk/protocol/group" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/relation" - pbcrypto "github.com/openimsdk/protocol/crypto" "github.com/openimsdk/protocol/rtc" "github.com/openimsdk/protocol/third" "github.com/openimsdk/protocol/user" @@ -188,7 +188,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co friendRouterGroup.POST("/get_self_friend_apply_list", f.GetSelfApplyList) friendRouterGroup.POST("/get_friend_list", f.GetFriendList) friendRouterGroup.POST("/get_designated_friends", f.GetDesignatedFriends) - friendRouterGroup.POST("/add_friend", f.ApplyToAddFriend) + friendRouterGroup.POST("/add_friend", f.AddOnewayFriend) friendRouterGroup.POST("/add_friend_response", f.RespondFriendApply) friendRouterGroup.POST("/set_friend_remark", f.SetFriendRemark) friendRouterGroup.POST("/add_black", f.AddBlack) @@ -334,8 +334,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co phoneGroup := r.Group("/phone") phoneGroup.POST("/get_sn_info", phoneSN.GetSNInfo) phoneGroup.POST("/set_sn_info", phoneSN.SetSNInfo) - } - { + } + { rc := NewRtcApi(rtc.NewRtcServiceClient(rtcConn)) rtcGroup := r.Group("/rtc") rtcGroup.POST("/signal_message_assemble", rc.SignalMessageAssemble) diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index f1dcd6c99..3a54be167 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -43,23 +43,19 @@ const ( ) type User struct { - UserID string `bson:"user_id"` - Nickname string `bson:"nickname"` - FaceURL string `bson:"face_url"` - Ex string `bson:"ex"` - AppMangerLevel int32 `bson:"app_manger_level"` - GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` - CreateTime time.Time `bson:"create_time"` - FirstName string `bson:"first_name"` - LastName string `bson:"last_name"` - // 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"` + UserID string `bson:"user_id"` + Nickname string `bson:"nickname"` + FaceURL string `bson:"face_url"` + Ex string `bson:"ex"` + AppMangerLevel int32 `bson:"app_manger_level"` + GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` + CreateTime time.Time `bson:"create_time"` + FirstName string `bson:"first_name"` + LastName string `bson:"last_name"` + Phone string `bson:"phone"` + PhoneVisibility int32 `bson:"phone_visibility"` + CallAcceptSetting int32 `bson:"call_accept_setting"` + MsgReceiveSetting int32 `bson:"msg_receive_setting"` } func (u *User) GetNickname() string { From 04717ffe1518ccb3a01b5f548f9ba42e9dafe69a Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:29:17 +0800 Subject: [PATCH 03/39] showname --- go.mod | 2 -- go.sum | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 6f54e0c62..771945bc4 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/openimsdk/open-im-server/v3 go 1.25.0 -replace github.com/openimsdk/tools => ../tools - replace github.com/openimsdk/protocol => ./protocol require ( diff --git a/go.sum b/go.sum index a84f11fdb..f1a738175 100644 --- a/go.sum +++ b/go.sum @@ -455,6 +455,8 @@ github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lk github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= github.com/openimsdk/gomake v0.0.17 h1:q8haP48VOH45WhJRiLj1YSBJyUFJqD8CTedH65i1YH8= github.com/openimsdk/gomake v0.0.17/go.mod h1:nnjS8yCtrPJAt1knMbyPiUwCH2gpyBzj/EZAONfUOXg= +github.com/openimsdk/tools v0.0.50-alpha.113 h1:rhLWaSJuhjgJFNVzmpChLCG7dPXS0+bte+CPI0008Us= +github.com/openimsdk/tools v0.0.50-alpha.113/go.mod h1:x9i/e+WJFW4tocy6RNJQ9NofQiP3KJ1Y576/06TqOG4= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= From ac8062b7c69e266c72b03dfa1996ba994f5b5c83 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:04:07 +0800 Subject: [PATCH 04/39] full name --- internal/rpc/user/user.go | 2 ++ pkg/common/convert/user.go | 42 +++++++++++++++++++++++--------- pkg/common/storage/model/user.go | 1 + protocol | 2 +- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index f889214ad..84e6f88e2 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -590,6 +590,7 @@ func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterR now := time.Now() users := make([]*tablerelation.User, 0, len(req.Users)) for _, user := range req.Users { + fullName := convert.BuildFullName(user.FirstName, user.LastName) users = append(users, &tablerelation.User{ UserID: user.UserID, Nickname: user.Nickname, @@ -600,6 +601,7 @@ func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterR GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, FirstName: user.FirstName, LastName: user.LastName, + FullName: fullName, }) } if err := s.db.Create(ctx, users); err != nil { diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index a85e025d1..4cf094c24 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -15,26 +15,38 @@ package convert import ( + "strings" + "time" + relationtb "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" "github.com/openimsdk/tools/utils/datautil" - "time" "github.com/openimsdk/protocol/sdkws" ) +func BuildFullName(firstName, lastName string) string { + if firstName == "" { + return lastName + } + if lastName == "" { + return firstName + } + return strings.TrimSpace(firstName + " " + lastName) +} + func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { return &sdkws.UserInfo{ - UserID: user.UserID, - Nickname: user.Nickname, - FaceURL: user.FaceURL, - Ex: user.Ex, - CreateTime: user.CreateTime.UnixMilli(), - AppMangerLevel: user.AppMangerLevel, - GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, - FirstName: user.FirstName, - LastName: user.LastName, - Phone: user.Phone, - PhoneVisibility: user.PhoneVisibility, + UserID: user.UserID, + Nickname: user.Nickname, + FaceURL: user.FaceURL, + Ex: user.Ex, + CreateTime: user.CreateTime.UnixMilli(), + AppMangerLevel: user.AppMangerLevel, + GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, + FirstName: user.FirstName, + LastName: user.LastName, + Phone: user.Phone, + PhoneVisibility: user.PhoneVisibility, CallAcceptSetting: user.CallAcceptSetting, MsgReceiveSetting: user.MsgReceiveSetting, } @@ -45,6 +57,7 @@ func UsersDB2Pb(users []*relationtb.User) []*sdkws.UserInfo { } func UserPb2DB(user *sdkws.UserInfo) *relationtb.User { + fullName := BuildFullName(user.FirstName, user.LastName) return &relationtb.User{ UserID: user.UserID, Nickname: user.Nickname, @@ -55,6 +68,7 @@ func UserPb2DB(user *sdkws.UserInfo) *relationtb.User { GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, FirstName: user.FirstName, LastName: user.LastName, + FullName: fullName, } } @@ -79,6 +93,10 @@ func UserPb2DBMap(user *sdkws.UserInfo) map[string]any { val[key] = v } } + if user.FirstName != "" || user.LastName != "" { + fullName := BuildFullName(user.FirstName, user.LastName) + val["full_name"] = fullName + } return val } func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 3a54be167..8c2e18167 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -52,6 +52,7 @@ type User struct { CreateTime time.Time `bson:"create_time"` FirstName string `bson:"first_name"` LastName string `bson:"last_name"` + FullName string `bson:"full_name"` Phone string `bson:"phone"` PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` diff --git a/protocol b/protocol index a186c4a07..ba5857cc0 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit a186c4a0722d8ea983dacd34b6664f23e23dc18e +Subproject commit ba5857cc0817dcfaf6892af0c542385b71f46377 From 1d682847874db7040c39c85815d0793536bb443a Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:57:56 +0800 Subject: [PATCH 05/39] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/user_global_black.go | 65 ++++++++++++++----- internal/rpc/auth/auth.go | 18 ++--- internal/rpc/msg/send.go | 4 ++ internal/rpc/msg/server.go | 6 ++ internal/rpc/msg/verify.go | 38 +++++++++++ .../storage/controller/user_global_black.go | 8 ++- .../storage/database/mgo/user_global_black.go | 17 ++++- .../storage/database/user_global_black.go | 2 + pkg/common/storage/model/user.go | 10 +++ pkg/common/storage/model/user_global_black.go | 5 +- 10 files changed, 144 insertions(+), 29 deletions(-) diff --git a/internal/api/user_global_black.go b/internal/api/user_global_black.go index 543960aa0..8efcaf9d0 100644 --- a/internal/api/user_global_black.go +++ b/internal/api/user_global_black.go @@ -29,10 +29,14 @@ func NewUserGlobalBlackApi(blacklistDB controller.UserGlobalBlackDatabase, userD type addGlobalBlacklistReq struct { UserIDs []string `json:"userIDs" binding:"required,min=1"` Reason string `json:"reason"` + // Status 限制类型:1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线) + Status int32 `json:"status" binding:"required,oneof=1 2"` } type removeGlobalBlacklistReq struct { UserIDs []string `json:"userIDs" binding:"required,min=1"` + // Status 目标状态:0=恢复正常(同步从 blacklistDB 删除记录);1=冻结;2=黑名单 + Status int32 `json:"status" binding:"oneof=0 1 2"` } type getGlobalBlacklistReq struct { @@ -45,6 +49,8 @@ type globalBlackItem struct { OperatorID string `json:"operatorID"` Reason string `json:"reason"` CreateTime int64 `json:"createTime"` + // Status 限制类型:1=冻结,2=黑名单 + Status int32 `json:"status"` } type getGlobalBlacklistResp struct { @@ -52,7 +58,8 @@ type getGlobalBlacklistResp struct { Blacks []globalBlackItem `json:"blacks"` } -// AddGlobalBlacklist 管理员将用户加入全局黑名单,并立即踢下线(所有平台 token 标记 KickedToken) +// AddGlobalBlacklist 管理员设置用户限制状态。 +// Status=1(冻结):可登录,但不能收发消息;Status=2(黑名单):不可登录,自动踢下线,不能收发消息。 func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { var req addGlobalBlacklistReq if err := c.ShouldBindJSON(&req); err != nil { @@ -85,31 +92,44 @@ func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { Nickname: u.Nickname, OperatorID: operatorID, Reason: req.Reason, + Status: req.Status, }) } if err := b.blacklistDB.AddBlack(c, blacks); err != nil { apiresp.GinError(c, err) return } - // 黑名单写入成功后,对每个被封禁用户的所有非管理员平台执行 force_logout: - // 1. 断开 WS 长连接(msggateway.KickUserOffline) - // 2. 将 Redis 中该平台的所有 token 标记为 KickedToken - for _, black := range blacks { - for platformID := range constant.PlatformID2Name { - if int32(platformID) == constant.AdminPlatformID { - continue - } - if err := b.authClient.ForceLogout(c, black.UserID, int32(platformID)); err != nil { - // 踢下线失败不阻断主流程,记录警告即可 - log.ZWarn(c, "AddGlobalBlacklist: ForceLogout failed", err, - "userID", black.UserID, "platformID", platformID) + // 同步更新 user 集合中的状态字段 + for _, userID := range req.UserIDs { + if err := b.userDB.UpdateByMap(c, userID, map[string]any{"status": req.Status}); err != nil { + log.ZWarn(c, "AddGlobalBlacklist: UpdateByMap status failed", err, + "userID", userID, "status", req.Status) + } + } + // 仅黑名单(Status=2)需要踢下线:断开 WS 长连接并将 token 标记为 KickedToken + if req.Status == model.UserStatusBlacklist { + for _, black := range blacks { + for platformID := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + if err := b.authClient.ForceLogout(c, black.UserID, int32(platformID)); err != nil { + log.ZWarn(c, "AddGlobalBlacklist: ForceLogout failed", err, + "userID", black.UserID, "platformID", platformID) + } } } } apiresp.GinSuccess(c, nil) } -// RemoveGlobalBlacklist 管理员从全局黑名单移除用户 +// RemoveGlobalBlacklist 管理员更新用户账号状态。 +// 执行顺序: +// 1. 将 user 集合中的 status 字段更新为请求值 +// 2. 仅当 status == 0(恢复正常)时,才从 blacklistDB 删除该用户的限制记录 +// +// 说明:blacklistDB 是 auth/msg 层的拦截依据;状态先落 user 集合, +// 只有确认目标状态为"正常"时才清除黑名单记录,避免状态写入成功但记录未删导致仍被拦截。 func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { var req removeGlobalBlacklistReq if err := c.ShouldBindJSON(&req); err != nil { @@ -120,9 +140,19 @@ func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { apiresp.GinError(c, err) return } - if err := b.blacklistDB.RemoveBlack(c, req.UserIDs); err != nil { - apiresp.GinError(c, err) - return + for _, userID := range req.UserIDs { + if err := b.userDB.UpdateByMap(c, userID, map[string]any{"status": req.Status}); err != nil { + log.ZError(c, "RemoveGlobalBlacklist: UpdateByMap status failed", err, "userID", userID, "status", req.Status) + apiresp.GinError(c, err) + return + } + } + // 只有目标状态为 0(正常)时才删除 blacklistDB 中的限制记录 + if req.Status == model.UserStatusNormal { + if err := b.blacklistDB.RemoveBlack(c, req.UserIDs); err != nil { + apiresp.GinError(c, err) + return + } } apiresp.GinSuccess(c, nil) } @@ -151,6 +181,7 @@ func (b *UserGlobalBlackApi) GetGlobalBlacklist(c *gin.Context) { OperatorID: blk.OperatorID, Reason: blk.Reason, CreateTime: blk.CreateTime.UnixMilli(), + Status: blk.Status, }) } apiresp.GinSuccess(c, getGlobalBlacklistResp{Total: total, Blacks: items}) diff --git a/internal/rpc/auth/auth.go b/internal/rpc/auth/auth.go index bb7a95ce1..d5846b715 100644 --- a/internal/rpc/auth/auth.go +++ b/internal/rpc/auth/auth.go @@ -32,6 +32,7 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/prommetrics" "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/common/storage/model" pbauth "github.com/openimsdk/protocol/auth" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/protocol/msggateway" @@ -140,13 +141,13 @@ func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenR return nil, errs.ErrArgs.WrapMsg("app account can`t get token") } - blocked, _ := s.blacklistDB.IsBlocked(ctx, req.UserID) - if blocked { - // Blacklisted users should be actively kicked to invalidate existing sessions. + // 仅黑名单(status=2)禁止登录;冻结(status=1)允许获取 token,仅在收发消息层面拦截 + status, _ := s.blacklistDB.GetStatus(ctx, req.UserID) + if status == model.UserStatusBlacklist { if kickErr := s.forceKickOffAllPlatforms(ctx, req.UserID); kickErr != nil { log.ZWarn(ctx, "GetUserToken forceKickOffAllPlatforms failed", kickErr, "userID", req.UserID) } - log.ZWarn(ctx, "GetUserToken is blocked", errors.New("user is in global blacklist, userID="+req.UserID), "userID", req.UserID, "blocked", blocked) + log.ZWarn(ctx, "GetUserToken is blocked", errors.New("user is in global blacklist, userID="+req.UserID), "userID", req.UserID, "status", status) return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + req.UserID) } token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(req.PlatformID)) @@ -167,14 +168,13 @@ func (s *authServer) parseToken(ctx context.Context, tokensString string) (claim if isAdmin { return claims, nil } - // 非管理员用户检查全局黑名单 - blocked, _ := s.blacklistDB.IsBlocked(ctx, claims.UserID) - if blocked { - // Blacklisted users should be actively kicked to invalidate existing sessions. + // 非管理员用户检查全局黑名单:仅 status=2(黑名单)拦截;status=1(冻结)允许通过 token 校验 + status, _ := s.blacklistDB.GetStatus(ctx, claims.UserID) + if status == model.UserStatusBlacklist { if kickErr := s.forceKickOffAllPlatforms(ctx, claims.UserID); kickErr != nil { log.ZWarn(ctx, "parseToken forceKickOffAllPlatforms failed", kickErr, "userID", claims.UserID) } - log.ZWarn(ctx, "parseToken is blocked", errors.New("user is in global blacklist, userID="+claims.UserID), "userID", claims.UserID, "blocked", blocked) + log.ZWarn(ctx, "parseToken is blocked", errors.New("user is in global blacklist, userID="+claims.UserID), "userID", claims.UserID, "status", status) return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + claims.UserID) } m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID) diff --git a/internal/rpc/msg/send.go b/internal/rpc/msg/send.go index f754f9057..e96f0d935 100644 --- a/internal/rpc/msg/send.go +++ b/internal/rpc/msg/send.go @@ -34,6 +34,10 @@ import ( func (m *msgServer) SendMsg(ctx context.Context, req *pbmsg.SendMsgReq) (*pbmsg.SendMsgResp, error) { if req.MsgData != nil { m.encapsulateMsgData(req.MsgData) + // 全局账号状态校验:冻结/黑名单用户不可收发消息 + if err := m.verifyUserStatus(ctx, req); err != nil { + return nil, err + } switch req.MsgData.SessionType { case constant.SingleChatType: return m.sendMsgSingleChat(ctx, req) diff --git a/internal/rpc/msg/server.go b/internal/rpc/msg/server.go index 2b91c4405..de00217ea 100644 --- a/internal/rpc/msg/server.go +++ b/internal/rpc/msg/server.go @@ -71,6 +71,7 @@ type msgServer struct { webhookClient *webhook.Client conversationClient *rpcli.ConversationClient spamReportDB database.SpamReport + globalBlackDB controller.UserGlobalBlackDatabase } func (m *msgServer) addInterceptorHandler(interceptorFunc ...MessageInterceptorFunc) { @@ -127,6 +128,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + globalBlackMgo, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return err + } s := &msgServer{ MsgDatabase: msgDatabase, RegisterCenter: client, @@ -138,6 +143,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), conversationClient: conversationClient, spamReportDB: spamReportDB, + globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), } s.notificationSender = notification.NewNotificationSender(&config.NotificationConfig, notification.WithLocalSendMsg(s.SendMsg)) diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index 8b4d53dd0..1f5c45661 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -21,6 +21,7 @@ import ( "time" "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/sdkws" @@ -50,6 +51,43 @@ type MessageRevoked struct { Seq uint32 `json:"seq"` } +// verifyUserStatus 校验发送方/接收方的全局账号状态。 +// 任意一方处于冻结(1)或黑名单(2)即拒绝消息发送/投递。 +// 通知类消息(NotificationBegin~NotificationEnd)和管理员发送方放行。 +func (m *msgServer) verifyUserStatus(ctx context.Context, data *msg.SendMsgReq) error { + if data == nil || data.MsgData == nil { + return nil + } + if data.MsgData.ContentType >= constant.NotificationBegin && data.MsgData.ContentType <= constant.NotificationEnd { + return nil + } + sendID := data.MsgData.SendID + if datautil.Contain(sendID, m.config.Share.IMAdminUserID...) { + return nil + } + if sendID != "" { + st, err := m.globalBlackDB.GetStatus(ctx, sendID) + if err != nil { + log.ZWarn(ctx, "verifyUserStatus: GetStatus(send) failed", err, "sendID", sendID) + } else if st == model.UserStatusFrozen || st == model.UserStatusBlacklist { + return servererrs.ErrUserBlocked.WithDetail("sender is restricted, status=" + strconv.Itoa(int(st))) + } + } + // 单聊:同时校验接收方状态;群聊接收方拦截在推送层处理 + if data.MsgData.SessionType == constant.SingleChatType { + recvID := data.MsgData.RecvID + if recvID != "" && !datautil.Contain(recvID, m.config.Share.IMAdminUserID...) { + st, err := m.globalBlackDB.GetStatus(ctx, recvID) + if err != nil { + log.ZWarn(ctx, "verifyUserStatus: GetStatus(recv) failed", err, "recvID", recvID) + } else if st == model.UserStatusFrozen || st == model.UserStatusBlacklist { + return servererrs.ErrMsgReceiveNotAllowed.WrapMsg("receiver is restricted") + } + } + } + return nil +} + func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgReq) error { switch data.MsgData.SessionType { case constant.SingleChatType: diff --git a/pkg/common/storage/controller/user_global_black.go b/pkg/common/storage/controller/user_global_black.go index 2a6d114d4..973d87539 100644 --- a/pkg/common/storage/controller/user_global_black.go +++ b/pkg/common/storage/controller/user_global_black.go @@ -14,8 +14,10 @@ type UserGlobalBlackDatabase interface { AddBlack(ctx context.Context, blacks []*model.UserGlobalBlack) error // RemoveBlack 按 userID 将用户从全局黑名单移除 RemoveBlack(ctx context.Context, userIDs []string) error - // IsBlocked 检查用户是否在全局黑名单 + // IsBlocked 检查用户是否在全局黑名单(含冻结) IsBlocked(ctx context.Context, userID string) (bool, error) + // GetStatus 返回用户限制状态:0=正常,1=冻结,2=黑名单 + GetStatus(ctx context.Context, userID string) (int32, error) // FindBlocked 批量查询哪些 userID 在全局黑名单中,返回被封禁的记录 FindBlocked(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // GetBlackList 分页获取黑名单列表 @@ -42,6 +44,10 @@ func (u *userGlobalBlackDatabase) IsBlocked(ctx context.Context, userID string) return u.db.IsBlocked(ctx, userID) } +func (u *userGlobalBlackDatabase) GetStatus(ctx context.Context, userID string) (int32, error) { + return u.db.GetStatus(ctx, userID) +} + func (u *userGlobalBlackDatabase) GetBlackList(ctx context.Context, pagination pagination.Pagination) (int64, []*model.UserGlobalBlack, error) { return u.db.Page(ctx, pagination) } diff --git a/pkg/common/storage/database/mgo/user_global_black.go b/pkg/common/storage/database/mgo/user_global_black.go index 686c2bf3f..8bca466db 100644 --- a/pkg/common/storage/database/mgo/user_global_black.go +++ b/pkg/common/storage/database/mgo/user_global_black.go @@ -37,7 +37,7 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal b.CreateTime = time.Now() } } - // 使用 upsert 避免重复插入报错 + // 使用 upsert 避免重复插入报错;status 也走 $set 以便升级/降级(冻结↔黑名单)时同步更新 for _, b := range blacks { filter := bson.M{"user_id": b.UserID} update := bson.M{ @@ -45,6 +45,7 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal "nickname": b.Nickname, "operator_id": b.OperatorID, "reason": b.Reason, + "status": b.Status, }, "$setOnInsert": bson.M{ "user_id": b.UserID, @@ -59,6 +60,20 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal return nil } +// GetStatus 返回 userID 对应的限制状态: +// 0=正常(无记录),1=冻结,2=黑名单 +func (u *UserGlobalBlackMgo) GetStatus(ctx context.Context, userID string) (int32, error) { + var doc model.UserGlobalBlack + err := u.coll.FindOne(ctx, bson.M{"user_id": userID}, options.FindOne().SetProjection(bson.M{"status": 1})).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return model.UserStatusNormal, nil + } + return model.UserStatusNormal, errs.Wrap(err) + } + return doc.Status, nil +} + func (u *UserGlobalBlackMgo) Remove(ctx context.Context, users []string) error { if len(users) == 0 { return nil diff --git a/pkg/common/storage/database/user_global_black.go b/pkg/common/storage/database/user_global_black.go index a30dbaadf..3c980d9d5 100644 --- a/pkg/common/storage/database/user_global_black.go +++ b/pkg/common/storage/database/user_global_black.go @@ -17,6 +17,8 @@ type UserGlobalBlack interface { Find(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // IsBlocked 检查单个用户是否在黑名单 IsBlocked(ctx context.Context, userID string) (bool, error) + // GetStatus 返回用户限制状态:0=正常,1=冻结,2=黑名单 + GetStatus(ctx context.Context, userID string) (int32, error) // Page 分页查询黑名单列表 Page(ctx context.Context, pagination pagination.Pagination) (count int64, blacks []*model.UserGlobalBlack, err error) } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 8c2e18167..dc02fa5cf 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -42,6 +42,14 @@ const ( MsgReceiveSettingNobody int32 = 2 ) +// UserStatus 用户账号状态枚举。 +// 0=正常;1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线,不能收发消息) +const ( + UserStatusNormal int32 = 0 + UserStatusFrozen int32 = 1 + UserStatusBlacklist int32 = 2 +) + type User struct { UserID string `bson:"user_id"` Nickname string `bson:"nickname"` @@ -57,6 +65,8 @@ type User struct { PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` MsgReceiveSetting int32 `bson:"msg_receive_setting"` + // Status 账号状态:0=正常,1=冻结,2=黑名单 + Status int32 `bson:"status"` } func (u *User) GetNickname() string { diff --git a/pkg/common/storage/model/user_global_black.go b/pkg/common/storage/model/user_global_black.go index a0329cf86..1beac7b37 100644 --- a/pkg/common/storage/model/user_global_black.go +++ b/pkg/common/storage/model/user_global_black.go @@ -2,11 +2,14 @@ package model import "time" -// UserGlobalBlack 全局黑名单记录,被加入黑名单的用户无法登录 +// UserGlobalBlack 全局黑名单/冻结记录。 +// Status: 1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线,不能收发消息) type UserGlobalBlack struct { UserID string `bson:"user_id"` Nickname string `bson:"nickname"` OperatorID string `bson:"operator_id"` Reason string `bson:"reason"` CreateTime time.Time `bson:"create_time"` + // Status 限制类型:1=冻结,2=黑名单 + Status int32 `bson:"status"` } From 6e42d0079496d8c1c093ad7527da8ac34fcfe47a Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:33:37 +0800 Subject: [PATCH 06/39] add friend with remark --- internal/rpc/relation/friend.go | 2 +- pkg/common/storage/controller/friend.go | 6 +++--- protocol | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index c1eeee386..ef44566f2 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -693,7 +693,7 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT if in1 { return nil, servererrs.ErrRelationshipAlready.WrapMsg("already in friend list") } - if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway); err != nil { + if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway, req.Remark); err != nil { return nil, err } // Notify only A (FromUserID) so incremental friend sync is triggered diff --git a/pkg/common/storage/controller/friend.go b/pkg/common/storage/controller/friend.go index 65ca90186..f863f596b 100644 --- a/pkg/common/storage/controller/friend.go +++ b/pkg/common/storage/controller/friend.go @@ -95,7 +95,7 @@ type FriendDatabase interface { // BecomeOnewayFriend inserts a single-side friendship: ownerUserID -> friendUserID. // The reverse side (friendUserID -> ownerUserID) is NOT created. - BecomeOnewayFriend(ctx context.Context, ownerUserID, friendUserID string, addSource int32) error + BecomeOnewayFriend(ctx context.Context, ownerUserID, friendUserID string, addSource int32, remark string) error } type friendDatabase struct { @@ -415,7 +415,7 @@ func (f *friendDatabase) GetPinnedFriendIDs(ctx context.Context, ownerUserID str // BecomeOnewayFriend creates only the ownerUserID->friendUserID side of the friendship. // The reverse side is intentionally omitted so that the target user is not aware of being added. -func (f *friendDatabase) BecomeOnewayFriend(ctx context.Context, ownerUserID, friendUserID string, addSource int32) error { +func (f *friendDatabase) BecomeOnewayFriend(ctx context.Context, ownerUserID, friendUserID string, addSource int32, remark string) error { return f.tx.Transaction(ctx, func(ctx context.Context) error { existing, err := f.friend.FindFriends(ctx, ownerUserID, []string{friendUserID}) if err != nil { @@ -427,7 +427,7 @@ func (f *friendDatabase) BecomeOnewayFriend(ctx context.Context, ownerUserID, fr } opUserID := mcontext.GetOpUserID(ctx) if err := f.friend.Create(ctx, []*model.Friend{ - {OwnerUserID: ownerUserID, FriendUserID: friendUserID, AddSource: addSource, OperatorUserID: opUserID}, + {OwnerUserID: ownerUserID, FriendUserID: friendUserID, AddSource: addSource, OperatorUserID: opUserID, Remark: remark}, }); err != nil { return err } diff --git a/protocol b/protocol index ba5857cc0..7f613eb71 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit ba5857cc0817dcfaf6892af0c542385b71f46377 +Subproject commit 7f613eb71f23a69730cfb3c3abd1515da0fb17cf From d51378bd7b57b40aad2878cffed23acbd5c51ef3 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:03:01 +0800 Subject: [PATCH 07/39] pin group message --- internal/api/group.go | 12 + internal/api/router.go | 3 + internal/rpc/group/group.go | 6 +- internal/rpc/group/notification.go | 26 ++ internal/rpc/group/pinned_msg.go | 271 ++++++++++++++++++ pkg/common/storage/controller/group.go | 40 ++- .../storage/database/group_pinned_msg.go | 22 ++ .../storage/database/mgo/group_pinned_msg.go | 115 ++++++++ pkg/common/storage/database/name.go | 1 + pkg/common/storage/model/group_pinned_msg.go | 69 +++++ pkg/rpcli/msg.go | 23 ++ 11 files changed, 577 insertions(+), 11 deletions(-) create mode 100644 internal/rpc/group/pinned_msg.go create mode 100644 pkg/common/storage/database/group_pinned_msg.go create mode 100644 pkg/common/storage/database/mgo/group_pinned_msg.go create mode 100644 pkg/common/storage/model/group_pinned_msg.go diff --git a/internal/api/group.go b/internal/api/group.go index 9a2ffda06..eec11353a 100644 --- a/internal/api/group.go +++ b/internal/api/group.go @@ -173,3 +173,15 @@ func (o *GroupApi) GetGroupApplicationUnhandledCount(c *gin.Context) { func (o *GroupApi) GetCommonGroupsWithFriend(c *gin.Context) { a2r.Call(c, group.GroupClient.GetCommonGroupsWithFriend, o.Client) } + +func (o *GroupApi) PinGroupMessage(c *gin.Context) { + a2r.Call(c, group.GroupClient.PinGroupMessage, o.Client) +} + +func (o *GroupApi) UnpinGroupMessage(c *gin.Context) { + a2r.Call(c, group.GroupClient.UnpinGroupMessage, o.Client) +} + +func (o *GroupApi) GetGroupPinnedMessages(c *gin.Context) { + a2r.Call(c, group.GroupClient.GetGroupPinnedMessages, o.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index accaae666..026f327d7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -243,6 +243,9 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co groupRouterGroup.POST("/get_full_join_group_ids", g.GetFullJoinGroupIDs) groupRouterGroup.POST("/get_group_application_unhandled_count", g.GetGroupApplicationUnhandledCount) groupRouterGroup.POST("/get_common_groups_with_friend", g.GetCommonGroupsWithFriend) + groupRouterGroup.POST("/pin_group_message", g.PinGroupMessage) + groupRouterGroup.POST("/unpin_group_message", g.UnpinGroupMessage) + groupRouterGroup.POST("/get_group_pinned_messages", g.GetGroupPinnedMessages) } // certificate { diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go index f829f07cf..d9d8fd427 100644 --- a/internal/rpc/group/group.go +++ b/internal/rpc/group/group.go @@ -101,6 +101,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + groupPinnedMsgDB, err := mgo.NewGroupPinnedMsgMongo(mgocli.GetDB()) + if err != nil { + return err + } //userRpcClient := rpcclient.NewUserRpcClient(client, config.Share.RpcRegisterName.User, config.Share.IMAdminUserID) //msgRpcClient := rpcclient.NewMessageRpcClient(client, config.Share.RpcRegisterName.Msg) @@ -130,7 +134,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg conversationClient: rpcli.NewConversationClient(conversationConn), //cryptoClient: rpcli.NewCryptoClient(cryptoConn), } - gs.db = controller.NewGroupDatabase(rdb, &config.LocalCacheConfig, groupDB, groupMemberDB, groupRequestDB, mgocli.GetTx(), grouphash.NewGroupHashFromGroupServer(&gs)) + gs.db = controller.NewGroupDatabase(rdb, &config.LocalCacheConfig, groupDB, groupMemberDB, groupRequestDB, groupPinnedMsgDB, mgocli.GetTx(), grouphash.NewGroupHashFromGroupServer(&gs)) gs.notification = NewNotificationSender(gs.db, config, gs.userClient, gs.msgClient, gs.conversationClient) localcache.InitLocalCache(&config.LocalCacheConfig) pbgroup.RegisterGroupServer(server, &gs) diff --git a/internal/rpc/group/notification.go b/internal/rpc/group/notification.go index 95335b2fc..3b1a24688 100644 --- a/internal/rpc/group/notification.go +++ b/internal/rpc/group/notification.go @@ -852,6 +852,32 @@ func (g *NotificationSender) GroupMemberSetToAdminNotification(ctx context.Conte g.Notification(ctx, mcontext.GetOpUserID(ctx), group.GroupID, constant.GroupMemberSetToAdminNotification, tips) } +// GroupMessagePinnedNotification 通知群成员有消息被置顶或取消置顶 +// pinType: 1=置顶, 2=取消置顶 +func (g *NotificationSender) GroupMessagePinnedNotification(ctx context.Context, groupID string, pinType int32, + pinned *sdkws.GroupPinnedMsgInfo, pinnedList []*sdkws.GroupPinnedMsgInfo) { + var err error + defer func() { + if err != nil { + log.ZError(ctx, stringutil.GetFuncName(1)+" failed", err) + } + }() + groupInfo, err := g.getGroupInfo(ctx, groupID) + if err != nil { + return + } + tips := &sdkws.GroupMessagePinnedTips{ + Group: groupInfo, + Type: pinType, + PinnedMsg: pinned, + PinnedList: pinnedList, + } + if err = g.fillOpUser(ctx, &tips.OpUser, groupID); err != nil { + return + } + g.Notification(ctx, mcontext.GetOpUserID(ctx), groupID, constant.GroupMessagePinnedNotification, tips) +} + func (g *NotificationSender) GroupMemberSetToOrdinaryUserNotification(ctx context.Context, groupID, groupMemberUserID string) { var err error defer func() { diff --git a/internal/rpc/group/pinned_msg.go b/internal/rpc/group/pinned_msg.go new file mode 100644 index 000000000..d28977111 --- /dev/null +++ b/internal/rpc/group/pinned_msg.go @@ -0,0 +1,271 @@ +// Copyright © 2026 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. + +package group + +import ( + "context" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/authverify" + "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/open-im-server/v3/pkg/msgprocessor" + "github.com/openimsdk/protocol/constant" + pbgroup "github.com/openimsdk/protocol/group" + "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/mcontext" +) + +// 群置顶消息相关 RPC 实现: +// - 自动滚动保留最近 N 条置顶消息(N=model.GroupPinnedMsgMaxKeep,默认为 3) +// - 置顶时把整条消息内容做完整快照存档,避免后续消息删除/撤回影响展示 +// - 每条置顶记录拥有唯一 pinID,作为 unpin 时的精准删除凭据 +// - 权限:默认全员可置顶;当 group.AllowPinMsg=1 时,仅群主/管理员可置顶或取消置顶 + +const ( + groupPinnedActionPin = int32(1) + groupPinnedActionUnpin = int32(2) +) + +// PinGroupMessage 群聊中置顶单条消息 +func (s *groupServer) PinGroupMessage(ctx context.Context, req *pbgroup.PinGroupMessageReq) (*pbgroup.PinGroupMessageResp, error) { + if req.GroupID == "" { + return nil, errs.ErrArgs.WrapMsg("groupID empty") + } + if req.Seq <= 0 { + return nil, errs.ErrArgs.WrapMsg("seq must be positive") + } + + group, err := s.db.TakeGroup(ctx, req.GroupID) + if err != nil { + return nil, err + } + if group.Status == constant.GroupStatusDismissed { + return nil, servererrs.ErrDismissedAlready.Wrap() + } + + if err := s.checkPinPermission(ctx, group); err != nil { + return nil, err + } + + conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, req.GroupID) + msgData, err := s.msgClient.GetSingleMsgBySeq(ctx, conversationID, req.Seq) + if err != nil { + return nil, err + } + if msgData == nil { + return nil, servererrs.ErrRecordNotFound.WrapMsg("message not found by seq") + } + if msgData.GroupID != "" && msgData.GroupID != req.GroupID { + return nil, errs.ErrArgs.WrapMsg("seq does not belong to this group") + } + if msgData.Status >= constant.MsgStatusHasDeleted { + return nil, servererrs.ErrRecordNotFound.WrapMsg("message has been deleted") + } + + pin := buildPinSnapshot(req.GroupID, conversationID, mcontext.GetOpUserID(ctx), msgData) + + pinnedList, err := s.db.PinGroupMessage(ctx, req.GroupID, pin) + if err != nil { + return nil, err + } + + pbPinned := pinnedMsgDB2PB(pin) + pbList := pinnedListDB2PB(pinnedList) + + s.notification.GroupMessagePinnedNotification(ctx, req.GroupID, groupPinnedActionPin, pbPinned, pbList) + + return &pbgroup.PinGroupMessageResp{ + PinnedMsg: pbPinned, + PinnedList: pbList, + }, nil +} + +// UnpinGroupMessage 群聊中取消置顶单条消息(pinID 优先;为空则按 seq) +func (s *groupServer) UnpinGroupMessage(ctx context.Context, req *pbgroup.UnpinGroupMessageReq) (*pbgroup.UnpinGroupMessageResp, error) { + if req.GroupID == "" { + return nil, errs.ErrArgs.WrapMsg("groupID empty") + } + if req.PinID == "" && req.Seq <= 0 { + return nil, errs.ErrArgs.WrapMsg("either pinID or seq must be provided") + } + + group, err := s.db.TakeGroup(ctx, req.GroupID) + if err != nil { + return nil, err + } + if group.Status == constant.GroupStatusDismissed { + return nil, servererrs.ErrDismissedAlready.Wrap() + } + if err := s.checkPinPermission(ctx, group); err != nil { + return nil, err + } + + current, err := s.db.GetGroupPinnedMessages(ctx, req.GroupID) + if err != nil { + return nil, err + } + var target *model.GroupPinnedMessage + for _, m := range current { + if req.PinID != "" { + if m.PinID == req.PinID { + target = m + break + } + } else if m.Seq == req.Seq { + target = m + break + } + } + if target == nil { + return nil, servererrs.ErrRecordNotFound.WrapMsg("pinned message not found") + } + + pinnedList, err := s.db.UnpinGroupMessage(ctx, req.GroupID, req.PinID, req.Seq) + if err != nil { + return nil, err + } + + pbPinned := pinnedMsgDB2PB(target) + pbList := pinnedListDB2PB(pinnedList) + + s.notification.GroupMessagePinnedNotification(ctx, req.GroupID, groupPinnedActionUnpin, pbPinned, pbList) + + return &pbgroup.UnpinGroupMessageResp{PinnedList: pbList}, nil +} + +// GetGroupPinnedMessages 获取群置顶消息列表 +func (s *groupServer) GetGroupPinnedMessages(ctx context.Context, req *pbgroup.GetGroupPinnedMessagesReq) (*pbgroup.GetGroupPinnedMessagesResp, error) { + if req.GroupID == "" { + return nil, errs.ErrArgs.WrapMsg("groupID empty") + } + if err := s.checkAdminOrInGroup(ctx, req.GroupID); err != nil { + return nil, err + } + pinnedList, err := s.db.GetGroupPinnedMessages(ctx, req.GroupID) + if err != nil { + return nil, err + } + return &pbgroup.GetGroupPinnedMessagesResp{ + PinnedList: pinnedListDB2PB(pinnedList), + }, nil +} + +// checkPinPermission 校验当前操作者是否具备群消息置顶权限 +func (s *groupServer) checkPinPermission(ctx context.Context, group *model.Group) error { + if authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) { + return nil + } + opUserID := mcontext.GetOpUserID(ctx) + if opUserID == "" { + return errs.ErrNoPermission.WrapMsg("op user id empty") + } + member, err := s.db.TakeGroupMember(ctx, group.GroupID, opUserID) + if err != nil { + return err + } + isOwnerOrAdmin := member.RoleLevel == constant.GroupOwner || member.RoleLevel == constant.GroupAdmin + if group.AllowPinMsg == model.GroupPermAdminOnly && !isOwnerOrAdmin { + return errs.ErrNoPermission.WrapMsg("only owner or admin can pin/unpin group message") + } + return nil +} + +// buildPinSnapshot 把 sdkws.MsgData 完整快照成 GroupPinnedMessage +// PinID 在 mgo 层 Pin 时若为空会自动生成;这里留空交由存储层处理 +func buildPinSnapshot(groupID, conversationID, opUserID string, m *sdkws.MsgData) *model.GroupPinnedMessage { + pin := &model.GroupPinnedMessage{ + GroupID: groupID, + ConversationID: conversationID, + Seq: m.Seq, + ServerMsgID: m.ServerMsgID, + ClientMsgID: m.ClientMsgID, + SendID: m.SendID, + RecvID: m.RecvID, + SenderPlatformID: m.SenderPlatformID, + SenderNickname: m.SenderNickname, + SenderFaceURL: m.SenderFaceURL, + SessionType: m.SessionType, + MsgFrom: m.MsgFrom, + ContentType: m.ContentType, + Content: string(m.Content), + AtUserIDList: append([]string(nil), m.AtUserIDList...), + Options: copyOptions(m.Options), + AttachedInfo: m.AttachedInfo, + Ex: m.Ex, + SendTime: m.SendTime, + CreateTime: m.CreateTime, + Status: m.Status, + PinUserID: opUserID, + PinTime: time.Now().UnixMilli(), + } + if m.OfflinePushInfo != nil { + pin.OfflinePush = &model.GroupPinnedOfflinePush{ + Title: m.OfflinePushInfo.Title, + Desc: m.OfflinePushInfo.Desc, + Ex: m.OfflinePushInfo.Ex, + IOSPushSound: m.OfflinePushInfo.IOSPushSound, + IOSBadgeCount: m.OfflinePushInfo.IOSBadgeCount, + SignalInfo: m.OfflinePushInfo.SignalInfo, + } + } + return pin +} + +func copyOptions(src map[string]bool) map[string]bool { + if len(src) == 0 { + return nil + } + dst := make(map[string]bool, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func pinnedMsgDB2PB(m *model.GroupPinnedMessage) *sdkws.GroupPinnedMsgInfo { + if m == nil { + return nil + } + return &sdkws.GroupPinnedMsgInfo{ + PinID: m.PinID, + GroupID: m.GroupID, + ConversationID: m.ConversationID, + Seq: m.Seq, + ServerMsgID: m.ServerMsgID, + ClientMsgID: m.ClientMsgID, + SendID: m.SendID, + RecvID: m.RecvID, + SenderPlatformID: m.SenderPlatformID, + SenderNickname: m.SenderNickname, + SenderFaceURL: m.SenderFaceURL, + SessionType: m.SessionType, + MsgFrom: m.MsgFrom, + ContentType: m.ContentType, + Content: m.Content, + AtUserIDList: append([]string(nil), m.AtUserIDList...), + Options: copyOptions(m.Options), + AttachedInfo: m.AttachedInfo, + Ex: m.Ex, + SendTime: m.SendTime, + CreateTime: m.CreateTime, + Status: m.Status, + PinUserID: m.PinUserID, + PinTime: m.PinTime, + } +} + +func pinnedListDB2PB(list []*model.GroupPinnedMessage) []*sdkws.GroupPinnedMsgInfo { + if len(list) == 0 { + return nil + } + result := make([]*sdkws.GroupPinnedMsgInfo, 0, len(list)) + for _, m := range list { + result = append(result, pinnedMsgDB2PB(m)) + } + return result +} diff --git a/pkg/common/storage/controller/group.go b/pkg/common/storage/controller/group.go index 539f7dccc..f8cbb0933 100644 --- a/pkg/common/storage/controller/group.go +++ b/pkg/common/storage/controller/group.go @@ -126,6 +126,11 @@ type GroupDatabase interface { FindJoinGroupID(ctx context.Context, userID string) ([]string, error) GetGroupApplicationUnhandledCount(ctx context.Context, groupIDs []string, ts int64) (int64, error) + + // 群置顶消息:保留最近 N 条 + PinGroupMessage(ctx context.Context, groupID string, msg *model.GroupPinnedMessage) ([]*model.GroupPinnedMessage, error) + UnpinGroupMessage(ctx context.Context, groupID string, pinID string, seq int64) ([]*model.GroupPinnedMessage, error) + GetGroupPinnedMessages(ctx context.Context, groupID string) ([]*model.GroupPinnedMessage, error) } func NewGroupDatabase( @@ -134,24 +139,39 @@ func NewGroupDatabase( groupDB database.Group, groupMemberDB database.GroupMember, groupRequestDB database.GroupRequest, + groupPinnedMsgDB database.GroupPinnedMsg, ctxTx tx.Tx, groupHash cache.GroupHash, ) GroupDatabase { return &groupDatabase{ - groupDB: groupDB, - groupMemberDB: groupMemberDB, - groupRequestDB: groupRequestDB, - ctxTx: ctxTx, - cache: redis2.NewGroupCacheRedis(rdb, localCache, groupDB, groupMemberDB, groupRequestDB, groupHash, redis2.GetRocksCacheOptions()), + groupDB: groupDB, + groupMemberDB: groupMemberDB, + groupRequestDB: groupRequestDB, + groupPinnedMsgDB: groupPinnedMsgDB, + ctxTx: ctxTx, + cache: redis2.NewGroupCacheRedis(rdb, localCache, groupDB, groupMemberDB, groupRequestDB, groupHash, redis2.GetRocksCacheOptions()), } } type groupDatabase struct { - groupDB database.Group - groupMemberDB database.GroupMember - groupRequestDB database.GroupRequest - ctxTx tx.Tx - cache cache.GroupCache + groupDB database.Group + groupMemberDB database.GroupMember + groupRequestDB database.GroupRequest + groupPinnedMsgDB database.GroupPinnedMsg + ctxTx tx.Tx + cache cache.GroupCache +} + +func (g *groupDatabase) PinGroupMessage(ctx context.Context, groupID string, msg *model.GroupPinnedMessage) ([]*model.GroupPinnedMessage, error) { + return g.groupPinnedMsgDB.Pin(ctx, groupID, msg) +} + +func (g *groupDatabase) UnpinGroupMessage(ctx context.Context, groupID string, pinID string, seq int64) ([]*model.GroupPinnedMessage, error) { + return g.groupPinnedMsgDB.Unpin(ctx, groupID, pinID, seq) +} + +func (g *groupDatabase) GetGroupPinnedMessages(ctx context.Context, groupID string) ([]*model.GroupPinnedMessage, error) { + return g.groupPinnedMsgDB.Get(ctx, groupID) } func (g *groupDatabase) FindJoinGroupID(ctx context.Context, userID string) ([]string, error) { diff --git a/pkg/common/storage/database/group_pinned_msg.go b/pkg/common/storage/database/group_pinned_msg.go new file mode 100644 index 000000000..82a268b6f --- /dev/null +++ b/pkg/common/storage/database/group_pinned_msg.go @@ -0,0 +1,22 @@ +// Copyright © 2026 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. + +package database + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" +) + +// GroupPinnedMsg 群置顶消息的存储抽象 +type GroupPinnedMsg interface { + // Pin 置顶一条消息:若 PinID 为空会自动生成;自动滚动保留最近 N 条 + Pin(ctx context.Context, groupID string, msg *model.GroupPinnedMessage) ([]*model.GroupPinnedMessage, error) + // Unpin 取消置顶;pinID 非空时按 pinID 精确删除,否则按 seq 删除 + Unpin(ctx context.Context, groupID string, pinID string, seq int64) ([]*model.GroupPinnedMessage, error) + // Get 获取群置顶消息列表(最新的在前) + Get(ctx context.Context, groupID string) ([]*model.GroupPinnedMessage, error) +} diff --git a/pkg/common/storage/database/mgo/group_pinned_msg.go b/pkg/common/storage/database/mgo/group_pinned_msg.go new file mode 100644 index 000000000..478bc7ebc --- /dev/null +++ b/pkg/common/storage/database/mgo/group_pinned_msg.go @@ -0,0 +1,115 @@ +// Copyright © 2026 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. + +package mgo + +import ( + "context" + "errors" + + "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/errs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewGroupPinnedMsgMongo(db *mongo.Database) (database.GroupPinnedMsg, error) { + coll := db.Collection(database.GroupPinnedMsgName) + _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: "group_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + return nil, errs.Wrap(err) + } + return &groupPinnedMsgMgo{coll: coll}, nil +} + +type groupPinnedMsgMgo struct { + coll *mongo.Collection +} + +func (g *groupPinnedMsgMgo) get(ctx context.Context, groupID string) (*model.GroupPinnedMsg, error) { + doc, err := mongoutil.FindOne[*model.GroupPinnedMsg](ctx, g.coll, bson.M{"group_id": groupID}) + if err != nil { + if errs.ErrRecordNotFound.Is(err) || errors.Is(err, mongo.ErrNoDocuments) { + return &model.GroupPinnedMsg{GroupID: groupID}, nil + } + return nil, err + } + return doc, nil +} + +func (g *groupPinnedMsgMgo) Get(ctx context.Context, groupID string) ([]*model.GroupPinnedMessage, error) { + doc, err := g.get(ctx, groupID) + if err != nil { + return nil, err + } + return doc.PinnedMsgs, nil +} + +// Pin 置顶一条消息: +// - 若提供的 msg.PinID 为空,则自动生成 ObjectID().Hex() +// - 同 seq 的旧记录会被先移除避免重复 +// - 新记录 push 到数组首位,自动滚动保留最近 GroupPinnedMsgMaxKeep 条 +func (g *groupPinnedMsgMgo) Pin(ctx context.Context, groupID string, msg *model.GroupPinnedMessage) ([]*model.GroupPinnedMessage, error) { + if msg == nil { + return nil, errs.ErrArgs.WrapMsg("pin msg is nil") + } + if msg.PinID == "" { + msg.PinID = primitive.NewObjectID().Hex() + } + msg.GroupID = groupID + + if _, err := mongoutil.UpdateOneResult(ctx, g.coll, + bson.M{"group_id": groupID}, + bson.M{"$pull": bson.M{"pinned_msgs": bson.M{"seq": msg.Seq}}}, + ); err != nil { + return nil, err + } + filter := bson.M{"group_id": groupID} + update := bson.M{ + "$push": bson.M{ + "pinned_msgs": bson.M{ + "$each": bson.A{msg}, + "$position": 0, + "$slice": model.GroupPinnedMsgMaxKeep, + }, + }, + "$setOnInsert": bson.M{"group_id": groupID}, + } + opts := options.Update().SetUpsert(true) + if _, err := g.coll.UpdateOne(ctx, filter, update, opts); err != nil { + return nil, errs.Wrap(err) + } + return g.Get(ctx, groupID) +} + +// Unpin 取消置顶: +// - pinID 非空时按 pinID 精确删除(推荐) +// - 否则按 seq 删除 +// 返回更新后的置顶列表(可能为空数组) +func (g *groupPinnedMsgMgo) Unpin(ctx context.Context, groupID string, pinID string, seq int64) ([]*model.GroupPinnedMessage, error) { + if pinID == "" && seq <= 0 { + return nil, errs.ErrArgs.WrapMsg("either pinID or seq must be provided") + } + pull := bson.M{} + if pinID != "" { + pull["pin_id"] = pinID + } else { + pull["seq"] = seq + } + if _, err := mongoutil.UpdateOneResult(ctx, g.coll, + bson.M{"group_id": groupID}, + bson.M{"$pull": bson.M{"pinned_msgs": pull}}, + ); err != nil { + return nil, err + } + return g.Get(ctx, groupID) +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 100e6d112..0fd9b3b2e 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -12,6 +12,7 @@ const ( GroupJoinVersionName = "group_join_version" ConversationVersionName = "conversation_version" GroupRequestName = "group_request" + GroupPinnedMsgName = "group_pinned_msg" LogName = "log" ObjectName = "s3" UserName = "user" diff --git a/pkg/common/storage/model/group_pinned_msg.go b/pkg/common/storage/model/group_pinned_msg.go new file mode 100644 index 000000000..a80780875 --- /dev/null +++ b/pkg/common/storage/model/group_pinned_msg.go @@ -0,0 +1,69 @@ +// Copyright © 2026 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. + +package model + +// GroupPinnedMsgMaxKeep 群置顶消息最多保留的条数(最新置顶的在最前) +const GroupPinnedMsgMaxKeep = 3 + +// GroupPinnedOfflinePush 离线推送信息快照 +type GroupPinnedOfflinePush struct { + Title string `bson:"title"` + Desc string `bson:"desc"` + Ex string `bson:"ex"` + IOSPushSound string `bson:"ios_push_sound"` + IOSBadgeCount bool `bson:"ios_badge_count"` + SignalInfo string `bson:"signal_info"` +} + +// GroupPinnedMessage 一条群置顶消息的完整内容快照 +// 置顶时把消息整体快照入库,避免后续消息删除/撤回影响已置顶展示 +type GroupPinnedMessage struct { + // PinID 全局唯一 id,用于精准取消置顶(生产由 mongo ObjectID().Hex() 生成) + PinID string `bson:"pin_id"` + + // 会话 / 群信息 + ConversationID string `bson:"conversation_id"` + GroupID string `bson:"group_id"` + + // 消息标识 + Seq int64 `bson:"seq"` + ServerMsgID string `bson:"server_msg_id"` + ClientMsgID string `bson:"client_msg_id"` + + // 发送方信息 + SendID string `bson:"send_id"` + RecvID string `bson:"recv_id"` + SenderPlatformID int32 `bson:"sender_platform_id"` + SenderNickname string `bson:"sender_nickname"` + SenderFaceURL string `bson:"sender_face_url"` + + // 消息内容快照 + SessionType int32 `bson:"session_type"` + MsgFrom int32 `bson:"msg_from"` + ContentType int32 `bson:"content_type"` + Content string `bson:"content"` + AtUserIDList []string `bson:"at_user_id_list"` + Options map[string]bool `bson:"options"` + AttachedInfo string `bson:"attached_info"` + Ex string `bson:"ex"` + + OfflinePush *GroupPinnedOfflinePush `bson:"offline_push"` + + // 时间 + SendTime int64 `bson:"send_time"` + CreateTime int64 `bson:"create_time"` + Status int32 `bson:"status"` + + // 操作人 & 时间 + PinUserID string `bson:"pin_user_id"` + PinTime int64 `bson:"pin_time"` +} + +// GroupPinnedMsg 一个群的置顶消息文档,按 group_id 唯一 +type GroupPinnedMsg struct { + GroupID string `bson:"group_id"` + PinnedMsgs []*GroupPinnedMessage `bson:"pinned_msgs"` +} diff --git a/pkg/rpcli/msg.go b/pkg/rpcli/msg.go index e4d1ece6e..d439d0c12 100644 --- a/pkg/rpcli/msg.go +++ b/pkg/rpcli/msg.go @@ -75,6 +75,29 @@ func (x *MsgClient) GetActiveConversation(ctx context.Context, conversationIDs [ return extractField(ctx, x.MsgClient.GetActiveConversation, req, (*msg.GetActiveConversationResp).GetConversations) } +// GetSingleMsgBySeq 根据会话 ID 与 seq 拉取一条消息(不存在时返回 nil) +func (x *MsgClient) GetSingleMsgBySeq(ctx context.Context, conversationID string, seq int64) (*sdkws.MsgData, error) { + if conversationID == "" || seq <= 0 { + return nil, nil + } + req := &msg.GetMsgByConversationIDsReq{ + ConversationIDs: []string{conversationID}, + MaxSeqs: map[string]int64{conversationID: seq}, + } + resp, err := x.MsgClient.GetMsgByConversationIDs(ctx, req) + if err != nil { + return nil, err + } + m := resp.GetMsgDatas() + if len(m) == 0 { + return nil, nil + } + if v, ok := m[conversationID]; ok && v != nil && v.Seq == seq { + return v, nil + } + return nil, nil +} + func (x *MsgClient) GetSeqMessage(ctx context.Context, userID string, conversations []*msg.ConversationSeqs) (map[string]*sdkws.PullMsgs, error) { if len(conversations) == 0 { return nil, nil From bede0f275fc7ee7ed8d747c961298bae590a2a0c Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 1 May 2026 02:17:52 +0800 Subject: [PATCH 08/39] first name --- internal/api/router.go | 32 +++++++++++++++++++++++++++++--- internal/rpc/relation/friend.go | 8 ++++++-- pkg/common/convert/friend.go | 11 +++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 026f327d7..52190ecce 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -12,6 +12,7 @@ import ( pbcrypto "github.com/openimsdk/protocol/crypto" "github.com/openimsdk/protocol/group" "github.com/openimsdk/protocol/msg" + pbredpacket "github.com/openimsdk/protocol/redpacket" "github.com/openimsdk/protocol/relation" "github.com/openimsdk/protocol/rtc" "github.com/openimsdk/protocol/third" @@ -117,6 +118,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co if err != nil { return nil, err } + redpacketConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.RedPacket) + if err != nil { + return nil, err + } gin.SetMode(gin.ReleaseMode) r := gin.New() if v, ok := binding.Validator.Engine().(*validator.Validate); ok { @@ -243,9 +248,6 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co groupRouterGroup.POST("/get_full_join_group_ids", g.GetFullJoinGroupIDs) groupRouterGroup.POST("/get_group_application_unhandled_count", g.GetGroupApplicationUnhandledCount) groupRouterGroup.POST("/get_common_groups_with_friend", g.GetCommonGroupsWithFriend) - groupRouterGroup.POST("/pin_group_message", g.PinGroupMessage) - groupRouterGroup.POST("/unpin_group_message", g.UnpinGroupMessage) - groupRouterGroup.POST("/get_group_pinned_messages", g.GetGroupPinnedMessages) } // certificate { @@ -366,6 +368,30 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co cryptoGroup.POST("/integrity_report", cr.IntegrityReport) } + // RedPacket + { + rp := NewRedPacketApi(pbredpacket.NewRedPacketClient(redpacketConn)) + redpacketGroup := r.Group("/redpacket") + redpacketGroup.POST("/create_order", rp.CreateOrder) + redpacketGroup.POST("/created_callback", rp.CreatedCallback) + redpacketGroup.POST("/detail", rp.GetDetail) + redpacketGroup.POST("/issue_claim_sign", rp.IssueClaimSign) + redpacketGroup.POST("/claim_result", rp.ClaimResult) + redpacketGroup.POST("/request_refund", rp.RequestRefund) + redpacketGroup.POST("/get_refund", rp.GetRefund) + redpacketGroup.POST("/wallet_bind/challenge", rp.IssueWalletBindChallenge) + redpacketGroup.POST("/wallet_bind/confirm", rp.ConfirmWalletBind) + redpacketGroup.POST("/wallet_bind/detail", rp.GetWalletBinding) + + adminGroup := redpacketGroup.Group("/admin") + adminGroup.POST("/set_signer", rp.AdminSetSigner) + adminGroup.POST("/set_token", rp.AdminSetToken) + adminGroup.POST("/set_expiry", rp.AdminSetExpiry) + adminGroup.POST("/set_allow_all_tokens", rp.AdminSetAllowAllTokens) + adminGroup.POST("/set_native_token_enabled", rp.AdminSetNativeTokenEnabled) + adminGroup.POST("/parse_tx_events", rp.AdminParseTxEvents) + } + { statisticsGroup := r.Group("/statistics") statisticsGroup.POST("/user/register", u.UserRegisterCount) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index ef44566f2..d9ded4943 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -314,7 +314,11 @@ func (s *friendServer) GetFriendInfo(ctx context.Context, req *relation.GetFrien if err != nil { return nil, err } - return &relation.GetFriendInfoResp{FriendInfos: convert.FriendOnlyDB2PbOnly(friends)}, nil + users, err := s.userClient.GetUsersInfoMap(ctx, req.FriendUserIDs) + if err != nil { + return nil, err + } + return &relation.GetFriendInfoResp{FriendInfos: convert.FriendOnlyDB2PbOnly(friends, users)}, nil } func (s *friendServer) GetDesignatedFriends(ctx context.Context, req *relation.GetDesignatedFriendsReq) (resp *relation.GetDesignatedFriendsResp, err error) { @@ -693,7 +697,7 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT if in1 { return nil, servererrs.ErrRelationshipAlready.WrapMsg("already in friend list") } - if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway, req.Remark); err != nil { + if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway); err != nil { return nil, err } // Notify only A (FromUserID) so incremental friend sync is triggered diff --git a/pkg/common/convert/friend.go b/pkg/common/convert/friend.go index 994c6d7d5..e530edca2 100644 --- a/pkg/common/convert/friend.go +++ b/pkg/common/convert/friend.go @@ -78,6 +78,8 @@ func FriendsDB2Pb(ctx context.Context, friendsDB []*model.Friend, getUsers func( friendPb.FriendUser.Nickname = users[friend.FriendUserID].Nickname friendPb.FriendUser.FaceURL = users[friend.FriendUserID].FaceURL friendPb.FriendUser.Ex = users[friend.FriendUserID].Ex + friendPb.FriendUser.FirstName = users[friend.FriendUserID].FirstName + friendPb.FriendUser.LastName = users[friend.FriendUserID].LastName friendPb.CreateTime = friend.CreateTime.Unix() friendPb.IsPinned = friend.IsPinned friendPb.IsMute = friend.IsMuted @@ -88,9 +90,9 @@ func FriendsDB2Pb(ctx context.Context, friendsDB []*model.Friend, getUsers func( return friendsPb, nil } -func FriendOnlyDB2PbOnly(friendsDB []*model.Friend) []*relation.FriendInfoOnly { +func FriendOnlyDB2PbOnly(friendsDB []*model.Friend, users map[string]*sdkws.UserInfo) []*relation.FriendInfoOnly { return datautil.Slice(friendsDB, func(f *model.Friend) *relation.FriendInfoOnly { - return &relation.FriendInfoOnly{ + info := &relation.FriendInfoOnly{ OwnerUserID: f.OwnerUserID, FriendUserID: f.FriendUserID, Remark: f.Remark, @@ -103,6 +105,11 @@ func FriendOnlyDB2PbOnly(friendsDB []*model.Friend) []*relation.FriendInfoOnly { MuteDuration: f.MuteDuration, MuteEndTime: f.MuteEndTime, } + if u, ok := users[f.FriendUserID]; ok { + info.FirstName = u.FirstName + info.LastName = u.LastName + } + return info }) } From c93ce2f8e5b7ef32795b4de7bcafb8013f9459bd Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 1 May 2026 02:20:49 +0800 Subject: [PATCH 09/39] first name --- internal/rpc/relation/friend.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index d9ded4943..d39bb2c4e 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -702,13 +702,13 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT } // Notify only A (FromUserID) so incremental friend sync is triggered // without notifying B (ToUserID). - tips := sdkws.FriendApplicationApprovedTips{ - FromToUserID: &sdkws.FromToUserID{ - FromUserID: req.FromUserID, - ToUserID: req.ToUserID, - }, - } - s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) + //tips := sdkws.FriendApplicationApprovedTips{ + // FromToUserID: &sdkws.FromToUserID{ + // FromUserID: req.FromUserID, + // ToUserID: req.ToUserID, + // }, + //} + //s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) return &relation.ApplyToAddFriendResp{}, nil } From d346fcb4d67dc29c0dadf687ee839927832ade46 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 1 May 2026 02:27:52 +0800 Subject: [PATCH 10/39] first name --- internal/rpc/relation/friend.go | 20 ++++++++++++-------- pkg/common/convert/friend.go | 11 +++++++++-- protocol | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index ef44566f2..a1c2d24cd 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -314,7 +314,11 @@ func (s *friendServer) GetFriendInfo(ctx context.Context, req *relation.GetFrien if err != nil { return nil, err } - return &relation.GetFriendInfoResp{FriendInfos: convert.FriendOnlyDB2PbOnly(friends)}, nil + users, err := s.userClient.GetUsersInfoMap(ctx, req.FriendUserIDs) + if err != nil { + return nil, err + } + return &relation.GetFriendInfoResp{FriendInfos: convert.FriendOnlyDB2PbOnly(friends, users)}, nil } func (s *friendServer) GetDesignatedFriends(ctx context.Context, req *relation.GetDesignatedFriendsReq) (resp *relation.GetDesignatedFriendsResp, err error) { @@ -698,13 +702,13 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT } // Notify only A (FromUserID) so incremental friend sync is triggered // without notifying B (ToUserID). - tips := sdkws.FriendApplicationApprovedTips{ - FromToUserID: &sdkws.FromToUserID{ - FromUserID: req.FromUserID, - ToUserID: req.ToUserID, - }, - } - s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) + //tips := sdkws.FriendApplicationApprovedTips{ + // FromToUserID: &sdkws.FromToUserID{ + // FromUserID: req.FromUserID, + // ToUserID: req.ToUserID, + // }, + //} + //s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) return &relation.ApplyToAddFriendResp{}, nil } diff --git a/pkg/common/convert/friend.go b/pkg/common/convert/friend.go index 994c6d7d5..e530edca2 100644 --- a/pkg/common/convert/friend.go +++ b/pkg/common/convert/friend.go @@ -78,6 +78,8 @@ func FriendsDB2Pb(ctx context.Context, friendsDB []*model.Friend, getUsers func( friendPb.FriendUser.Nickname = users[friend.FriendUserID].Nickname friendPb.FriendUser.FaceURL = users[friend.FriendUserID].FaceURL friendPb.FriendUser.Ex = users[friend.FriendUserID].Ex + friendPb.FriendUser.FirstName = users[friend.FriendUserID].FirstName + friendPb.FriendUser.LastName = users[friend.FriendUserID].LastName friendPb.CreateTime = friend.CreateTime.Unix() friendPb.IsPinned = friend.IsPinned friendPb.IsMute = friend.IsMuted @@ -88,9 +90,9 @@ func FriendsDB2Pb(ctx context.Context, friendsDB []*model.Friend, getUsers func( return friendsPb, nil } -func FriendOnlyDB2PbOnly(friendsDB []*model.Friend) []*relation.FriendInfoOnly { +func FriendOnlyDB2PbOnly(friendsDB []*model.Friend, users map[string]*sdkws.UserInfo) []*relation.FriendInfoOnly { return datautil.Slice(friendsDB, func(f *model.Friend) *relation.FriendInfoOnly { - return &relation.FriendInfoOnly{ + info := &relation.FriendInfoOnly{ OwnerUserID: f.OwnerUserID, FriendUserID: f.FriendUserID, Remark: f.Remark, @@ -103,6 +105,11 @@ func FriendOnlyDB2PbOnly(friendsDB []*model.Friend) []*relation.FriendInfoOnly { MuteDuration: f.MuteDuration, MuteEndTime: f.MuteEndTime, } + if u, ok := users[f.FriendUserID]; ok { + info.FirstName = u.FirstName + info.LastName = u.LastName + } + return info }) } diff --git a/protocol b/protocol index 7f613eb71..0db6a7324 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 7f613eb71f23a69730cfb3c3abd1515da0fb17cf +Subproject commit 0db6a732426df40792921f861112e32785405e8d From 8c442c2e244f2069d1ea891c5b49c441fec03f68 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sun, 3 May 2026 14:47:47 +0800 Subject: [PATCH 11/39] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=80=9A=E7=9F=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/relation/friend.go | 14 +++++--------- internal/rpc/relation/notification.go | 13 +++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index a1c2d24cd..3c3c844e0 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -700,15 +700,11 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway, req.Remark); err != nil { return nil, err } - // Notify only A (FromUserID) so incremental friend sync is triggered - // without notifying B (ToUserID). - //tips := sdkws.FriendApplicationApprovedTips{ - // FromToUserID: &sdkws.FromToUserID{ - // FromUserID: req.FromUserID, - // ToUserID: req.ToUserID, - // }, - //} - //s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) + + // Silently notify only A (FromUserID) to trigger an incremental friend-list sync + // so the remark is reflected in the conversation list. + // B (ToUserID) receives no notification of any kind. + s.notificationSender.FriendAddedOnewayNotification(ctx, req.FromUserID, req.ToUserID) return &relation.ApplyToAddFriendResp{}, nil } diff --git a/internal/rpc/relation/notification.go b/internal/rpc/relation/notification.go index 4ee45e197..d9d773c76 100644 --- a/internal/rpc/relation/notification.go +++ b/internal/rpc/relation/notification.go @@ -282,6 +282,19 @@ func (f *FriendNotificationSender) FriendsInfoUpdateNotification(ctx context.Con f.Notification(ctx, toUserID, toUserID, constant.FriendsInfoUpdateNotification, &tips) } +// FriendAddedOnewayNotification silently notifies ownerUserID that friendUserID has been added +// to their friend list (one-way, no consent from friendUserID required). +// isSendMsg=false ensures no visible message appears in either user's conversation list. +func (f *FriendNotificationSender) FriendAddedOnewayNotification(ctx context.Context, ownerUserID, friendUserID string) { + tips := sdkws.FriendsInfoUpdateTips{ + FromToUserID: &sdkws.FromToUserID{ToUserID: ownerUserID}, + FriendIDs: []string{friendUserID}, + } + f.setSortVersion(ctx, &tips.FriendVersion, &tips.FriendVersionID, + database.FriendVersionName, ownerUserID, &tips.FriendSortVersion) + f.Notification(ctx, ownerUserID, ownerUserID, constant.FriendsInfoUpdateNotification, &tips) +} + func (f *FriendNotificationSender) BlackAddedNotification(ctx context.Context, req *relation.AddBlackReq) { tips := sdkws.BlackAddedTips{FromToUserID: &sdkws.FromToUserID{}} tips.FromToUserID.FromUserID = req.OwnerUserID From c0076e12024b66ff0e81f7dc18a25b21d2be9c00 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sun, 3 May 2026 17:25:42 +0800 Subject: [PATCH 12/39] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=80=9A=E7=9F=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/relation/friend.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index 3c3c844e0..861ae1ecd 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -704,7 +704,17 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT // Silently notify only A (FromUserID) to trigger an incremental friend-list sync // so the remark is reflected in the conversation list. // B (ToUserID) receives no notification of any kind. - s.notificationSender.FriendAddedOnewayNotification(ctx, req.FromUserID, req.ToUserID) + //s.notificationSender.FriendAddedOnewayNotification(ctx, req.FromUserID, req.ToUserID) + + // Notify only A (FromUserID) so incremental friend sync is triggered + // without notifying B (ToUserID). + tips := sdkws.FriendApplicationApprovedTips{ + FromToUserID: &sdkws.FromToUserID{ + FromUserID: req.FromUserID, + ToUserID: req.ToUserID, + }, + } + s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) return &relation.ApplyToAddFriendResp{}, nil } From 5118bcc1c097c90b89a5199c8b48bbf59c2c0afc Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sun, 3 May 2026 22:05:57 +0800 Subject: [PATCH 13/39] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=80=9A=E7=9F=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 1 + internal/rpc/relation/friend.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index accaae666..11caa06b9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -205,6 +205,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co 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) + friendRouterGroup.POST("/add_oneway_friend", f.AddOnewayFriend) } g := NewGroupApi(group.NewGroupClient(groupConn)) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index 861ae1ecd..770158882 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -704,17 +704,17 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT // Silently notify only A (FromUserID) to trigger an incremental friend-list sync // so the remark is reflected in the conversation list. // B (ToUserID) receives no notification of any kind. - //s.notificationSender.FriendAddedOnewayNotification(ctx, req.FromUserID, req.ToUserID) + s.notificationSender.FriendAddedOnewayNotification(ctx, req.FromUserID, req.ToUserID) // Notify only A (FromUserID) so incremental friend sync is triggered // without notifying B (ToUserID). - tips := sdkws.FriendApplicationApprovedTips{ - FromToUserID: &sdkws.FromToUserID{ - FromUserID: req.FromUserID, - ToUserID: req.ToUserID, - }, - } - s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) + //tips := sdkws.FriendApplicationApprovedTips{ + // FromToUserID: &sdkws.FromToUserID{ + // FromUserID: req.FromUserID, + // ToUserID: req.ToUserID, + // }, + //} + //s.notificationSender.Notification(ctx, req.FromUserID, req.FromUserID, constant.FriendApplicationApprovedNotification, &tips) return &relation.ApplyToAddFriendResp{}, nil } From f9250283c1af3da5d2aa6341a0e71863254650a1 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 16:11:26 +0800 Subject: [PATCH 14/39] update user fisrtname and lastname --- pkg/common/convert/user.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index 4cf094c24..8305f56c8 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -115,6 +115,23 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.Ex != nil { val["ex"] = user.Ex.Value } + if user.FirstName != nil { + val["first_name"] = user.FirstName.Value + } + if user.LastName != nil { + val["last_name"] = user.LastName.Value + } + if user.FirstName != nil || user.LastName != nil { + firstName := "" + lastName := "" + if user.FirstName != nil { + firstName = user.FirstName.Value + } + if user.LastName != nil { + lastName = user.LastName.Value + } + val["full_name"] = BuildFullName(firstName, lastName) + } if user.GlobalRecvMsgOpt != nil { val["global_recv_msg_opt"] = user.GlobalRecvMsgOpt.Value } @@ -130,7 +147,5 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.MsgReceiveSetting != nil { val["msg_receive_setting"] = user.MsgReceiveSetting.Value } - // TODO: Add FirstName/LastName support to UserInfoWithEx proto when regenerated - return val } From 1444496c9d4e48dca03c2ea4eeb7c5ed9f729571 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 17:33:14 +0800 Subject: [PATCH 15/39] =?UTF-8?q?=E6=9D=A5=E7=94=B5=E9=93=83=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/rtc/signal.go | 40 +++++++++++++++++++++++++++----- pkg/common/convert/user.go | 24 ++++++++++++------- pkg/common/storage/model/user.go | 2 ++ protocol | 2 +- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index b20e02d36..393f1f2bc 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -108,6 +108,19 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, } } + // 从主叫用户资料获取铃声 URL,注入到邀请信息中,被叫方收到后播放主叫方铃声 + if inviterInfo, err := s.userClient.GetUserInfo(ctx, req.UserID); err == nil && inviterInfo.CallRingtoneURL != "" { + inv.CallerRingtoneURL = inviterInfo.CallRingtoneURL + } + + // 查询被叫方铃声 URL,供主叫方在等待时播放 + var calleeRingtoneURL string + if len(inv.InviteeUserIDList) > 0 { + if inviteeInfo, err := s.userClient.GetUserInfo(ctx, inv.InviteeUserIDList[0]); err == nil { + calleeRingtoneURL = inviteeInfo.CallRingtoneURL + } + } + 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) @@ -147,9 +160,10 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, log.ZDebug(ctx, "handleInvite", "token", token, "roomID", inv.RoomID, "liveURL", s.config.RpcConfig.LiveKit.ExternalAddress) return &rtc.SignalInviteResp{ - Token: token, - RoomID: inv.RoomID, - LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + Token: token, + RoomID: inv.RoomID, + LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + CalleeRingtoneURL: calleeRingtoneURL, }, nil } @@ -164,6 +178,19 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() + // 从主叫用户资料获取铃声 URL,注入到邀请信息中,被叫方收到后播放主叫方铃声 + if inviterInfo, err := s.userClient.GetUserInfo(ctx, req.UserID); err == nil && inviterInfo.CallRingtoneURL != "" { + inv.CallerRingtoneURL = inviterInfo.CallRingtoneURL + } + + // 查询第一位被叫的铃声 URL,供主叫方在等待时播放 + var calleeRingtoneURL string + if len(inv.InviteeUserIDList) > 0 { + if inviteeInfo, err := s.userClient.GetUserInfo(ctx, inv.InviteeUserIDList[0]); err == nil { + calleeRingtoneURL = inviteeInfo.CallRingtoneURL + } + } + if _, err := s.roomClient.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: inv.RoomID}); err != nil { return nil, errs.WrapMsg(err, "LiveKit CreateRoom failed", "roomID", inv.RoomID) } @@ -206,9 +233,10 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi } return &rtc.SignalInviteInGroupResp{ - Token: token, - RoomID: inv.RoomID, - LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + Token: token, + RoomID: inv.RoomID, + LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + CalleeRingtoneURL: calleeRingtoneURL, }, nil } diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index 8305f56c8..b49c7b7c5 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -49,6 +49,7 @@ func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { PhoneVisibility: user.PhoneVisibility, CallAcceptSetting: user.CallAcceptSetting, MsgReceiveSetting: user.MsgReceiveSetting, + CallRingtoneURL: user.CallRingtoneURL, } } @@ -59,16 +60,17 @@ func UsersDB2Pb(users []*relationtb.User) []*sdkws.UserInfo { func UserPb2DB(user *sdkws.UserInfo) *relationtb.User { fullName := BuildFullName(user.FirstName, user.LastName) return &relationtb.User{ - UserID: user.UserID, - Nickname: user.Nickname, - FaceURL: user.FaceURL, - Ex: user.Ex, - CreateTime: time.UnixMilli(user.CreateTime), - AppMangerLevel: user.AppMangerLevel, + UserID: user.UserID, + Nickname: user.Nickname, + FaceURL: user.FaceURL, + Ex: user.Ex, + CreateTime: time.UnixMilli(user.CreateTime), + AppMangerLevel: user.AppMangerLevel, GlobalRecvMsgOpt: user.GlobalRecvMsgOpt, - FirstName: user.FirstName, - LastName: user.LastName, - FullName: fullName, + FirstName: user.FirstName, + LastName: user.LastName, + FullName: fullName, + CallRingtoneURL: user.CallRingtoneURL, } } @@ -85,6 +87,7 @@ func UserPb2DBMap(user *sdkws.UserInfo) map[string]any { "last_name": user.LastName, "app_manager_level": user.AppMangerLevel, "global_recv_msg_opt": user.GlobalRecvMsgOpt, + "call_ringtone_url": user.CallRingtoneURL, } for key, value := range fields { if v, ok := value.(string); ok && v != "" { @@ -147,5 +150,8 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.MsgReceiveSetting != nil { val["msg_receive_setting"] = user.MsgReceiveSetting.Value } + if user.CallRingtoneURL != nil { + val["call_ringtone_url"] = user.CallRingtoneURL.Value + } return val } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 8c2e18167..fa6786947 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -57,6 +57,8 @@ type User struct { PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` MsgReceiveSetting int32 `bson:"msg_receive_setting"` + // CallRingtoneURL 用户自定义来电铃声 URL;对方来电时播放此铃声 + CallRingtoneURL string `bson:"call_ringtone_url"` } func (u *User) GetNickname() string { diff --git a/protocol b/protocol index 0db6a7324..8bdb641d1 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 0db6a732426df40792921f861112e32785405e8d +Subproject commit 8bdb641d1356efc61620c0d37647a86f5dc94973 From a305b7f8624bfd39fff57a293b37175ac6091f7d Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 20:27:11 +0800 Subject: [PATCH 16/39] update user fisrtname and lastname --- pkg/common/storage/database/mgo/user.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index 5a2dc7e34..ef599b782 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -63,7 +63,15 @@ func (u *UserMgo) UpdateByMap(ctx context.Context, userID string, args map[strin if len(args) == 0 { return nil } - return mongoutil.UpdateOne(ctx, u.coll, bson.M{"user_id": userID}, bson.M{"$set": args}, true) + filter := bson.M{"user_id": userID} + update := bson.M{"$set": args} + if err := mongoutil.UpdateOne(ctx, u.coll, filter, update, true); err != nil { + return err + } + + // Keep user attributes in sync for consumers that read from the "attribute" collection. + attributeColl := u.coll.Database().Collection("attribute") + return mongoutil.UpdateOne(ctx, attributeColl, filter, update, true) } func (u *UserMgo) Find(ctx context.Context, userIDs []string) (users []*model.User, err error) { From 64cc500c0dc55f0827d7b01c1aff525e5e02a911 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 20:35:09 +0800 Subject: [PATCH 17/39] update user fisrtname and lastname --- pkg/common/storage/database/mgo/user.go | 33 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index ef599b782..15f86fd07 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -16,9 +16,10 @@ 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" - "time" "github.com/openimsdk/protocol/user" "github.com/openimsdk/tools/db/mongoutil" @@ -69,9 +70,35 @@ func (u *UserMgo) UpdateByMap(ctx context.Context, userID string, args map[strin return err } + return nil + // Keep user attributes in sync for consumers that read from the "attribute" collection. - attributeColl := u.coll.Database().Collection("attribute") - return mongoutil.UpdateOne(ctx, attributeColl, filter, update, true) + // Only sync the allowed attribute fields. + //attributeSet := make(map[string]any) + //for _, key := range []string{ + // "nickname", + // "first_name", + // "last_name", + // "full_name", + // "remark", + // "face_url", + // "phone_number", + // "area_code", + //} { + // if v, ok := args[key]; ok { + // attributeSet[key] = v + // } + //} + //// user collection uses "phone"; attribute collection uses "phone_number". + //if v, ok := args["phone"]; ok { + // attributeSet["phone_number"] = v + //} + //if len(attributeSet) == 0 { + // return nil + //} + + //attributeColl := u.coll.Database().Collection("attribute") + //return mongoutil.UpdateOne(ctx, attributeColl, filter, bson.M{"$set": attributeSet}, true) } func (u *UserMgo) Find(ctx context.Context, userIDs []string) (users []*model.User, err error) { From ae00605f22ecf3318059a0022f4cc62fb1a6d06f Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 20:56:35 +0800 Subject: [PATCH 18/39] update area_code --- pkg/common/convert/user.go | 6 ++++++ pkg/common/storage/model/user.go | 1 + protocol | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index b49c7b7c5..2c4063cbf 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -46,6 +46,7 @@ func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { FirstName: user.FirstName, LastName: user.LastName, Phone: user.Phone, + AreaCode: user.AreaCode, PhoneVisibility: user.PhoneVisibility, CallAcceptSetting: user.CallAcceptSetting, MsgReceiveSetting: user.MsgReceiveSetting, @@ -70,6 +71,7 @@ func UserPb2DB(user *sdkws.UserInfo) *relationtb.User { FirstName: user.FirstName, LastName: user.LastName, FullName: fullName, + AreaCode: user.AreaCode, CallRingtoneURL: user.CallRingtoneURL, } } @@ -85,6 +87,7 @@ func UserPb2DBMap(user *sdkws.UserInfo) map[string]any { "ex": user.Ex, "first_name": user.FirstName, "last_name": user.LastName, + "area_code": user.AreaCode, "app_manager_level": user.AppMangerLevel, "global_recv_msg_opt": user.GlobalRecvMsgOpt, "call_ringtone_url": user.CallRingtoneURL, @@ -141,6 +144,9 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.Phone != nil { val["phone"] = user.Phone.Value } + if user.AreaCode != nil { + val["area_code"] = user.AreaCode.Value + } if user.PhoneVisibility != nil { val["phone_visibility"] = user.PhoneVisibility.Value } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index fa6786947..14f026fac 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -54,6 +54,7 @@ type User struct { LastName string `bson:"last_name"` FullName string `bson:"full_name"` Phone string `bson:"phone"` + AreaCode string `bson:"area_code"` PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` MsgReceiveSetting int32 `bson:"msg_receive_setting"` diff --git a/protocol b/protocol index 8bdb641d1..3b211f91d 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8bdb641d1356efc61620c0d37647a86f5dc94973 +Subproject commit 3b211f91d0e6b98797f91ba34fa64a7b47df5645 From e37cea2d6469368a860b1efa23973a0c6c510a50 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 6 May 2026 21:08:38 +0800 Subject: [PATCH 19/39] update area_code --- pkg/common/storage/database/mgo/user.go | 51 ++++++++++++------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index 15f86fd07..d5f64a5ab 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -69,36 +69,33 @@ func (u *UserMgo) UpdateByMap(ctx context.Context, userID string, args map[strin if err := mongoutil.UpdateOne(ctx, u.coll, filter, update, true); err != nil { return err } - - return nil - // Keep user attributes in sync for consumers that read from the "attribute" collection. // Only sync the allowed attribute fields. - //attributeSet := make(map[string]any) - //for _, key := range []string{ - // "nickname", - // "first_name", - // "last_name", - // "full_name", - // "remark", - // "face_url", - // "phone_number", - // "area_code", - //} { - // if v, ok := args[key]; ok { - // attributeSet[key] = v - // } - //} + attributeSet := make(map[string]any) + for _, key := range []string{ + "nickname", + "first_name", + "last_name", + "full_name", + "remark", + "face_url", + "phone_number", + "area_code", + } { + if v, ok := args[key]; ok { + attributeSet[key] = v + } + } //// user collection uses "phone"; attribute collection uses "phone_number". - //if v, ok := args["phone"]; ok { - // attributeSet["phone_number"] = v - //} - //if len(attributeSet) == 0 { - // return nil - //} - - //attributeColl := u.coll.Database().Collection("attribute") - //return mongoutil.UpdateOne(ctx, attributeColl, filter, bson.M{"$set": attributeSet}, true) + if v, ok := args["phone"]; ok { + attributeSet["phone_number"] = v + } + if len(attributeSet) == 0 { + return nil + } + + attributeColl := u.coll.Database().Collection("attribute") + return mongoutil.UpdateOne(ctx, attributeColl, filter, bson.M{"$set": attributeSet}, true) } func (u *UserMgo) Find(ctx context.Context, userIDs []string) (users []*model.User, err error) { From 56618ddd465c24c95934d346734c94067d1186d2 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 11:21:08 +0800 Subject: [PATCH 20/39] update --- internal/api/router.go | 33 ++++----------------------------- internal/rpc/relation/friend.go | 2 +- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index af9c69aed..91866804d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -12,7 +12,6 @@ import ( pbcrypto "github.com/openimsdk/protocol/crypto" "github.com/openimsdk/protocol/group" "github.com/openimsdk/protocol/msg" - pbredpacket "github.com/openimsdk/protocol/redpacket" "github.com/openimsdk/protocol/relation" "github.com/openimsdk/protocol/rtc" "github.com/openimsdk/protocol/third" @@ -118,10 +117,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co if err != nil { return nil, err } - redpacketConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.RedPacket) - if err != nil { - return nil, err - } + gin.SetMode(gin.ReleaseMode) r := gin.New() if v, ok := binding.Validator.Engine().(*validator.Validate); ok { @@ -249,6 +245,9 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co groupRouterGroup.POST("/get_full_join_group_ids", g.GetFullJoinGroupIDs) groupRouterGroup.POST("/get_group_application_unhandled_count", g.GetGroupApplicationUnhandledCount) groupRouterGroup.POST("/get_common_groups_with_friend", g.GetCommonGroupsWithFriend) + groupRouterGroup.POST("/pin_group_message", g.PinGroupMessage) + groupRouterGroup.POST("/unpin_group_message", g.UnpinGroupMessage) + groupRouterGroup.POST("/get_group_pinned_messages", g.GetGroupPinnedMessages) } // certificate { @@ -369,30 +368,6 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co cryptoGroup.POST("/integrity_report", cr.IntegrityReport) } - // RedPacket - { - rp := NewRedPacketApi(pbredpacket.NewRedPacketClient(redpacketConn)) - redpacketGroup := r.Group("/redpacket") - redpacketGroup.POST("/create_order", rp.CreateOrder) - redpacketGroup.POST("/created_callback", rp.CreatedCallback) - redpacketGroup.POST("/detail", rp.GetDetail) - redpacketGroup.POST("/issue_claim_sign", rp.IssueClaimSign) - redpacketGroup.POST("/claim_result", rp.ClaimResult) - redpacketGroup.POST("/request_refund", rp.RequestRefund) - redpacketGroup.POST("/get_refund", rp.GetRefund) - redpacketGroup.POST("/wallet_bind/challenge", rp.IssueWalletBindChallenge) - redpacketGroup.POST("/wallet_bind/confirm", rp.ConfirmWalletBind) - redpacketGroup.POST("/wallet_bind/detail", rp.GetWalletBinding) - - adminGroup := redpacketGroup.Group("/admin") - adminGroup.POST("/set_signer", rp.AdminSetSigner) - adminGroup.POST("/set_token", rp.AdminSetToken) - adminGroup.POST("/set_expiry", rp.AdminSetExpiry) - adminGroup.POST("/set_allow_all_tokens", rp.AdminSetAllowAllTokens) - adminGroup.POST("/set_native_token_enabled", rp.AdminSetNativeTokenEnabled) - adminGroup.POST("/parse_tx_events", rp.AdminParseTxEvents) - } - { statisticsGroup := r.Group("/statistics") statisticsGroup.POST("/user/register", u.UserRegisterCount) diff --git a/internal/rpc/relation/friend.go b/internal/rpc/relation/friend.go index 28417e61c..770158882 100644 --- a/internal/rpc/relation/friend.go +++ b/internal/rpc/relation/friend.go @@ -697,7 +697,7 @@ func (s *friendServer) AddOnewayFriend(ctx context.Context, req *relation.ApplyT if in1 { return nil, servererrs.ErrRelationshipAlready.WrapMsg("already in friend list") } - if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway); err != nil { + if err := s.db.BecomeOnewayFriend(ctx, req.FromUserID, req.ToUserID, becomeFriendByOneway, req.Remark); err != nil { return nil, err } From 90f326b4f4d57822758502dc6d4b01f3e3cdddd8 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 15:58:00 +0800 Subject: [PATCH 21/39] delete user --- config/openim-push.yml | 4 +- internal/api/delete_user.go | 143 ++++++++++++++++++++++++ internal/api/router.go | 4 + pkg/common/storage/database/mgo/user.go | 8 ++ pkg/common/storage/database/user.go | 3 + 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 internal/api/delete_user.go diff --git a/config/openim-push.yml b/config/openim-push.yml index 1bb84a172..64acb577f 100644 --- a/config/openim-push.yml +++ b/config/openim-push.yml @@ -18,7 +18,7 @@ prometheus: maxConcurrentWorkers: 3 #Use geTui for offline push notifications, or choose fcm or jpush; corresponding configuration settings must be specified. -enable: geTui +enable: fcm geTui: pushUrl: https://restapi.getui.com/v2/$appId masterSecret: @@ -28,7 +28,7 @@ geTui: channelName: fcm: # Prioritize using file paths. If the file path is empty, use URL - filePath: # File path is concatenated with the parameters passed in through - c(`mage` default pass in `config/`) and filePath. + filePath: sokim-firebase-adminsdk.json # File path is concatenated with the parameters passed in through - c(`mage` default pass in `config/`) and filePath. authURL: # Must start with https or http. jpush: appKey: diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go new file mode 100644 index 000000000..f62b9ec4c --- /dev/null +++ b/internal/api/delete_user.go @@ -0,0 +1,143 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/openimsdk/open-im-server/v3/pkg/authverify" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/rpcli" + "github.com/openimsdk/protocol/constant" + "github.com/openimsdk/protocol/group" + "github.com/openimsdk/protocol/relation" + "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/tools/apiresp" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" +) + +// DeleteUserApi handles real account deletion (hard delete). +// It follows the same direct-DB pattern as UserGlobalBlackApi. +type DeleteUserApi struct { + userDB database.User + authClient *rpcli.AuthClient + groupClient group.GroupClient + friendClient relation.FriendClient + imAdminUserIDs []string +} + +func NewDeleteUserApi( + userDB database.User, + authClient *rpcli.AuthClient, + groupClient group.GroupClient, + friendClient relation.FriendClient, + imAdminUserIDs []string, +) *DeleteUserApi { + return &DeleteUserApi{ + userDB: userDB, + authClient: authClient, + groupClient: groupClient, + friendClient: friendClient, + imAdminUserIDs: imAdminUserIDs, + } +} + +type deleteUserReq struct { + UserID string `json:"userID" binding:"required"` +} + +// DeleteUser permanently deletes a user account and cleans up associated data. +// Steps: force-logout → delete friends → quit/kick groups → hard-delete user doc. +// Only IM admins may call this endpoint. +func (d *DeleteUserApi) DeleteUser(c *gin.Context) { + var req deleteUserReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if err := authverify.CheckAdmin(c, d.imAdminUserIDs); err != nil { + apiresp.GinError(c, err) + return + } + + // 1. Verify user exists + users, err := d.userDB.Find(c, []string{req.UserID}) + if err != nil { + apiresp.GinError(c, err) + return + } + if len(users) == 0 { + apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("user not found", "userID", req.UserID)) + return + } + + // 2. Force logout from every platform + for platformID := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + if err := d.authClient.ForceLogout(c, req.UserID, int32(platformID)); err != nil { + log.ZWarn(c, "DeleteUser: ForceLogout failed", err, "userID", req.UserID, "platformID", platformID) + } + } + + // 3. Delete all friendships (both directions: target→friend and friend→target) + friendIDsResp, err := d.friendClient.GetFriendIDs(c, &relation.GetFriendIDsReq{UserID: req.UserID}) + if err != nil { + log.ZWarn(c, "DeleteUser: GetFriendIDs failed", err, "userID", req.UserID) + } else { + for _, friendID := range friendIDsResp.FriendIDs { + // Remove from target user's friend list + if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ + OwnerUserID: req.UserID, + FriendUserID: friendID, + }); err != nil { + log.ZWarn(c, "DeleteUser: DeleteFriend (owner→friend) failed", err, + "ownerUserID", req.UserID, "friendUserID", friendID) + } + // Remove from the friend's friend list + if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ + OwnerUserID: friendID, + FriendUserID: req.UserID, + }); err != nil { + log.ZWarn(c, "DeleteUser: DeleteFriend (friend→owner) failed", err, + "ownerUserID", friendID, "friendUserID", req.UserID) + } + } + } + + // 4. Quit / kick from all joined groups (paginated, page size 100) + pageNumber := int32(1) + const pageSize = int32(100) + for { + groupListResp, err := d.groupClient.GetJoinedGroupList(c, &group.GetJoinedGroupListReq{ + FromUserID: req.UserID, + Pagination: &sdkws.RequestPagination{PageNumber: pageNumber, ShowNumber: pageSize}, + }) + if err != nil { + log.ZWarn(c, "DeleteUser: GetJoinedGroupList failed", err, "userID", req.UserID, "page", pageNumber) + break + } + for _, g := range groupListResp.Groups { + if _, err := d.groupClient.QuitGroup(c, &group.QuitGroupReq{ + GroupID: g.GroupID, + UserID: req.UserID, + }); err != nil { + log.ZWarn(c, "DeleteUser: QuitGroup failed", err, "userID", req.UserID, "groupID", g.GroupID) + } + } + if int32(len(groupListResp.Groups)) < pageSize { + break + } + pageNumber++ + } + + // 5. Hard-delete user document from MongoDB. + // Redis cache will become stale and expire via TTL; the user can no longer + // authenticate because their tokens were already invalidated in step 2. + if err := d.userDB.Delete(c, []string{req.UserID}); err != nil { + apiresp.GinError(c, err) + return + } + + log.ZInfo(c, "DeleteUser: user deleted", "userID", req.UserID) + apiresp.GinSuccess(c, nil) +} diff --git a/internal/api/router.go b/internal/api/router.go index 91866804d..519f3bf31 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -137,6 +137,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn)) bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) + du := NewDeleteUserApi(userDB, rpcli.NewAuthClient(authConn), group.NewGroupClient(groupConn), relation.NewFriendClient(friendConn), config.Share.IMAdminUserID) phoneSN := NewPhoneSNApi(phoneSNDB) userRouterGroup := r.Group("/user") { @@ -178,6 +179,9 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) + + // 真实删除账号(仅管理员) + userRouterGroup.POST("/delete_user", du.DeleteUser) } // friend routing group { diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index d5f64a5ab..9ba0a0514 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -375,6 +375,14 @@ func (u *UserMgo) CountRangeEverydayTotal(ctx context.Context, start time.Time, return res, nil } +func (u *UserMgo) Delete(ctx context.Context, userIDs []string) error { + if len(userIDs) == 0 { + return nil + } + _, err := u.coll.DeleteMany(ctx, bson.M{"user_id": bson.M{"$in": userIDs}}) + return errs.Wrap(err) +} + func (u *UserMgo) SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) { if len(userIDName) == 0 { return nil, nil diff --git a/pkg/common/storage/database/user.go b/pkg/common/storage/database/user.go index 2682bc780..eb5685ee0 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -45,6 +45,9 @@ type User interface { SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) + // Delete permanently removes user documents by userID. + Delete(ctx context.Context, userIDs []string) error + // CRUD user command AddUserCommand(ctx context.Context, userID string, Type int32, UUID string, value string, ex string) error DeleteUserCommand(ctx context.Context, userID string, Type int32, UUID string) error From 21088f21768b4fb68fc16d99a1a86cad91faafae Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 18:51:17 +0800 Subject: [PATCH 22/39] delete account --- internal/api/delete_user.go | 5 +++-- internal/api/router.go | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go index f62b9ec4c..a9a1bb1ca 100644 --- a/internal/api/delete_user.go +++ b/internal/api/delete_user.go @@ -46,14 +46,15 @@ type deleteUserReq struct { // DeleteUser permanently deletes a user account and cleans up associated data. // Steps: force-logout → delete friends → quit/kick groups → hard-delete user doc. -// Only IM admins may call this endpoint. +// Caller must be the same user as userID, or an IM admin (see CheckAccessV3). func (d *DeleteUserApi) DeleteUser(c *gin.Context) { var req deleteUserReq if err := c.ShouldBindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) return } - if err := authverify.CheckAdmin(c, d.imAdminUserIDs); err != nil { + // Only the user themselves (or an IM admin) may delete the account. + if err := authverify.CheckAccessV3(c, req.UserID, d.imAdminUserIDs); err != nil { apiresp.GinError(c, err) return } diff --git a/internal/api/router.go b/internal/api/router.go index 519f3bf31..9430cc7ed 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -180,7 +180,6 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) - // 真实删除账号(仅管理员) userRouterGroup.POST("/delete_user", du.DeleteUser) } // friend routing group From 6c6292a817297f04bfafc53b13e4ab9529026c6e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 21:14:55 +0800 Subject: [PATCH 23/39] delete account --- internal/api/delete_user.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go index a9a1bb1ca..81c87103f 100644 --- a/internal/api/delete_user.go +++ b/internal/api/delete_user.go @@ -95,13 +95,13 @@ func (d *DeleteUserApi) DeleteUser(c *gin.Context) { "ownerUserID", req.UserID, "friendUserID", friendID) } // Remove from the friend's friend list - if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ - OwnerUserID: friendID, - FriendUserID: req.UserID, - }); err != nil { - log.ZWarn(c, "DeleteUser: DeleteFriend (friend→owner) failed", err, - "ownerUserID", friendID, "friendUserID", req.UserID) - } + //if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ + // OwnerUserID: friendID, + // FriendUserID: req.UserID, + //}); err != nil { + // log.ZWarn(c, "DeleteUser: DeleteFriend (friend→owner) failed", err, + // "ownerUserID", friendID, "friendUserID", req.UserID) + //} } } From c6b43f790a5af4f94c4deab90e61483e66ca4177 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 21:55:24 +0800 Subject: [PATCH 24/39] bugfix --- internal/rpc/user/user.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index 84e6f88e2..a7491dc7d 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -602,6 +602,8 @@ func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterR FirstName: user.FirstName, LastName: user.LastName, FullName: fullName, + Phone: user.Phone, + AreaCode: user.AreaCode, }) } if err := s.db.Create(ctx, users); err != nil { From 1d21bc3c62769b7309b368cc853b6952364e4be3 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 23:05:37 +0800 Subject: [PATCH 25/39] =?UTF-8?q?=E6=89=8B=E6=9C=BA=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/user/user.go | 46 ++++++++++----------------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index a7491dc7d..b76cdb365 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -389,17 +389,15 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs "msgReceiveSetting", req.MsgReceiveSetting) return nil, err } - s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + //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) → 任何人均可搜到 +// phone_visibility 仅控制用户资料中手机号字段是否展示,不影响搜索本身: +// 无论目标用户将手机号设置为何种可见性,只要手机号匹配就能找到该用户。 +// 返回的 UserInfo 中 phone 字段仍按 applyPhoneVisibility 规则处理。 // // 返回空 userInfo 并不代表错误,调用方应以 nil userInfo 判断"未找到"。 func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPhoneReq) (*pbuser.GetUserByPhoneResp, error) { @@ -413,7 +411,7 @@ func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPh 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, @@ -421,34 +419,14 @@ func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPh 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) + // 搜索者已知手机号(主动输入),仍对返回的资料字段应用可见性规则 + viewerID := mcontext.GetOpUserID(ctx) + if err := s.applyPhoneVisibility(ctx, viewerID, []*sdkws.UserInfo{pbUser}, []*tablerelation.User{dbUser}); err != nil { + log.ZError(ctx, "GetUserByPhone: applyPhoneVisibility failed", err, + "opUserID", viewerID, "targetUserID", dbUser.UserID) + return nil, err + } return &pbuser.GetUserByPhoneResp{UserInfo: pbUser}, nil } From 6c1fe8fcca7bc2e61f5ca3b16e603c9325a6092b Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 23:09:49 +0800 Subject: [PATCH 26/39] =?UTF-8?q?=E6=8E=A5=E5=8F=97=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/user/user.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index b76cdb365..1d4099ec5 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -399,6 +399,7 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs // 无论目标用户将手机号设置为何种可见性,只要手机号匹配就能找到该用户。 // 返回的 UserInfo 中 phone 字段仍按 applyPhoneVisibility 规则处理。 // +// 当目标用户 MsgReceiveSetting=2(不接受任何人消息)时,对非本人搜索者不可见。 // 返回空 userInfo 并不代表错误,调用方应以 nil userInfo 判断"未找到"。 func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPhoneReq) (*pbuser.GetUserByPhoneResp, error) { if req.Phone == "" { @@ -419,9 +420,14 @@ func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPh return nil, err } + viewerID := mcontext.GetOpUserID(ctx) + // MsgReceiveSetting=2 表示不接受任何人消息,对非本人搜索者隐藏该用户 + if dbUser.MsgReceiveSetting == tablerelation.MsgReceiveSettingNobody && viewerID != dbUser.UserID { + return &pbuser.GetUserByPhoneResp{}, nil + } + pbUser := convert.UserDB2Pb(dbUser) // 搜索者已知手机号(主动输入),仍对返回的资料字段应用可见性规则 - viewerID := mcontext.GetOpUserID(ctx) if err := s.applyPhoneVisibility(ctx, viewerID, []*sdkws.UserInfo{pbUser}, []*tablerelation.User{dbUser}); err != nil { log.ZError(ctx, "GetUserByPhone: applyPhoneVisibility failed", err, "opUserID", viewerID, "targetUserID", dbUser.UserID) @@ -432,6 +438,7 @@ func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPh // GetUsersByNickname 按昵称精确匹配查询普通用户(app_manger_level 与分页拉取用户一致)。 // 全局黑名单用户会被过滤;手机号字段按 phone_visibility 与 getDesignateUsers 相同规则处理。 +// MsgReceiveSetting=2 的用户对非本人搜索者不可见。 func (s *userServer) GetUsersByNickname(ctx context.Context, req *pbuser.GetUsersByNicknameReq) (*pbuser.GetUsersByNicknameResp, error) { nickname := strings.TrimSpace(req.Nickname) if nickname == "" { @@ -475,8 +482,23 @@ func (s *userServer) GetUsersByNickname(ctx context.Context, req *pbuser.GetUser return &pbuser.GetUsersByNicknameResp{}, nil } - pbUsers := convert.UsersDB2Pb(users) + // 过滤掉 MsgReceiveSetting=2(不接受任何人消息)的用户,本人除外 viewerID := mcontext.GetOpUserID(ctx) + { + visible := make([]*tablerelation.User, 0, len(users)) + for _, u := range users { + if u.MsgReceiveSetting == tablerelation.MsgReceiveSettingNobody && viewerID != u.UserID { + continue + } + visible = append(visible, u) + } + users = visible + } + if len(users) == 0 { + return &pbuser.GetUsersByNicknameResp{}, nil + } + + pbUsers := convert.UsersDB2Pb(users) if err := s.applyPhoneVisibility(ctx, viewerID, pbUsers, users); err != nil { log.ZError(ctx, "GetUsersByNickname: applyPhoneVisibility failed", err, "opUserID", viewerID, "count", len(users)) From f268d91db6597d99a291d6ad2b17122fbbb24b14 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 23:20:44 +0800 Subject: [PATCH 27/39] =?UTF-8?q?=E7=BE=A4=E9=82=80=E8=AF=B7=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 1 + internal/api/user.go | 4 ++++ internal/rpc/group/group.go | 31 +++++++++++++++++++++++++++++++ internal/rpc/user/user.go | 31 +++++++++++++++++++++++++++++++ pkg/common/convert/user.go | 10 +++++++--- pkg/common/storage/model/user.go | 9 +++++++++ protocol | 2 +- 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 9430cc7ed..1003ff62b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -170,6 +170,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_phone_visibility", u.SetPhoneVisibility) userRouterGroup.POST("/set_call_accept_setting", u.SetCallAcceptSetting) userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) + userRouterGroup.POST("/set_group_invite_setting", u.SetGroupInviteSetting) // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) diff --git a/internal/api/user.go b/internal/api/user.go index 356251406..d5c8b4203 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -351,6 +351,10 @@ func (u *UserApi) SetMsgReceiveSetting(c *gin.Context) { a2r.Call(c, user.UserClient.SetMsgReceiveSetting, u.Client) } +func (u *UserApi) SetGroupInviteSetting(c *gin.Context) { + a2r.Call(c, user.UserClient.SetGroupInviteSetting, u.Client) +} + func (u *UserApi) GetUserByPhone(c *gin.Context) { a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) } diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go index d9d8fd427..fbe918c5e 100644 --- a/internal/rpc/group/group.go +++ b/internal/rpc/group/group.go @@ -67,6 +67,7 @@ type groupServer struct { msgClient *rpcli.MsgClient conversationClient *rpcli.ConversationClient cryptoClient *rpcli.CryptoClient + relationClient *rpcli.RelationClient } type Config struct { @@ -122,6 +123,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + friendConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Friend) + if err != nil { + return err + } //cryptoConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Crypto) //if err != nil { // return err @@ -132,6 +137,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg userClient: rpcli.NewUserClient(userConn), msgClient: rpcli.NewMsgClient(msgConn), conversationClient: rpcli.NewConversationClient(conversationConn), + relationClient: rpcli.NewRelationClient(friendConn), //cryptoClient: rpcli.NewCryptoClient(cryptoConn), } gs.db = controller.NewGroupDatabase(rdb, &config.LocalCacheConfig, groupDB, groupMemberDB, groupRequestDB, groupPinnedMsgDB, mgocli.GetTx(), grouphash.NewGroupHashFromGroupServer(&gs)) @@ -457,6 +463,31 @@ func (s *groupServer) InviteUserToGroup(ctx context.Context, req *pbgroup.Invite return nil, errs.ErrRecordNotFound.WrapMsg("user not found") } + // 检查受邀用户的群邀请权限设置(管理员操作跳过) + if !authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) { + inviterID := mcontext.GetOpUserID(ctx) + for _, userID := range req.InvitedUserIDs { + info, ok := userMap[userID] + if !ok { + continue + } + switch info.GroupInviteSetting { + case 2: // GroupInviteSettingNobody:所有人不可邀请 + return nil, errs.ErrNoPermission.WrapMsg("user has disabled group invitations", "userID", userID) + case 1: // GroupInviteSettingFriends:仅好友可邀请 + isFriend, err := s.relationClient.IsFriend(ctx, inviterID, userID) + if err != nil { + log.ZError(ctx, "InviteUserToGroup: IsFriend check failed", err, + "inviterID", inviterID, "invitedUserID", userID) + return nil, err + } + if !isFriend { + return nil, errs.ErrNoPermission.WrapMsg("user only allows friends to invite them to groups", "userID", userID) + } + } + } + } + var groupMember *model.GroupMember var opUserID string if !authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) { diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index 1d4099ec5..d5d3a84a1 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -393,6 +393,37 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs return &pbuser.SetMsgReceiveSettingResp{}, nil } +// SetGroupInviteSetting 设置群邀请权限(0=所有人可邀请,1=仅好友可邀请,2=所有人不可邀请)。 +// 只允许本人或管理员操作。 +func (s *userServer) SetGroupInviteSetting(ctx context.Context, req *pbuser.SetGroupInviteSettingReq) (*pbuser.SetGroupInviteSettingResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.GroupInviteSetting < 0 || req.GroupInviteSetting > 2 { + return nil, errs.ErrArgs.WrapMsg("groupInviteSetting must be 0, 1 or 2") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetGroupInviteSetting: 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, "SetGroupInviteSetting: 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{ + "group_invite_setting": req.GroupInviteSetting, + }); err != nil { + log.ZError(ctx, "SetGroupInviteSetting: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "groupInviteSetting", req.GroupInviteSetting) + return nil, err + } + s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + return &pbuser.SetGroupInviteSettingResp{}, nil +} + // GetUserByPhone 根据精确手机号查询用户。 // // phone_visibility 仅控制用户资料中手机号字段是否展示,不影响搜索本身: diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index 2c4063cbf..1a45d6ff8 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -48,9 +48,10 @@ func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { Phone: user.Phone, AreaCode: user.AreaCode, PhoneVisibility: user.PhoneVisibility, - CallAcceptSetting: user.CallAcceptSetting, - MsgReceiveSetting: user.MsgReceiveSetting, - CallRingtoneURL: user.CallRingtoneURL, + CallAcceptSetting: user.CallAcceptSetting, + MsgReceiveSetting: user.MsgReceiveSetting, + GroupInviteSetting: user.GroupInviteSetting, + CallRingtoneURL: user.CallRingtoneURL, } } @@ -156,6 +157,9 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.MsgReceiveSetting != nil { val["msg_receive_setting"] = user.MsgReceiveSetting.Value } + if user.GroupInviteSetting != nil { + val["group_invite_setting"] = user.GroupInviteSetting.Value + } if user.CallRingtoneURL != nil { val["call_ringtone_url"] = user.CallRingtoneURL.Value } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 4290f1828..dea729a49 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -42,6 +42,14 @@ const ( MsgReceiveSettingNobody int32 = 2 ) +// GroupInviteSetting 群邀请权限枚举。 +// 0=所有人可邀请, 1=仅好友可邀请, 2=所有人不可邀请 +const ( + GroupInviteSettingPublic int32 = 0 + GroupInviteSettingFriends int32 = 1 + GroupInviteSettingNobody int32 = 2 +) + // UserStatus 用户账号状态枚举。 // 0=正常;1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线,不能收发消息) const ( @@ -66,6 +74,7 @@ type User struct { PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` MsgReceiveSetting int32 `bson:"msg_receive_setting"` + GroupInviteSetting int32 `bson:"group_invite_setting"` // CallRingtoneURL 用户自定义来电铃声 URL;对方来电时播放此铃声 CallRingtoneURL string `bson:"call_ringtone_url"` // Status 账号状态:0=正常,1=冻结,2=黑名单 diff --git a/protocol b/protocol index 3b211f91d..de7a73dfc 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 3b211f91d0e6b98797f91ba34fa64a7b47df5645 +Subproject commit de7a73dfcf97ac618dacdad970da0f3cbd7f0ac8 From f85a6a0bd1b5e28cc932ea25acce7855db4b98f2 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 23:32:39 +0800 Subject: [PATCH 28/39] =?UTF-8?q?=E9=98=85=E5=90=8E=E5=8D=B3=E7=84=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 2 ++ internal/api/user.go | 4 ++++ internal/rpc/user/user.go | 31 +++++++++++++++++++++++++++++++ pkg/common/convert/user.go | 5 +++++ pkg/common/storage/model/user.go | 2 ++ protocol | 2 +- 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/api/router.go b/internal/api/router.go index 1003ff62b..e9fde37ee 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -171,6 +171,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_call_accept_setting", u.SetCallAcceptSetting) userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) userRouterGroup.POST("/set_group_invite_setting", u.SetGroupInviteSetting) + // 设置用户全局阅后即焚时长(秒),0 表示关闭 + userRouterGroup.POST("/set_user_msg_burn_duration", u.SetUserMsgBurnDuration) // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) diff --git a/internal/api/user.go b/internal/api/user.go index d5c8b4203..aa82589a7 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -355,6 +355,10 @@ func (u *UserApi) SetGroupInviteSetting(c *gin.Context) { a2r.Call(c, user.UserClient.SetGroupInviteSetting, u.Client) } +func (u *UserApi) SetUserMsgBurnDuration(c *gin.Context) { + a2r.Call(c, user.UserClient.SetUserMsgBurnDuration, u.Client) +} + func (u *UserApi) GetUserByPhone(c *gin.Context) { a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) } diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index d5d3a84a1..1cd47121d 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -424,6 +424,37 @@ func (s *userServer) SetGroupInviteSetting(ctx context.Context, req *pbuser.SetG return &pbuser.SetGroupInviteSettingResp{}, nil } +// SetUserMsgBurnDuration 设置用户全局消息阅后即焚时长(秒);0 表示关闭,要求 >=0。 +// 只允许本人或管理员操作。 +func (s *userServer) SetUserMsgBurnDuration(ctx context.Context, req *pbuser.SetUserMsgBurnDurationReq) (*pbuser.SetUserMsgBurnDurationResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.MsgBurnDuration < 0 { + return nil, errs.ErrArgs.WrapMsg("msgBurnDuration must be >= 0 (seconds)") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetUserMsgBurnDuration: 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, "SetUserMsgBurnDuration: 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_burn_duration": req.MsgBurnDuration, + }); err != nil { + log.ZError(ctx, "SetUserMsgBurnDuration: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "msgBurnDuration", req.MsgBurnDuration) + return nil, err + } + s.friendNotificationSender.UserInfoUpdatedNotification(ctx, req.UserID) + return &pbuser.SetUserMsgBurnDurationResp{}, nil +} + // GetUserByPhone 根据精确手机号查询用户。 // // phone_visibility 仅控制用户资料中手机号字段是否展示,不影响搜索本身: diff --git a/pkg/common/convert/user.go b/pkg/common/convert/user.go index 1a45d6ff8..8098b416d 100644 --- a/pkg/common/convert/user.go +++ b/pkg/common/convert/user.go @@ -52,6 +52,7 @@ func UserDB2Pb(user *relationtb.User) *sdkws.UserInfo { MsgReceiveSetting: user.MsgReceiveSetting, GroupInviteSetting: user.GroupInviteSetting, CallRingtoneURL: user.CallRingtoneURL, + MsgBurnDuration: user.MsgBurnDuration, } } @@ -92,6 +93,7 @@ func UserPb2DBMap(user *sdkws.UserInfo) map[string]any { "app_manager_level": user.AppMangerLevel, "global_recv_msg_opt": user.GlobalRecvMsgOpt, "call_ringtone_url": user.CallRingtoneURL, + "msg_burn_duration": user.MsgBurnDuration, } for key, value := range fields { if v, ok := value.(string); ok && v != "" { @@ -163,5 +165,8 @@ func UserPb2DBMapEx(user *sdkws.UserInfoWithEx) map[string]any { if user.CallRingtoneURL != nil { val["call_ringtone_url"] = user.CallRingtoneURL.Value } + if user.MsgBurnDuration != nil { + val["msg_burn_duration"] = user.MsgBurnDuration.Value + } return val } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index dea729a49..e880e1255 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -79,6 +79,8 @@ type User struct { CallRingtoneURL string `bson:"call_ringtone_url"` // Status 账号状态:0=正常,1=冻结,2=黑名单 Status int32 `bson:"status"` + // MsgBurnDuration 用户全局消息阅后即焚时长(秒);0 表示关闭 + MsgBurnDuration int32 `bson:"msg_burn_duration"` } func (u *User) GetNickname() string { diff --git a/protocol b/protocol index de7a73dfc..49fe436a9 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit de7a73dfcf97ac618dacdad970da0f3cbd7f0ac8 +Subproject commit 49fe436a9a00d4b5a2e86f728cdd34156bed1164 From b8c4be2aaaba08d770ef2208ea8c93faad2390e3 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 8 May 2026 22:28:13 +0800 Subject: [PATCH 29/39] =?UTF-8?q?=E9=98=85=E5=90=8E=E5=8D=B3=E7=84=9A?= =?UTF-8?q?=EF=BC=8C=E6=8E=A5=E5=8F=97=E8=80=85=E9=9A=90=E8=97=8F=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/conversation/conversation.go | 61 +++++++- internal/rpc/msg/as_read.go | 51 +++++++ internal/rpc/msg/server.go | 6 + internal/tools/burn_msg.go | 55 +++++++ internal/tools/cron_task.go | 8 ++ .../storage/database/mgo/msg_burn_deadline.go | 134 ++++++++++++++++++ .../storage/database/msg_burn_deadline.go | 48 +++++++ pkg/common/storage/database/name.go | 1 + pkg/common/storage/model/msg_burn_deadline.go | 31 ++++ 9 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 internal/tools/burn_msg.go create mode 100644 pkg/common/storage/database/mgo/msg_burn_deadline.go create mode 100644 pkg/common/storage/database/msg_burn_deadline.go create mode 100644 pkg/common/storage/model/msg_burn_deadline.go diff --git a/internal/rpc/conversation/conversation.go b/internal/rpc/conversation/conversation.go index d5345f6ec..b65f77812 100644 --- a/internal/rpc/conversation/conversation.go +++ b/internal/rpc/conversation/conversation.go @@ -23,6 +23,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/storage/model" dbModel "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" @@ -47,6 +48,7 @@ import ( type conversationServer struct { pbconversation.UnimplementedConversationServer conversationDatabase controller.ConversationDatabase + msgBurnDeadlineDB database.MsgBurnDeadline conversationNotificationSender *ConversationNotificationSender config *Config @@ -79,6 +81,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + msgBurnDeadlineDB, err := mgo.NewMsgBurnDeadlineMongo(mgocli.GetDB()) + if err != nil { + return err + } userConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.User) if err != nil { return err @@ -97,9 +103,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg conversationNotificationSender: NewConversationNotificationSender(&config.NotificationConfig, msgClient), conversationDatabase: controller.NewConversationDatabase(conversationDB, redis.NewConversationRedis(rdb, &config.LocalCacheConfig, redis.GetRocksCacheOptions(), conversationDB), mgocli.GetTx()), - userClient: rpcli.NewUserClient(userConn), - groupClient: rpcli.NewGroupClient(groupConn), - msgClient: msgClient, + msgBurnDeadlineDB: msgBurnDeadlineDB, + userClient: rpcli.NewUserClient(userConn), + groupClient: rpcli.NewGroupClient(groupConn), + msgClient: msgClient, }) return nil } @@ -823,3 +830,51 @@ func (c *conversationServer) setConversationMinSeqAndLatestMsgDestructTime(ctx c c.conversationNotificationSender.ConversationChangeNotification(ctx, ownerUserID, []string{conversationID}) return nil } + +// ClearBurnExpiredMsgs 处理「阅后即焚」过期消息: +// 1. 从 msg_burn_deadline 中拉取一批过期分组(按 user/conversation 聚合,含每组最大 seq)。 +// 2. 对每个分组把用户在该会话上的 min_seq 推进到 max(过期 seq) + 1。 +// 3. 同步更新 conversation 文档的 min_seq 字段并下发会话变更通知。 +// 4. 删除已处理的 deadline 记录。 +// +// 单次最多处理 req.Limit 个分组;若返回的 count == limit,cron 可继续触发。 +func (c *conversationServer) ClearBurnExpiredMsgs(ctx context.Context, req *pbconversation.ClearBurnExpiredMsgsReq) (*pbconversation.ClearBurnExpiredMsgsResp, error) { + if c.msgBurnDeadlineDB == nil { + return &pbconversation.ClearBurnExpiredMsgsResp{Count: 0}, nil + } + limit := int(req.Limit) + if limit <= 0 { + limit = 100 + } + groups, err := c.msgBurnDeadlineDB.FindExpiredGroups(ctx, req.Timestamp, limit) + if err != nil { + return nil, err + } + var processed int32 + for _, g := range groups { + if g.UserID == "" || g.ConversationID == "" || g.MaxSeq <= 0 { + continue + } + newMinSeq := g.MaxSeq + 1 + if err := c.msgClient.SetUserConversationMin(ctx, g.ConversationID, []string{g.UserID}, newMinSeq); err != nil { + log.ZError(ctx, "ClearBurnExpiredMsgs SetUserConversationMin failed", err, + "userID", g.UserID, "conversationID", g.ConversationID, "minSeq", newMinSeq) + continue + } + if err := c.conversationDatabase.UpdateUsersConversationField(ctx, []string{g.UserID}, g.ConversationID, + map[string]any{"min_seq": newMinSeq}); err != nil { + log.ZError(ctx, "ClearBurnExpiredMsgs UpdateUsersConversationField failed", err, + "userID", g.UserID, "conversationID", g.ConversationID, "minSeq", newMinSeq) + continue + } + c.conversationNotificationSender.ConversationChangeNotification(ctx, g.UserID, []string{g.ConversationID}) + if err := c.msgBurnDeadlineDB.DeleteByUserConversationSeqs(ctx, g.UserID, g.ConversationID, g.Seqs); err != nil { + log.ZError(ctx, "ClearBurnExpiredMsgs DeleteByUserConversationSeqs failed", err, + "userID", g.UserID, "conversationID", g.ConversationID, "seqs", g.Seqs) + } + log.ZDebug(ctx, "ClearBurnExpiredMsgs advanced min_seq", "userID", g.UserID, + "conversationID", g.ConversationID, "minSeq", newMinSeq, "seqs", g.Seqs) + processed++ + } + return &pbconversation.ClearBurnExpiredMsgsResp{Count: processed}, nil +} diff --git a/internal/rpc/msg/as_read.go b/internal/rpc/msg/as_read.go index de1879438..ff3014d95 100644 --- a/internal/rpc/msg/as_read.go +++ b/internal/rpc/msg/as_read.go @@ -17,9 +17,12 @@ package msg import ( "context" "errors" + "time" cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" "github.com/openimsdk/protocol/constant" + "github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/sdkws" "github.com/openimsdk/tools/errs" @@ -126,6 +129,7 @@ func (m *msgServer) MarkMsgsAsRead(ctx context.Context, req *msg.MarkMsgsAsReadR ContentType: conversation.ConversationType, } m.webhookAfterSingleMsgRead(ctx, &m.config.WebhooksConfig.AfterSingleMsgRead, reqCallback) + m.recordBurnDeadlines(ctx, conversation, req.UserID, req.Seqs) m.sendMarkAsReadNotification(ctx, req.ConversationID, conversation.ConversationType, req.UserID, m.conversationAndGetRecvID(conversation, req.UserID), req.Seqs, hasReadSeq) return &msg.MarkMsgsAsReadResp{}, nil @@ -166,6 +170,7 @@ func (m *msgServer) MarkConversationAsRead(ctx context.Context, req *msg.MarkCon } hasReadSeq = req.HasReadSeq } + m.recordBurnDeadlines(ctx, conversation, req.UserID, seqs) m.sendMarkAsReadNotification(ctx, req.ConversationID, conversation.ConversationType, req.UserID, m.conversationAndGetRecvID(conversation, req.UserID), seqs, hasReadSeq) } else if conversation.ConversationType == constant.ReadGroupChatType || @@ -201,6 +206,52 @@ func (m *msgServer) MarkConversationAsRead(ctx context.Context, req *msg.MarkCon return &msg.MarkConversationAsReadResp{}, nil } +// recordBurnDeadlines 在「单聊」场景下,根据对端的 MsgBurnDuration 为本次已读的每条消息 +// 记录一份「阅后即焚」截止时间到 mongo。后续由 cron 推进 min_seq 让消息从该用户视图消失。 +// +// 设计要点: +// 1. 仅单聊:群聊有自己的不同语义(多接收方 + 仅 watermark 已读),暂不在此实现。 +// 2. 仅当对端 MsgBurnDuration > 0 时才记录;为 0 表示对端未开启该功能。 +// 3. 同一 (UserID, ConversationID, Seq) 已存在则不覆盖:保证以「首次阅读时刻」为基准, +// 避免多端重复 MarkAsRead 导致 deadline 被往后推。 +// 4. 失败仅记录日志,不影响主流程的已读语义。 +func (m *msgServer) recordBurnDeadlines(ctx context.Context, conv *conversation.Conversation, readerUserID string, seqs []int64) { + if len(seqs) == 0 { + return + } + if conv.ConversationType != constant.SingleChatType { + return + } + peerID := m.conversationAndGetRecvID(conv, readerUserID) + if peerID == "" || peerID == readerUserID { + return + } + peerInfo, err := m.UserLocalCache.GetUserInfo(ctx, peerID) + if err != nil { + log.ZWarn(ctx, "recordBurnDeadlines GetUserInfo failed", err, "peerID", peerID) + return + } + if peerInfo == nil || peerInfo.MsgBurnDuration <= 0 { + return + } + now := time.Now().UnixMilli() + deadline := now + int64(peerInfo.MsgBurnDuration)*1000 + items := make([]*model.MsgBurnDeadline, 0, len(seqs)) + for _, seq := range seqs { + items = append(items, &model.MsgBurnDeadline{ + UserID: readerUserID, + ConversationID: conv.ConversationID, + Seq: seq, + DeadlineMs: deadline, + CreateTime: now, + }) + } + if err := m.msgBurnDeadlineDB.UpsertIfAbsent(ctx, items); err != nil { + log.ZError(ctx, "recordBurnDeadlines UpsertIfAbsent failed", err, + "userID", readerUserID, "conversationID", conv.ConversationID, "seqs", seqs) + } +} + func (m *msgServer) sendMarkAsReadNotification(ctx context.Context, conversationID string, sessionType int32, sendID, recvID string, seqs []int64, hasReadSeq int64) { tips := &sdkws.MarkAsReadTips{ MarkAsReadUserID: sendID, diff --git a/internal/rpc/msg/server.go b/internal/rpc/msg/server.go index de00217ea..3408a32b6 100644 --- a/internal/rpc/msg/server.go +++ b/internal/rpc/msg/server.go @@ -72,6 +72,7 @@ type msgServer struct { conversationClient *rpcli.ConversationClient spamReportDB database.SpamReport globalBlackDB controller.UserGlobalBlackDatabase + msgBurnDeadlineDB database.MsgBurnDeadline } func (m *msgServer) addInterceptorHandler(interceptorFunc ...MessageInterceptorFunc) { @@ -132,6 +133,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + msgBurnDeadlineDB, err := mgo.NewMsgBurnDeadlineMongo(mgocli.GetDB()) + if err != nil { + return err + } s := &msgServer{ MsgDatabase: msgDatabase, RegisterCenter: client, @@ -144,6 +149,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg conversationClient: conversationClient, spamReportDB: spamReportDB, globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), + msgBurnDeadlineDB: msgBurnDeadlineDB, } s.notificationSender = notification.NewNotificationSender(&config.NotificationConfig, notification.WithLocalSendMsg(s.SendMsg)) diff --git a/internal/tools/burn_msg.go b/internal/tools/burn_msg.go new file mode 100644 index 000000000..597acccda --- /dev/null +++ b/internal/tools/burn_msg.go @@ -0,0 +1,55 @@ +// 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 tools + +import ( + "fmt" + "os" + "time" + + pbconversation "github.com/openimsdk/protocol/conversation" + "github.com/openimsdk/tools/log" + "github.com/openimsdk/tools/mcontext" +) + +// clearBurnExpiredMsgs 阅后即焚 cron 入口:循环调用 conversation 服务的 +// ClearBurnExpiredMsgs,每次至多处理 burnLimit 个 (user, conversation) 分组, +// 直至本轮没有新的过期分组或达到防御性的最大循环次数。 +func (c *cronServer) clearBurnExpiredMsgs() { + now := time.Now() + operationID := fmt.Sprintf("cron_burn_msg_%d_%d", os.Getpid(), now.UnixMilli()) + ctx := mcontext.SetOperationID(c.ctx, operationID) + log.ZDebug(ctx, "clear burn expired msgs cron start") + const ( + maxLoop = 10000 + burnLimit = 100 + ) + var count int + for i := 1; i <= maxLoop; i++ { + resp, err := c.conversationClient.ClearBurnExpiredMsgs(ctx, &pbconversation.ClearBurnExpiredMsgsReq{ + Timestamp: now.UnixMilli(), + Limit: burnLimit, + }) + if err != nil { + log.ZError(ctx, "ClearBurnExpiredMsgs failed.", err) + return + } + count += int(resp.Count) + if resp.Count < burnLimit { + break + } + } + log.ZDebug(ctx, "clear burn expired msgs cron completed", "cost", time.Since(now), "count", count) +} diff --git a/internal/tools/cron_task.go b/internal/tools/cron_task.go index 689244dd1..1e28ffafc 100644 --- a/internal/tools/cron_task.go +++ b/internal/tools/cron_task.go @@ -84,6 +84,9 @@ func Start(ctx context.Context, config *CronTaskConfig) error { if err := srv.registerClearUserMsg(); err != nil { return err } + if err := srv.registerClearBurnExpiredMsgs(); err != nil { + return err + } log.ZDebug(ctx, "start cron task", "CronExecuteTime", config.CronTask.CronExecuteTime) srv.cron.Start() <-ctx.Done() @@ -121,3 +124,8 @@ func (c *cronServer) registerClearUserMsg() error { _, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, c.clearUserMsg) return errs.WrapMsg(err, "failed to register clear user msg cron task") } + +func (c *cronServer) registerClearBurnExpiredMsgs() error { + _, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, c.clearBurnExpiredMsgs) + return errs.WrapMsg(err, "failed to register clear burn expired msgs cron task") +} diff --git a/pkg/common/storage/database/mgo/msg_burn_deadline.go b/pkg/common/storage/database/mgo/msg_burn_deadline.go new file mode 100644 index 000000000..cc9b6832f --- /dev/null +++ b/pkg/common/storage/database/mgo/msg_burn_deadline.go @@ -0,0 +1,134 @@ +// 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" + + "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/errs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewMsgBurnDeadlineMongo(db *mongo.Database) (database.MsgBurnDeadline, error) { + coll := db.Collection(database.MsgBurnDeadlineName) + _, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "conversation_id", Value: 1}, + {Key: "seq", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "deadline_ms", Value: 1}}, + }, + }) + if err != nil { + return nil, errs.Wrap(err) + } + return &msgBurnDeadlineMgo{coll: coll}, nil +} + +type msgBurnDeadlineMgo struct { + coll *mongo.Collection +} + +func (m *msgBurnDeadlineMgo) UpsertIfAbsent(ctx context.Context, items []*model.MsgBurnDeadline) error { + if len(items) == 0 { + return nil + } + models := make([]mongo.WriteModel, 0, len(items)) + for _, item := range items { + filter := bson.M{ + "user_id": item.UserID, + "conversation_id": item.ConversationID, + "seq": item.Seq, + } + setOnInsert := bson.M{ + "user_id": item.UserID, + "conversation_id": item.ConversationID, + "seq": item.Seq, + "deadline_ms": item.DeadlineMs, + "create_time": item.CreateTime, + } + models = append(models, + mongo.NewUpdateOneModel(). + SetFilter(filter). + SetUpdate(bson.M{"$setOnInsert": setOnInsert}). + SetUpsert(true), + ) + } + _, err := m.coll.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false)) + return errs.Wrap(err) +} + +func (m *msgBurnDeadlineMgo) FindExpiredGroups(ctx context.Context, nowMs int64, limit int) ([]*database.ExpiredBurnGroup, error) { + if limit <= 0 { + return nil, nil + } + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: bson.M{"deadline_ms": bson.M{"$lte": nowMs}}}}, + bson.D{{Key: "$group", Value: bson.M{ + "_id": bson.M{ + "user_id": "$user_id", + "conversation_id": "$conversation_id", + }, + "max_seq": bson.M{"$max": "$seq"}, + "seqs": bson.M{"$push": "$seq"}, + }}}, + bson.D{{Key: "$limit", Value: int64(limit)}}, + } + type aggRow struct { + ID struct { + UserID string `bson:"user_id"` + ConversationID string `bson:"conversation_id"` + } `bson:"_id"` + MaxSeq int64 `bson:"max_seq"` + Seqs []int64 `bson:"seqs"` + } + rows, err := mongoutil.Aggregate[*aggRow](ctx, m.coll, pipeline) + if err != nil { + return nil, err + } + res := make([]*database.ExpiredBurnGroup, 0, len(rows)) + for _, r := range rows { + res = append(res, &database.ExpiredBurnGroup{ + UserID: r.ID.UserID, + ConversationID: r.ID.ConversationID, + MaxSeq: r.MaxSeq, + Seqs: r.Seqs, + }) + } + return res, nil +} + +func (m *msgBurnDeadlineMgo) DeleteByUserConversationSeqs(ctx context.Context, userID, conversationID string, seqs []int64) error { + if len(seqs) == 0 { + return nil + } + filter := bson.M{ + "user_id": userID, + "conversation_id": conversationID, + "seq": bson.M{"$in": seqs}, + } + _, err := m.coll.DeleteMany(ctx, filter) + return errs.Wrap(err) +} diff --git a/pkg/common/storage/database/msg_burn_deadline.go b/pkg/common/storage/database/msg_burn_deadline.go new file mode 100644 index 000000000..f5d7676ea --- /dev/null +++ b/pkg/common/storage/database/msg_burn_deadline.go @@ -0,0 +1,48 @@ +// 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" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" +) + +// ExpiredBurnGroup 表示某个 (UserID, ConversationID) 上需要被推进 min_seq 的 seq 集合。 +type ExpiredBurnGroup struct { + UserID string + ConversationID string + // MaxSeq 当前批次中最大的过期 seq;推进 min_seq 时使用 MaxSeq + 1。 + MaxSeq int64 + // Seqs 当前批次实际涉及的所有过期 seq,便于精确删除已处理的 deadline 记录。 + Seqs []int64 +} + +// MsgBurnDeadline 持久化每条消息对每个用户的「阅后即焚截止时间」。 +// 写入位置:msg 服务 MarkMsgsAsRead / MarkConversationAsRead(单聊)。 +// 消费位置:conversation 服务 ClearBurnExpiredMsgs cron 入口。 +type MsgBurnDeadline interface { + // UpsertIfAbsent 仅在 (UserID, ConversationID, Seq) 不存在时插入; + // 已存在则不覆盖,保证「首次阅读时刻」决定 deadline。 + UpsertIfAbsent(ctx context.Context, items []*model.MsgBurnDeadline) error + + // FindExpiredGroups 查询 deadline_ms <= nowMs 的记录,按 (UserID, ConversationID) + // 聚合并返回每组的最大 seq 与所涉及的 seq 列表。limit 限制返回的 group 数量。 + FindExpiredGroups(ctx context.Context, nowMs int64, limit int) ([]*ExpiredBurnGroup, error) + + // DeleteByUserConversationSeqs 删除某 (UserID, ConversationID) 下指定 seq 列表的 deadline 记录。 + // 一般在成功推进 min_seq 后调用。 + DeleteByUserConversationSeqs(ctx context.Context, userID, conversationID string, seqs []int64) error +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 0fd9b3b2e..08f8aa6c3 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -23,4 +23,5 @@ const ( SignalInvitationName = "signal_invitation" SignalRecordName = "signal_record" SpamReportName = "spam_report" + MsgBurnDeadlineName = "msg_burn_deadline" ) diff --git a/pkg/common/storage/model/msg_burn_deadline.go b/pkg/common/storage/model/msg_burn_deadline.go new file mode 100644 index 000000000..ef1055662 --- /dev/null +++ b/pkg/common/storage/model/msg_burn_deadline.go @@ -0,0 +1,31 @@ +// 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 + +// MsgBurnDeadline 单条消息的「阅后即焚截止时间」记录。 +// 在 MarkMsgsAsRead / MarkConversationAsRead(单聊场景)时按 +// (UserID, ConversationID, Seq) 维度写入。读取时间锁定后续不再覆盖。 +// +// 当当前时间 > DeadlineMs 时,cron 会把该用户在该会话上的 min_seq +// 推进到 max(已过期 seq) + 1,从而让这些消息从对该用户的拉取结果中消失。 +type MsgBurnDeadline struct { + UserID string `bson:"user_id"` + ConversationID string `bson:"conversation_id"` + Seq int64 `bson:"seq"` + // DeadlineMs 截止时间戳(毫秒);超过即可被 cron 收走推进 min_seq。 + DeadlineMs int64 `bson:"deadline_ms"` + // CreateTime 写入时刻(毫秒);用于排查/审计。 + CreateTime int64 `bson:"create_time"` +} From 153b438f2c1dd29f28af2911653ad5b041d419bb Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Fri, 8 May 2026 22:32:57 +0800 Subject: [PATCH 30/39] =?UTF-8?q?=E9=98=85=E5=90=8E=E5=8D=B3=E7=84=9A?= =?UTF-8?q?=EF=BC=8C=E6=8E=A5=E5=8F=97=E8=80=85=E9=9A=90=E8=97=8F=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/msg/as_read.go | 44 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/rpc/msg/as_read.go b/internal/rpc/msg/as_read.go index ff3014d95..095b1500d 100644 --- a/internal/rpc/msg/as_read.go +++ b/internal/rpc/msg/as_read.go @@ -206,15 +206,16 @@ func (m *msgServer) MarkConversationAsRead(ctx context.Context, req *msg.MarkCon return &msg.MarkConversationAsReadResp{}, nil } -// recordBurnDeadlines 在「单聊」场景下,根据对端的 MsgBurnDuration 为本次已读的每条消息 -// 记录一份「阅后即焚」截止时间到 mongo。后续由 cron 推进 min_seq 让消息从该用户视图消失。 +// recordBurnDeadlines 在「单聊」场景下,根据对端(发送者)的 MsgBurnDuration +// 为本次已读的每条消息同时给接收者和发送者各记录一份「阅后即焚」截止时间。 +// cron 到期后会分别推进两人各自的 min_seq,双方都看不到该消息。 // // 设计要点: -// 1. 仅单聊:群聊有自己的不同语义(多接收方 + 仅 watermark 已读),暂不在此实现。 -// 2. 仅当对端 MsgBurnDuration > 0 时才记录;为 0 表示对端未开启该功能。 -// 3. 同一 (UserID, ConversationID, Seq) 已存在则不覆盖:保证以「首次阅读时刻」为基准, -// 避免多端重复 MarkAsRead 导致 deadline 被往后推。 -// 4. 失败仅记录日志,不影响主流程的已读语义。 +// 1. 仅单聊。 +// 2. 仅当发送者 MsgBurnDuration > 0 时才记录;0 表示未开启。 +// 3. $setOnInsert 确保同一 (UserID, ConversationID, Seq) 已存在时不覆盖, +// 以「首次阅读时刻」为 deadline 基准,多端重复 MarkAsRead 不会往后推。 +// 4. 失败仅记录日志,不影响已读主流程。 func (m *msgServer) recordBurnDeadlines(ctx context.Context, conv *conversation.Conversation, readerUserID string, seqs []int64) { if len(seqs) == 0 { return @@ -236,19 +237,30 @@ func (m *msgServer) recordBurnDeadlines(ctx context.Context, conv *conversation. } now := time.Now().UnixMilli() deadline := now + int64(peerInfo.MsgBurnDuration)*1000 - items := make([]*model.MsgBurnDeadline, 0, len(seqs)) + // 每条消息同时为接收者和发送者各写一条 deadline,双方消息同步焚毁。 + items := make([]*model.MsgBurnDeadline, 0, len(seqs)*2) for _, seq := range seqs { - items = append(items, &model.MsgBurnDeadline{ - UserID: readerUserID, - ConversationID: conv.ConversationID, - Seq: seq, - DeadlineMs: deadline, - CreateTime: now, - }) + items = append(items, + &model.MsgBurnDeadline{ + UserID: readerUserID, + ConversationID: conv.ConversationID, + Seq: seq, + DeadlineMs: deadline, + CreateTime: now, + }, + &model.MsgBurnDeadline{ + UserID: peerID, + ConversationID: conv.ConversationID, + Seq: seq, + DeadlineMs: deadline, + CreateTime: now, + }, + ) } if err := m.msgBurnDeadlineDB.UpsertIfAbsent(ctx, items); err != nil { log.ZError(ctx, "recordBurnDeadlines UpsertIfAbsent failed", err, - "userID", readerUserID, "conversationID", conv.ConversationID, "seqs", seqs) + "readerUserID", readerUserID, "peerID", peerID, + "conversationID", conv.ConversationID, "seqs", seqs) } } From 9376a2946fa450f5eaa86277fb87abd040921825 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 9 May 2026 18:37:25 +0800 Subject: [PATCH 31/39] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 2 ++ internal/api/user.go | 4 ++++ internal/rpc/user/user.go | 23 +++++++++++++++++++++++ protocol | 2 +- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/api/router.go b/internal/api/router.go index e9fde37ee..bbfed0eba 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -173,6 +173,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_group_invite_setting", u.SetGroupInviteSetting) // 设置用户全局阅后即焚时长(秒),0 表示关闭 userRouterGroup.POST("/set_user_msg_burn_duration", u.SetUserMsgBurnDuration) + // 批量查询阅后即焚、手机号可见性、音视频接收、全局/会话消息接收、群邀请等设置 + userRouterGroup.POST("/get_user_privacy_settings", u.GetUserPrivacySettings) // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) diff --git a/internal/api/user.go b/internal/api/user.go index aa82589a7..ad4e23fee 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -359,6 +359,10 @@ func (u *UserApi) SetUserMsgBurnDuration(c *gin.Context) { a2r.Call(c, user.UserClient.SetUserMsgBurnDuration, u.Client) } +func (u *UserApi) GetUserPrivacySettings(c *gin.Context) { + a2r.Call(c, user.UserClient.GetUserPrivacySettings, u.Client) +} + func (u *UserApi) GetUserByPhone(c *gin.Context) { a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) } diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index 1cd47121d..bb263e4db 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -455,6 +455,29 @@ func (s *userServer) SetUserMsgBurnDuration(ctx context.Context, req *pbuser.Set return &pbuser.SetUserMsgBurnDurationResp{}, nil } +// GetUserPrivacySettings 返回当前登录用户(ctx opUserID)的隐私与接收相关设置。 +func (s *userServer) GetUserPrivacySettings(ctx context.Context, req *pbuser.GetUserPrivacySettingsReq) (*pbuser.GetUserPrivacySettingsResp, error) { + userID := mcontext.GetOpUserID(ctx) + if userID == "" { + return nil, errs.ErrArgs.WrapMsg("opUserID is required") + } + users, err := s.db.FindWithError(ctx, []string{userID}) + if err != nil { + log.ZError(ctx, "GetUserPrivacySettings: user not found or db error", err, + "opUserID", userID) + return nil, err + } + u := users[0] + return &pbuser.GetUserPrivacySettingsResp{ + MsgBurnDuration: u.MsgBurnDuration, + PhoneVisibility: u.PhoneVisibility, + CallAcceptSetting: u.CallAcceptSetting, + GlobalRecvMsgOpt: u.GlobalRecvMsgOpt, + MsgReceiveSetting: u.MsgReceiveSetting, + GroupInviteSetting: u.GroupInviteSetting, + }, nil +} + // GetUserByPhone 根据精确手机号查询用户。 // // phone_visibility 仅控制用户资料中手机号字段是否展示,不影响搜索本身: diff --git a/protocol b/protocol index 49fe436a9..9c1ea89b5 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 49fe436a9a00d4b5a2e86f728cdd34156bed1164 +Subproject commit 9c1ea89b54286e04a376bdd4fa56a96e5b08766f From bd42186c1b543386089a501d26c974e4e3c0dc54 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 9 May 2026 22:10:52 +0800 Subject: [PATCH 32/39] =?UTF-8?q?=E7=99=BB=E5=BD=95=E3=80=81=E7=99=BB?= =?UTF-8?q?=E5=87=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 4 +++ internal/api/user.go | 10 ++++++++ internal/rpc/user/user.go | 42 ++++++++++++++++++++++++++++++++ pkg/common/storage/model/user.go | 36 +++++++++++++++------------ protocol | 2 +- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index bbfed0eba..52e6e4684 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -179,6 +179,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) userRouterGroup.POST("/get_users_by_nickname", u.GetUsersByNickname) + // 记录用户登录时间,返回登录时间戳及上次登录信息 + userRouterGroup.POST("/login", u.UserLogin) + // 记录用户登出时间,返回登出时间戳(毫秒) + userRouterGroup.POST("/logout", u.UserLogout) // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index ad4e23fee..925a6a41f 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -370,3 +370,13 @@ func (u *UserApi) GetUserByPhone(c *gin.Context) { func (u *UserApi) GetUsersByNickname(c *gin.Context) { a2r.Call(c, user.UserClient.GetUsersByNickname, u.Client) } + +// UserLogin 记录当前用户的登录时间。 +func (u *UserApi) UserLogin(c *gin.Context) { + a2r.Call(c, user.UserClient.UserLogin, u.Client) +} + +// UserLogout 记录当前用户的登出时间。 +func (u *UserApi) UserLogout(c *gin.Context) { + a2r.Call(c, user.UserClient.UserLogout, u.Client) +} diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index bb263e4db..aefad60c8 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -1064,3 +1064,45 @@ func (s *userServer) SortQuery(ctx context.Context, req *pbuser.SortQueryReq) (* } return &pbuser.SortQueryResp{Users: convert.UsersDB2Pb(users)}, nil } + +// UserLogin 记录当前用户的登录时间(userID 取自上下文 opUserID)。 +func (s *userServer) UserLogin(ctx context.Context, _ *pbuser.UserLoginReq) (*pbuser.UserLoginResp, error) { + userID := mcontext.GetOpUserID(ctx) + if userID == "" { + return nil, errs.ErrArgs.WrapMsg("opUserID is required") + } + if _, err := s.db.FindWithError(ctx, []string{userID}); err != nil { + log.ZError(ctx, "UserLogin: user not found", err, "userID", userID) + return nil, err + } + now := time.Now() + if err := s.db.UpdateByMap(ctx, userID, map[string]any{ + "cur_login_time": now, + }); err != nil { + log.ZError(ctx, "UserLogin: UpdateByMap failed", err, "userID", userID) + return nil, err + } + log.ZDebug(ctx, "UserLogin: recorded login time", "userID", userID, "loginTime", now) + return &pbuser.UserLoginResp{}, nil +} + +// UserLogout 记录当前登录用户的登出时间(userID 取自上下文 opUserID)。 +func (s *userServer) UserLogout(ctx context.Context, _ *pbuser.UserLogoutReq) (*pbuser.UserLogoutResp, error) { + userID := mcontext.GetOpUserID(ctx) + if userID == "" { + return nil, errs.ErrArgs.WrapMsg("opUserID is required") + } + if _, err := s.db.FindWithError(ctx, []string{userID}); err != nil { + log.ZError(ctx, "UserLogout: user not found", err, "userID", userID) + return nil, err + } + now := time.Now() + if err := s.db.UpdateByMap(ctx, userID, map[string]any{ + "last_logout_time": now, + }); err != nil { + log.ZError(ctx, "UserLogout: UpdateByMap failed", err, "userID", userID) + return nil, err + } + log.ZDebug(ctx, "UserLogout: recorded logout time", "userID", userID, "logoutTime", now) + return &pbuser.UserLogoutResp{}, nil +} diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index e880e1255..5ab5a8cfa 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -59,28 +59,32 @@ const ( ) type User struct { - UserID string `bson:"user_id"` - Nickname string `bson:"nickname"` - FaceURL string `bson:"face_url"` - Ex string `bson:"ex"` - AppMangerLevel int32 `bson:"app_manger_level"` - GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` - CreateTime time.Time `bson:"create_time"` - FirstName string `bson:"first_name"` - LastName string `bson:"last_name"` - FullName string `bson:"full_name"` - Phone string `bson:"phone"` - AreaCode string `bson:"area_code"` - PhoneVisibility int32 `bson:"phone_visibility"` - CallAcceptSetting int32 `bson:"call_accept_setting"` - MsgReceiveSetting int32 `bson:"msg_receive_setting"` - GroupInviteSetting int32 `bson:"group_invite_setting"` + UserID string `bson:"user_id"` + Nickname string `bson:"nickname"` + FaceURL string `bson:"face_url"` + Ex string `bson:"ex"` + AppMangerLevel int32 `bson:"app_manger_level"` + GlobalRecvMsgOpt int32 `bson:"global_recv_msg_opt"` + CreateTime time.Time `bson:"create_time"` + FirstName string `bson:"first_name"` + LastName string `bson:"last_name"` + FullName string `bson:"full_name"` + Phone string `bson:"phone"` + AreaCode string `bson:"area_code"` + PhoneVisibility int32 `bson:"phone_visibility"` + CallAcceptSetting int32 `bson:"call_accept_setting"` + MsgReceiveSetting int32 `bson:"msg_receive_setting"` + GroupInviteSetting int32 `bson:"group_invite_setting"` // CallRingtoneURL 用户自定义来电铃声 URL;对方来电时播放此铃声 CallRingtoneURL string `bson:"call_ringtone_url"` // Status 账号状态:0=正常,1=冻结,2=黑名单 Status int32 `bson:"status"` // MsgBurnDuration 用户全局消息阅后即焚时长(秒);0 表示关闭 MsgBurnDuration int32 `bson:"msg_burn_duration"` + // CurLoginTime 当前(最近一次)登录时间;nil 表示从未登录 + CurLoginTime *time.Time `bson:"cur_login_time"` + // LastLogoutTime 最近一次登出时间;nil 表示从未登出 + LastLogoutTime *time.Time `bson:"last_logout_time"` } func (u *User) GetNickname() string { diff --git a/protocol b/protocol index 9c1ea89b5..4741c40fb 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9c1ea89b54286e04a376bdd4fa56a96e5b08766f +Subproject commit 4741c40fbd3d369bdf0f9f34c00791ec6483425b From b34d7262e3c548f5155bdb6859336d4787bee860 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 9 May 2026 22:32:10 +0800 Subject: [PATCH 33/39] =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E7=99=BB=E5=87=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 4 --- internal/api/user.go | 10 -------- internal/rpc/user/user.go | 42 -------------------------------- pkg/common/storage/model/user.go | 4 --- protocol | 2 +- 5 files changed, 1 insertion(+), 61 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 52e6e4684..bbfed0eba 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -179,10 +179,6 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 根据昵称精确查询用户(可多结果,与 getPaginationUsers 模糊搜索不同) userRouterGroup.POST("/get_users_by_nickname", u.GetUsersByNickname) - // 记录用户登录时间,返回登录时间戳及上次登录信息 - userRouterGroup.POST("/login", u.UserLogin) - // 记录用户登出时间,返回登出时间戳(毫秒) - userRouterGroup.POST("/logout", u.UserLogout) // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index 925a6a41f..ad4e23fee 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -370,13 +370,3 @@ func (u *UserApi) GetUserByPhone(c *gin.Context) { func (u *UserApi) GetUsersByNickname(c *gin.Context) { a2r.Call(c, user.UserClient.GetUsersByNickname, u.Client) } - -// UserLogin 记录当前用户的登录时间。 -func (u *UserApi) UserLogin(c *gin.Context) { - a2r.Call(c, user.UserClient.UserLogin, u.Client) -} - -// UserLogout 记录当前用户的登出时间。 -func (u *UserApi) UserLogout(c *gin.Context) { - a2r.Call(c, user.UserClient.UserLogout, u.Client) -} diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index aefad60c8..bb263e4db 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -1064,45 +1064,3 @@ func (s *userServer) SortQuery(ctx context.Context, req *pbuser.SortQueryReq) (* } return &pbuser.SortQueryResp{Users: convert.UsersDB2Pb(users)}, nil } - -// UserLogin 记录当前用户的登录时间(userID 取自上下文 opUserID)。 -func (s *userServer) UserLogin(ctx context.Context, _ *pbuser.UserLoginReq) (*pbuser.UserLoginResp, error) { - userID := mcontext.GetOpUserID(ctx) - if userID == "" { - return nil, errs.ErrArgs.WrapMsg("opUserID is required") - } - if _, err := s.db.FindWithError(ctx, []string{userID}); err != nil { - log.ZError(ctx, "UserLogin: user not found", err, "userID", userID) - return nil, err - } - now := time.Now() - if err := s.db.UpdateByMap(ctx, userID, map[string]any{ - "cur_login_time": now, - }); err != nil { - log.ZError(ctx, "UserLogin: UpdateByMap failed", err, "userID", userID) - return nil, err - } - log.ZDebug(ctx, "UserLogin: recorded login time", "userID", userID, "loginTime", now) - return &pbuser.UserLoginResp{}, nil -} - -// UserLogout 记录当前登录用户的登出时间(userID 取自上下文 opUserID)。 -func (s *userServer) UserLogout(ctx context.Context, _ *pbuser.UserLogoutReq) (*pbuser.UserLogoutResp, error) { - userID := mcontext.GetOpUserID(ctx) - if userID == "" { - return nil, errs.ErrArgs.WrapMsg("opUserID is required") - } - if _, err := s.db.FindWithError(ctx, []string{userID}); err != nil { - log.ZError(ctx, "UserLogout: user not found", err, "userID", userID) - return nil, err - } - now := time.Now() - if err := s.db.UpdateByMap(ctx, userID, map[string]any{ - "last_logout_time": now, - }); err != nil { - log.ZError(ctx, "UserLogout: UpdateByMap failed", err, "userID", userID) - return nil, err - } - log.ZDebug(ctx, "UserLogout: recorded logout time", "userID", userID, "logoutTime", now) - return &pbuser.UserLogoutResp{}, nil -} diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 5ab5a8cfa..503ccfb57 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -81,10 +81,6 @@ type User struct { Status int32 `bson:"status"` // MsgBurnDuration 用户全局消息阅后即焚时长(秒);0 表示关闭 MsgBurnDuration int32 `bson:"msg_burn_duration"` - // CurLoginTime 当前(最近一次)登录时间;nil 表示从未登录 - CurLoginTime *time.Time `bson:"cur_login_time"` - // LastLogoutTime 最近一次登出时间;nil 表示从未登出 - LastLogoutTime *time.Time `bson:"last_logout_time"` } func (u *User) GetNickname() string { diff --git a/protocol b/protocol index 4741c40fb..f7bfce630 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 4741c40fbd3d369bdf0f9f34c00791ec6483425b +Subproject commit f7bfce630638a3dc3fd776842d1c3e8078559269 From 62e965ad28bfbc6b6030316b5a91aeb6401faef3 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 9 May 2026 22:48:12 +0800 Subject: [PATCH 34/39] =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E7=99=BB=E5=87=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/delete_user.go | 12 +++++++++++- internal/api/router.go | 2 +- pkg/common/storage/database/mgo/phone_sn.go | 8 ++++++++ pkg/common/storage/database/phone_sn.go | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go index 81c87103f..ccc3be175 100644 --- a/internal/api/delete_user.go +++ b/internal/api/delete_user.go @@ -18,6 +18,7 @@ import ( // It follows the same direct-DB pattern as UserGlobalBlackApi. type DeleteUserApi struct { userDB database.User + phoneSNDB database.PhoneSN authClient *rpcli.AuthClient groupClient group.GroupClient friendClient relation.FriendClient @@ -26,6 +27,7 @@ type DeleteUserApi struct { func NewDeleteUserApi( userDB database.User, + phoneSNDB database.PhoneSN, authClient *rpcli.AuthClient, groupClient group.GroupClient, friendClient relation.FriendClient, @@ -33,6 +35,7 @@ func NewDeleteUserApi( ) *DeleteUserApi { return &DeleteUserApi{ userDB: userDB, + phoneSNDB: phoneSNDB, authClient: authClient, groupClient: groupClient, friendClient: friendClient, @@ -131,7 +134,14 @@ func (d *DeleteUserApi) DeleteUser(c *gin.Context) { pageNumber++ } - // 5. Hard-delete user document from MongoDB. + // 5. Delete phone_sn_info record bound to this user's phone number. + if phone := users[0].Phone; phone != "" { + if err := d.phoneSNDB.DeleteByPhone(c, phone); err != nil { + log.ZWarn(c, "DeleteUser: DeleteByPhone failed", err, "userID", req.UserID, "phone", phone) + } + } + + // 6. Hard-delete user document from MongoDB. // Redis cache will become stale and expire via TTL; the user can no longer // authenticate because their tokens were already invalidated in step 2. if err := d.userDB.Delete(c, []string{req.UserID}); err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index bbfed0eba..9f622be9e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -137,7 +137,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn)) bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) - du := NewDeleteUserApi(userDB, rpcli.NewAuthClient(authConn), group.NewGroupClient(groupConn), relation.NewFriendClient(friendConn), config.Share.IMAdminUserID) + du := NewDeleteUserApi(userDB, phoneSNDB, rpcli.NewAuthClient(authConn), group.NewGroupClient(groupConn), relation.NewFriendClient(friendConn), config.Share.IMAdminUserID) phoneSN := NewPhoneSNApi(phoneSNDB) userRouterGroup := r.Group("/user") { diff --git a/pkg/common/storage/database/mgo/phone_sn.go b/pkg/common/storage/database/mgo/phone_sn.go index fa09f3657..594ea72dc 100644 --- a/pkg/common/storage/database/mgo/phone_sn.go +++ b/pkg/common/storage/database/mgo/phone_sn.go @@ -48,6 +48,14 @@ func (p *phoneSNMgo) GetByPhone(ctx context.Context, phone string) (*model.Phone return doc, nil } +func (p *phoneSNMgo) DeleteByPhone(ctx context.Context, phone string) error { + if phone == "" { + return nil + } + _, err := p.coll.DeleteOne(ctx, bson.M{"phone": phone}) + return errs.Wrap(err) +} + func (p *phoneSNMgo) Upsert(ctx context.Context, phone string, userID int64, isSnd bool) error { if phone == "" { return errs.ErrArgs.WrapMsg("phone is empty") diff --git a/pkg/common/storage/database/phone_sn.go b/pkg/common/storage/database/phone_sn.go index 3ded66b0f..055a6c5b5 100644 --- a/pkg/common/storage/database/phone_sn.go +++ b/pkg/common/storage/database/phone_sn.go @@ -17,4 +17,6 @@ type PhoneSN interface { GetByPhone(ctx context.Context, phone string) (*model.PhoneSNInfo, error) // Upsert 写入或更新 is_snd 与 user_id Upsert(ctx context.Context, phone string, userID int64, isSnd bool) error + // DeleteByPhone 按手机号删除记录;记录不存在时不报错 + DeleteByPhone(ctx context.Context, phone string) error } From fb9eb73ef2a535c95863c50122c2fc93cba1cdfc Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 9 May 2026 23:26:14 +0800 Subject: [PATCH 35/39] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A418=E4=B8=AA=E6=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/router.go | 2 ++ internal/api/user.go | 4 +++ internal/rpc/user/user.go | 44 +++++++++++++++++++++---- pkg/common/storage/database/mgo/user.go | 5 +++ pkg/common/storage/model/user.go | 6 ++++ protocol | 2 +- 6 files changed, 56 insertions(+), 7 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 9f622be9e..ce16cd535 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -173,6 +173,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_group_invite_setting", u.SetGroupInviteSetting) // 设置用户全局阅后即焚时长(秒),0 表示关闭 userRouterGroup.POST("/set_user_msg_burn_duration", u.SetUserMsgBurnDuration) + // 设置删除账号等待间隔(秒),0 表示使用系统默认(18个月) + userRouterGroup.POST("/set_delete_account_interval", u.SetDeleteAccountInterval) // 批量查询阅后即焚、手机号可见性、音视频接收、全局/会话消息接收、群邀请等设置 userRouterGroup.POST("/get_user_privacy_settings", u.GetUserPrivacySettings) // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) diff --git a/internal/api/user.go b/internal/api/user.go index ad4e23fee..90f84fcbd 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -359,6 +359,10 @@ func (u *UserApi) SetUserMsgBurnDuration(c *gin.Context) { a2r.Call(c, user.UserClient.SetUserMsgBurnDuration, u.Client) } +func (u *UserApi) SetDeleteAccountInterval(c *gin.Context) { + a2r.Call(c, user.UserClient.SetDeleteAccountInterval, u.Client) +} + func (u *UserApi) GetUserPrivacySettings(c *gin.Context) { a2r.Call(c, user.UserClient.GetUserPrivacySettings, u.Client) } diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index bb263e4db..abd64eaf0 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -455,6 +455,36 @@ func (s *userServer) SetUserMsgBurnDuration(ctx context.Context, req *pbuser.Set return &pbuser.SetUserMsgBurnDurationResp{}, nil } +// SetDeleteAccountInterval 设置用户删除账号等待间隔(秒); +// 0 表示重置为系统默认(18个月),要求 >= 0。只允许本人或管理员操作。 +func (s *userServer) SetDeleteAccountInterval(ctx context.Context, req *pbuser.SetDeleteAccountIntervalReq) (*pbuser.SetDeleteAccountIntervalResp, error) { + if req.UserID == "" { + return nil, errs.ErrArgs.WrapMsg("userID is required") + } + if req.DeleteAccountInterval < 0 { + return nil, errs.ErrArgs.WrapMsg("deleteAccountInterval must be >= 0 (seconds)") + } + if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { + log.ZWarn(ctx, "SetDeleteAccountInterval: 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, "SetDeleteAccountInterval: 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{ + "delete_account_interval": req.DeleteAccountInterval, + }); err != nil { + log.ZError(ctx, "SetDeleteAccountInterval: UpdateByMap failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID, + "deleteAccountInterval", req.DeleteAccountInterval) + return nil, err + } + return &pbuser.SetDeleteAccountIntervalResp{}, nil +} + // GetUserPrivacySettings 返回当前登录用户(ctx opUserID)的隐私与接收相关设置。 func (s *userServer) GetUserPrivacySettings(ctx context.Context, req *pbuser.GetUserPrivacySettingsReq) (*pbuser.GetUserPrivacySettingsResp, error) { userID := mcontext.GetOpUserID(ctx) @@ -468,13 +498,15 @@ func (s *userServer) GetUserPrivacySettings(ctx context.Context, req *pbuser.Get return nil, err } u := users[0] + return &pbuser.GetUserPrivacySettingsResp{ - MsgBurnDuration: u.MsgBurnDuration, - PhoneVisibility: u.PhoneVisibility, - CallAcceptSetting: u.CallAcceptSetting, - GlobalRecvMsgOpt: u.GlobalRecvMsgOpt, - MsgReceiveSetting: u.MsgReceiveSetting, - GroupInviteSetting: u.GroupInviteSetting, + MsgBurnDuration: u.MsgBurnDuration, + PhoneVisibility: u.PhoneVisibility, + CallAcceptSetting: u.CallAcceptSetting, + GlobalRecvMsgOpt: u.GlobalRecvMsgOpt, + MsgReceiveSetting: u.MsgReceiveSetting, + GroupInviteSetting: u.GroupInviteSetting, + DeleteAccountInterval: u.DeleteAccountInterval, }, nil } diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index 9ba0a0514..25044cb1d 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -57,6 +57,11 @@ type UserMgo struct { } func (u *UserMgo) Create(ctx context.Context, users []*model.User) error { + for _, user := range users { + if user.DeleteAccountInterval == 0 { + user.DeleteAccountInterval = model.DefaultDeleteAccountIntervalSec + } + } return mongoutil.InsertMany(ctx, u.coll, users) } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 503ccfb57..1eac5c35a 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -58,6 +58,10 @@ const ( UserStatusBlacklist int32 = 2 ) +// DefaultDeleteAccountIntervalSec 删除账号等待间隔的系统默认值(18 个月,按 30 天/月折算)。 +// 注册时写入 MongoDB 的初始值;用户未显式修改时即为此值。 +const DefaultDeleteAccountIntervalSec int32 = 18 * 30 * 24 * 3600 + type User struct { UserID string `bson:"user_id"` Nickname string `bson:"nickname"` @@ -81,6 +85,8 @@ type User struct { Status int32 `bson:"status"` // MsgBurnDuration 用户全局消息阅后即焚时长(秒);0 表示关闭 MsgBurnDuration int32 `bson:"msg_burn_duration"` + // DeleteAccountInterval 删除账号间隔(秒);0 表示关闭 + DeleteAccountInterval int32 `bson:"delete_account_interval"` } func (u *User) GetNickname() string { diff --git a/protocol b/protocol index f7bfce630..24c722593 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit f7bfce630638a3dc3fd776842d1c3e8078559269 +Subproject commit 24c72259373e9080ab005bb10385524753d4dfc3 From aa2eaee96015afa38508c4c5f79636a08cce47fd Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 11 May 2026 15:47:38 +0800 Subject: [PATCH 36/39] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A418=E4=B8=AA=E6=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/user/online.go | 42 ++++++++ internal/rpc/user/user.go | 25 ++++- internal/tools/chat_admin_token.go | 23 +++++ internal/tools/cron_task.go | 73 ++++++++++---- internal/tools/delete_expired_user.go | 95 +++++++++++++++++++ pkg/common/cmd/cron_task.go | 6 ++ pkg/common/config/config.go | 9 ++ pkg/common/storage/controller/user.go | 15 +++ .../database/mgo/user_offline_record.go | 92 ++++++++++++++++++ pkg/common/storage/database/name.go | 1 + .../storage/database/user_offline_record.go | 28 ++++++ .../storage/model/user_offline_record.go | 14 +++ 12 files changed, 403 insertions(+), 20 deletions(-) create mode 100644 internal/tools/chat_admin_token.go create mode 100644 internal/tools/delete_expired_user.go create mode 100644 pkg/common/storage/database/mgo/user_offline_record.go create mode 100644 pkg/common/storage/database/user_offline_record.go create mode 100644 pkg/common/storage/model/user_offline_record.go diff --git a/internal/rpc/user/online.go b/internal/rpc/user/online.go index 0e5365ed9..5d8051e2d 100644 --- a/internal/rpc/user/online.go +++ b/internal/rpc/user/online.go @@ -2,7 +2,10 @@ package user import ( "context" + "time" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/utils/datautil" "github.com/openimsdk/protocol/constant" @@ -80,10 +83,49 @@ func (s *userServer) SetUserOnlineStatus(ctx context.Context, req *pbuser.SetUse if err := s.online.SetUserOnline(ctx, status.UserID, status.Online, status.Offline); err != nil { return nil, err } + s.updateOfflineRecord(ctx, status.UserID, len(status.Offline) > 0, len(status.Online) > 0) } return &pbuser.SetUserOnlineStatusResp{}, nil } +// updateOfflineRecord 根据用户当前在线状态维护 user_offline_record 集合: +// - 若某平台刚断开且用户已全平台离线 → upsert 离线记录(仅首次写入,保留最早离线时刻) +// - 若某平台刚上线且用户当前有在线平台 → 删除离线记录(停止计时) +func (s *userServer) updateOfflineRecord(ctx context.Context, userID string, hasOffline, hasOnline bool) { + if !hasOffline && !hasOnline { + return + } + platformIDs, err := s.online.GetOnline(ctx, userID) + if err != nil { + log.ZWarn(ctx, "updateOfflineRecord: GetOnline failed", err, "userID", userID) + return + } + if len(platformIDs) == 0 { + // 所有平台已离线,写入离线记录(含预计算的删除截止时间) + offlineTime := time.Now() + deadline := s.calcDeleteDeadline(ctx, userID, offlineTime) + if err := s.userOfflineRecord.Upsert(ctx, userID, offlineTime, deadline); err != nil { + log.ZWarn(ctx, "updateOfflineRecord: Upsert failed", err, "userID", userID) + } + } else if hasOnline { + // 用户重新上线,删除离线记录,停止计时 + if err := s.userOfflineRecord.Delete(ctx, userID); err != nil { + log.ZWarn(ctx, "updateOfflineRecord: Delete failed", err, "userID", userID) + } + } +} + +// calcDeleteDeadline 查询用户的 delete_account_interval 并计算删除截止时间。 +// 若查询失败或 interval 为 0,则使用系统默认值(18 个月)。 +func (s *userServer) calcDeleteDeadline(ctx context.Context, userID string, from time.Time) time.Time { + interval := int32(model.DefaultDeleteAccountIntervalSec) + users, err := s.db.Find(ctx, []string{userID}) + if err == nil && len(users) > 0 && users[0].DeleteAccountInterval > 0 { + interval = users[0].DeleteAccountInterval + } + return from.Add(time.Duration(interval) * time.Second) +} + func (s *userServer) GetAllOnlineUsers(ctx context.Context, req *pbuser.GetAllOnlineUsersReq) (*pbuser.GetAllOnlineUsersResp, error) { resMap, nextCursor, err := s.online.GetAllOnlineUsers(ctx, req.Cursor) if err != nil { diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index abd64eaf0..607d586e8 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -29,6 +29,7 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/prommetrics" "github.com/openimsdk/open-im-server/v3/pkg/common/storage/cache" "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" tablerelation "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" "github.com/openimsdk/open-im-server/v3/pkg/common/webhook" @@ -71,6 +72,7 @@ type userServer struct { groupClient *rpcli.GroupClient relationClient *rpcli.RelationClient globalBlackDB controller.UserGlobalBlackDatabase + userOfflineRecord database.UserOfflineRecord } type Config struct { @@ -122,6 +124,10 @@ func Start(ctx context.Context, config *Config, client registry.SvcDiscoveryRegi if err != nil { return err } + userOfflineRecordDB, err := mgo.NewUserOfflineRecordMongo(mgocli.GetDB()) + if err != nil { + return err + } localcache.InitLocalCache(&config.LocalCacheConfig) u := &userServer{ online: redis.NewUserOnline(rdb), @@ -132,9 +138,10 @@ func Start(ctx context.Context, config *Config, client registry.SvcDiscoveryRegi config: config, webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), - groupClient: rpcli.NewGroupClient(groupConn), - relationClient: rpcli.NewRelationClient(friendConn), - globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), + groupClient: rpcli.NewGroupClient(groupConn), + relationClient: rpcli.NewRelationClient(friendConn), + globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), + userOfflineRecord: userOfflineRecordDB, } pbuser.RegisterUserServer(server, u) return u.db.InitOnce(context.Background(), users) @@ -482,6 +489,18 @@ func (s *userServer) SetDeleteAccountInterval(ctx context.Context, req *pbuser.S "deleteAccountInterval", req.DeleteAccountInterval) return nil, err } + // 若用户当前处于离线状态(user_offline_record 中有记录),将 offline_time 与 + // delete_user_deadline 刷新为当前时刻及新截止时间,使倒计时从本次设置时刻重新起算。 + now := time.Now() + interval := req.DeleteAccountInterval + if interval == 0 { + interval = tablerelation.DefaultDeleteAccountIntervalSec + } + newDeadline := now.Add(time.Duration(interval) * time.Second) + if err := s.userOfflineRecord.RefreshOfflineTime(ctx, req.UserID, now, newDeadline); err != nil { + log.ZWarn(ctx, "SetDeleteAccountInterval: RefreshOfflineTime failed", err, + "userID", req.UserID) + } return &pbuser.SetDeleteAccountIntervalResp{}, nil } diff --git a/internal/tools/chat_admin_token.go b/internal/tools/chat_admin_token.go new file mode 100644 index 000000000..93fe01baf --- /dev/null +++ b/internal/tools/chat_admin_token.go @@ -0,0 +1,23 @@ +package tools + +import ( + "context" + + "github.com/openimsdk/protocol/auth" + "github.com/openimsdk/tools/log" +) + +// fetchChatAdminToken 通过 IM auth-rpc GetAdminToken 获取管理员 token。 +// 使用 config.Share.Secret 和第一个 IMAdminUserID 作为凭据。 +func (c *cronServer) fetchChatAdminToken(ctx context.Context) (string, error) { + userID := c.config.Share.IMAdminUserID[0] + resp, err := c.authClient.GetAdminToken(ctx, &auth.GetAdminTokenReq{ + Secret: c.config.Share.Secret, + UserID: userID, + }) + if err != nil { + return "", err + } + log.ZDebug(ctx, "fetchChatAdminToken: ok", "userID", userID) + return resp.Token, nil +} diff --git a/internal/tools/cron_task.go b/internal/tools/cron_task.go index 1e28ffafc..8dcb0195a 100644 --- a/internal/tools/cron_task.go +++ b/internal/tools/cron_task.go @@ -19,9 +19,13 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/config" kdisc "github.com/openimsdk/open-im-server/v3/pkg/common/discoveryregister" + "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/rpcli" pbconversation "github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/third" + "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/mcontext" "github.com/openimsdk/tools/mw" @@ -34,9 +38,10 @@ import ( ) type CronTaskConfig struct { - CronTask config.CronTask - Share config.Share - Discovery config.Discovery + CronTask config.CronTask + Share config.Share + Discovery config.Discovery + MongodbConfig config.Mongo } func Start(ctx context.Context, config *CronTaskConfig) error { @@ -55,24 +60,40 @@ func Start(ctx context.Context, config *CronTaskConfig) error { if err != nil { return err } - thirdConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Third) if err != nil { return err } - conversationConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Conversation) if err != nil { return err } + authConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Auth) + if err != nil { + return err + } + + mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build()) + if err != nil { + return errs.WrapMsg(err, "crontask: connect mongodb failed") + } + db := mgocli.GetDB() + + userOfflineRecordDB, err := mgo.NewUserOfflineRecordMongo(db) + if err != nil { + return errs.WrapMsg(err, "crontask: init user_offline_record collection failed") + } srv := &cronServer{ - ctx: ctx, - config: config, - cron: cron.New(), - msgClient: msg.NewMsgClient(msgConn), - conversationClient: pbconversation.NewConversationClient(conversationConn), - thirdClient: third.NewThirdClient(thirdConn), + ctx: ctx, + config: config, + cron: cron.New(), + msgClient: msg.NewMsgClient(msgConn), + conversationClient: pbconversation.NewConversationClient(conversationConn), + thirdClient: third.NewThirdClient(thirdConn), + authClient: rpcli.NewAuthClient(authConn), + userOfflineRecordDB: userOfflineRecordDB, + chatAPIAddress: config.CronTask.ChatAPI.Address, } if err := srv.registerClearS3(); err != nil { @@ -87,6 +108,9 @@ func Start(ctx context.Context, config *CronTaskConfig) error { if err := srv.registerClearBurnExpiredMsgs(); err != nil { return err } + if err := srv.registerDeleteExpiredOfflineUsers(); err != nil { + return err + } log.ZDebug(ctx, "start cron task", "CronExecuteTime", config.CronTask.CronExecuteTime) srv.cron.Start() <-ctx.Done() @@ -94,12 +118,15 @@ func Start(ctx context.Context, config *CronTaskConfig) error { } type cronServer struct { - ctx context.Context - config *CronTaskConfig - cron *cron.Cron - msgClient msg.MsgClient - conversationClient pbconversation.ConversationClient - thirdClient third.ThirdClient + ctx context.Context + config *CronTaskConfig + cron *cron.Cron + msgClient msg.MsgClient + conversationClient pbconversation.ConversationClient + thirdClient third.ThirdClient + authClient *rpcli.AuthClient + userOfflineRecordDB database.UserOfflineRecord + chatAPIAddress string } func (c *cronServer) registerClearS3() error { @@ -129,3 +156,15 @@ func (c *cronServer) registerClearBurnExpiredMsgs() error { _, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, c.clearBurnExpiredMsgs) return errs.WrapMsg(err, "failed to register clear burn expired msgs cron task") } + +// registerDeleteExpiredOfflineUsers 注册每小时执行一次的用户自动删除任务。 +// 固定使用 "@hourly" 表达式,与其他任务使用的 CronExecuteTime 独立。 +// chatAPI.address 未配置时跳过注册。 +func (c *cronServer) registerDeleteExpiredOfflineUsers() error { + if c.chatAPIAddress == "" { + log.ZInfo(c.ctx, "disable auto delete expired offline users: chatAPI.address not configured") + return nil + } + _, err := c.cron.AddFunc("@hourly", c.deleteExpiredOfflineUsers) + return errs.WrapMsg(err, "failed to register delete expired offline users cron task") +} diff --git a/internal/tools/delete_expired_user.go b/internal/tools/delete_expired_user.go new file mode 100644 index 000000000..bb303213d --- /dev/null +++ b/internal/tools/delete_expired_user.go @@ -0,0 +1,95 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/openimsdk/tools/log" + "github.com/openimsdk/tools/mcontext" +) + +const deleteExpiredUserBatchLimit = 100 + +// chatHTTPClient 带超时,防止 chat 服务无响应时 cron worker 永久挂起。 +var chatHTTPClient = &http.Client{Timeout: 3 * time.Second} + +// deleteExpiredOfflineUsers 是 cron "@hourly" 触发的入口。 +// 批量查询离线时长超过 delete_account_interval 的用户并依次调用 chat /account/del 删除。 +func (c *cronServer) deleteExpiredOfflineUsers() { + now := time.Now() + operationID := fmt.Sprintf("cron_del_expired_user_%d_%d", os.Getpid(), now.UnixMilli()) + ctx := mcontext.SetOperationID(c.ctx, operationID) + log.ZInfo(ctx, "deleteExpiredOfflineUsers: start", "time", now) + + users, err := c.userOfflineRecordDB.FindExpiredUsers(ctx, now, deleteExpiredUserBatchLimit) + if err != nil { + log.ZError(ctx, "deleteExpiredOfflineUsers: FindExpiredUsers failed", err) + return + } + if len(users) == 0 { + log.ZDebug(ctx, "deleteExpiredOfflineUsers: no expired users found") + return + } + log.ZInfo(ctx, "deleteExpiredOfflineUsers: found expired users", "count", len(users)) + + adminToken, err := c.fetchChatAdminToken(ctx) + if err != nil { + log.ZError(ctx, "deleteExpiredOfflineUsers: fetchChatAdminToken failed", err) + return + } + + for i, u := range users { + subCtx := mcontext.SetOperationID(c.ctx, fmt.Sprintf("%s_%d", operationID, i)) + c.deleteExpiredUser(subCtx, adminToken, u.UserID) + } + log.ZInfo(ctx, "deleteExpiredOfflineUsers: done", "count", len(users), "elapsed", time.Since(now)) +} + +// deleteExpiredUser 通过 chat HTTP API POST /account/del 删除单个过期用户。 +// chat 服务端会处理:强制登出、删除好友/群组关系、清理 chat 账号数据等。 +// adminToken 为当次批次开始时通过 admin-api /account/login 获取的管理员 token。 +func (c *cronServer) deleteExpiredUser(ctx context.Context, adminToken, userID string) { + log.ZInfo(ctx, "deleteExpiredUser: start", "userID", userID) + + operationID := mcontext.GetOperationID(ctx) + + body, _ := json.Marshal(map[string]any{"userIDs": []string{userID}}) + url := c.chatAPIAddress + "/account/del" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + log.ZError(ctx, "deleteExpiredUser: build request failed", err, "userID", userID) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("token", adminToken) + req.Header.Set("operationID", operationID) + + resp, err := chatHTTPClient.Do(req) + if err != nil { + log.ZError(ctx, "deleteExpiredUser: HTTP call failed", err, "userID", userID, "url", url) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var result map[string]any + _ = json.NewDecoder(resp.Body).Decode(&result) + log.ZError(ctx, "deleteExpiredUser: chat API returned error", + fmt.Errorf("status %d", resp.StatusCode), + "userID", userID, "response", result) + return + } + + // chat /account/del 已处理好友/群组/IM用户删除;仅清理 user_offline_record 防止重复触发 + if err := c.userOfflineRecordDB.Delete(ctx, userID); err != nil { + log.ZWarn(ctx, "deleteExpiredUser: Delete offline record failed", err, "userID", userID) + } + + log.ZInfo(ctx, "deleteExpiredUser: done", "userID", userID) +} diff --git a/pkg/common/cmd/cron_task.go b/pkg/common/cmd/cron_task.go index d6c5e472e..cb62e6f72 100644 --- a/pkg/common/cmd/cron_task.go +++ b/pkg/common/cmd/cron_task.go @@ -33,10 +33,16 @@ type CronTaskCmd struct { func NewCronTaskCmd() *CronTaskCmd { var cronTaskConfig tools.CronTaskConfig ret := &CronTaskCmd{cronTaskConfig: &cronTaskConfig} + // ChatAPI 配置内嵌在 openim-crontask.yml 的 chatAPI 字段中,无需单独文件。 + // 示例: + // chatAPI: + // address: "http://127.0.0.1:10008" + // adminToken 由 crontask 通过 IM auth-rpc GetAdminToken 自动获取,无需额外配置。 ret.configMap = map[string]any{ OpenIMCronTaskCfgFileName: &cronTaskConfig.CronTask, ShareFileName: &cronTaskConfig.Share, DiscoveryConfigFilename: &cronTaskConfig.Discovery, + MongodbConfigFileName: &cronTaskConfig.MongodbConfig, } ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap)) ret.ctx = context.WithValue(context.Background(), "version", version.Version) diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 52ddb7cac..0805e1767 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -119,6 +119,8 @@ type CronTask struct { RetainChatRecords int `mapstructure:"retainChatRecords"` FileExpireTime int `mapstructure:"fileExpireTime"` DeleteObjectType []string `mapstructure:"deleteObjectType"` + // ChatAPI 是 chat HTTP API 服务的访问配置,用于调用 /account/del 等需要管理员权限的接口。 + ChatAPI ChatAPI `mapstructure:"chatAPI"` } type OfflinePushConfig struct { @@ -404,6 +406,13 @@ type Share struct { RPCMaxBodySize MaxRequestBody `mapstructure:"rpcMaxBodySize"` } +// ChatAPI 是 chat HTTP API 服务的访问配置。 +// Address 为 chat-api 根地址(如 http://127.0.0.1:10008),用于 POST /account/del。 +// token 由 crontask 通过 IM auth-rpc GetAdminToken 自动获取,无需手动填写。 +type ChatAPI struct { + Address string `mapstructure:"address"` +} + type MaxRequestBody struct { RequestMaxBodySize int `mapstructure:"requestMaxBodySize"` ResponseMaxBodySize int `mapstructure:"responseMaxBodySize"` diff --git a/pkg/common/storage/controller/user.go b/pkg/common/storage/controller/user.go index d1ff44101..4568eac5a 100644 --- a/pkg/common/storage/controller/user.go +++ b/pkg/common/storage/controller/user.go @@ -69,6 +69,9 @@ type UserDatabase interface { SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) + // Delete permanently removes users and invalidates their cache entries. + Delete(ctx context.Context, userIDs []string) error + // CRUD user command AddUserCommand(ctx context.Context, userID string, Type int32, UUID string, value string, ex string) error DeleteUserCommand(ctx context.Context, userID string, Type int32, UUID string) error @@ -222,6 +225,18 @@ func (u *userDatabase) SortQuery(ctx context.Context, userIDName map[string]stri return u.userDB.SortQuery(ctx, userIDName, asc) } +func (u *userDatabase) Delete(ctx context.Context, userIDs []string) error { + if len(userIDs) == 0 { + return nil + } + return u.tx.Transaction(ctx, func(ctx context.Context) error { + if err := u.userDB.Delete(ctx, userIDs); err != nil { + return err + } + return u.cache.DelUsersInfo(userIDs...).ChainExecDel(ctx) + }) +} + func (u *userDatabase) AddUserCommand(ctx context.Context, userID string, Type int32, UUID string, value string, ex string) error { return u.userDB.AddUserCommand(ctx, userID, Type, UUID, value, ex) } diff --git a/pkg/common/storage/database/mgo/user_offline_record.go b/pkg/common/storage/database/mgo/user_offline_record.go new file mode 100644 index 000000000..f470fac69 --- /dev/null +++ b/pkg/common/storage/database/mgo/user_offline_record.go @@ -0,0 +1,92 @@ +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/errs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewUserOfflineRecordMongo(db *mongo.Database) (database.UserOfflineRecord, error) { + coll := db.Collection(database.UserOfflineRecordName) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "delete_user_deadline", Value: 1}}, + }, + } + if _, err := coll.Indexes().CreateMany(context.Background(), indexes); err != nil { + return nil, errs.Wrap(err) + } + return &userOfflineRecordMgo{coll: coll}, nil +} + +type userOfflineRecordMgo struct { + coll *mongo.Collection +} + +// Upsert 写入用户的离线记录;若记录已存在则不覆盖($setOnInsert), +// 保留最早一次的全离线时刻作为计时起点。 +// deadline = offlineTime + delete_account_interval,供范围查询快速定位过期账号。 +func (u *userOfflineRecordMgo) Upsert(ctx context.Context, userID string, offlineTime, deadline time.Time) error { + filter := bson.M{"user_id": userID} + update := bson.M{ + "$setOnInsert": bson.M{ + "user_id": userID, + "offline_time": offlineTime, + "delete_user_deadline": deadline, + }, + } + opt := options.Update().SetUpsert(true) + _, err := u.coll.UpdateOne(ctx, filter, update, opt) + return errs.Wrap(err) +} + +// RefreshOfflineTime 将离线记录的 offline_time 与 delete_user_deadline 同时覆盖写为新值($set), +// 仅更新已存在的记录;用户在线时(无记录)不做任何操作。 +// 适用场景:用户修改 delete_account_interval,让倒计时从设置时刻重新起算。 +func (u *userOfflineRecordMgo) RefreshOfflineTime(ctx context.Context, userID string, newOfflineTime, newDeadline time.Time) error { + filter := bson.M{"user_id": userID} + update := bson.M{"$set": bson.M{ + "offline_time": newOfflineTime, + "delete_user_deadline": newDeadline, + }} + _, err := u.coll.UpdateOne(ctx, filter, update) + return errs.Wrap(err) +} + +// Delete 删除用户的离线记录(用户重新上线时调用,停止计时)。 +func (u *userOfflineRecordMgo) Delete(ctx context.Context, userID string) error { + _, err := u.coll.DeleteOne(ctx, bson.M{"user_id": userID}) + return errs.Wrap(err) +} + +// FindExpiredUsers 返回 delete_user_deadline <= now 的用户。 +// 通过 $lookup 联表 user 集合获取完整 *model.User,$unwind 同时起到过滤孤儿记录的作用 +// (若 user 文档已不存在,$unwind 会将其丢弃,避免对无效账号重复触发删除)。 +func (u *userOfflineRecordMgo) FindExpiredUsers(ctx context.Context, now time.Time, limit int) ([]*model.User, error) { + pipeline := bson.A{ + bson.M{"$match": bson.M{ + "delete_user_deadline": bson.M{"$lte": now}, + }}, + bson.M{"$limit": limit}, + bson.M{"$lookup": bson.M{ + "from": database.UserName, + "localField": "user_id", + "foreignField": "user_id", + "as": "u", + }}, + bson.M{"$unwind": "$u"}, + bson.M{"$replaceRoot": bson.M{"newRoot": "$u"}}, + } + return mongoutil.Aggregate[*model.User](ctx, u.coll, pipeline) +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 08f8aa6c3..7d74142ce 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -24,4 +24,5 @@ const ( SignalRecordName = "signal_record" SpamReportName = "spam_report" MsgBurnDeadlineName = "msg_burn_deadline" + UserOfflineRecordName = "user_offline_record" ) diff --git a/pkg/common/storage/database/user_offline_record.go b/pkg/common/storage/database/user_offline_record.go new file mode 100644 index 000000000..5ed1416aa --- /dev/null +++ b/pkg/common/storage/database/user_offline_record.go @@ -0,0 +1,28 @@ +package database + +import ( + "context" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" +) + +// UserOfflineRecord 管理 user_offline_record 集合。 +// 集合中的每条记录代表一个当前处于全平台离线状态的用户及其首次全离线时刻。 +type UserOfflineRecord interface { + // Upsert 写入用户的离线记录;若记录已存在则不覆盖(保留最早的离线时刻)。 + // deadline = offlineTime + delete_account_interval,供 FindExpiredUsers 快速过滤。 + Upsert(ctx context.Context, userID string, offlineTime, deadline time.Time) error + + // RefreshOfflineTime 将已存在的离线记录的 offline_time 与 delete_user_deadline + // 同时刷新,使删除倒计时从 newOfflineTime 重新起算。 + // 若记录不存在(用户在线)则无操作。 + RefreshOfflineTime(ctx context.Context, userID string, newOfflineTime, newDeadline time.Time) error + + // Delete 删除用户的离线记录(用户重新上线时调用)。 + Delete(ctx context.Context, userID string) error + + // FindExpiredUsers 返回 delete_user_deadline <= now 的用户($lookup user 集合获取完整信息)。 + // limit 限制单次返回条数,防止单批处理量过大。 + FindExpiredUsers(ctx context.Context, now time.Time, limit int) ([]*model.User, error) +} diff --git a/pkg/common/storage/model/user_offline_record.go b/pkg/common/storage/model/user_offline_record.go new file mode 100644 index 000000000..a09026ed2 --- /dev/null +++ b/pkg/common/storage/model/user_offline_record.go @@ -0,0 +1,14 @@ +package model + +import "time" + +// UserOfflineRecord 记录用户全平台离线的时刻及账号自动删除截止时间。 +// 用户上线时删除记录;用户全部平台离线时 upsert 记录。 +// crontask 每小时扫描此集合,删除 DeleteUserDeadline <= now 的账号。 +type UserOfflineRecord struct { + UserID string `bson:"user_id"` + OfflineTime time.Time `bson:"offline_time"` + // DeleteUserDeadline = OfflineTime + delete_account_interval(秒) + // 用户修改 delete_account_interval 时同步刷新此字段。 + DeleteUserDeadline time.Time `bson:"delete_user_deadline"` +} From 35371dde66c7be29888e4864d2cc71948a73e344 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 11 May 2026 15:50:07 +0800 Subject: [PATCH 37/39] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A418=E4=B8=AA=E6=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/openim-crontask.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/openim-crontask.yml b/config/openim-crontask.yml index ff69c7dc8..ccce6cfd7 100644 --- a/config/openim-crontask.yml +++ b/config/openim-crontask.yml @@ -1,4 +1,6 @@ cronExecuteTime: 0 2 * * * retainChatRecords: 365 fileExpireTime: 180 -deleteObjectType: ["msg-picture","msg-file", "msg-voice","msg-video","msg-video-snapshot","sdklog"] \ No newline at end of file +deleteObjectType: ["msg-picture","msg-file", "msg-voice","msg-video","msg-video-snapshot","sdklog"] +chatAPI: + address: http://127.0.0.1:10008 \ No newline at end of file From bff3d4994a955a210db45d49b79cd83ab61e7090 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 11 May 2026 16:36:13 +0800 Subject: [PATCH 38/39] =?UTF-8?q?=E9=9F=B3=E8=A7=86=E9=A2=91=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E8=AE=B0=E5=BD=95=EF=BC=8C=E4=BC=9A=E8=AF=9D=E4=B8=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rpc/rtc/signal.go | 1 + pkg/msgprocessor/options.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index 393f1f2bc..a46aa3f27 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -627,6 +627,7 @@ func signalingMsgOptions() map[string]bool { // IsNotNotification=false 表示"这是通知消息",让 IsNotificationByMsg 返回 true // 从而跳过 modifyMessageByUserMessageReceiveOpt 中的黑名单/好友关系等校验 datautil.SetSwitchFromOptions(opts, constant.IsNotNotification, false) + datautil.SetSwitchFromOptions(opts, constant.IsSendMsg, false) datautil.SetSwitchFromOptions(opts, constant.IsHistory, false) datautil.SetSwitchFromOptions(opts, constant.IsPersistent, false) datautil.SetSwitchFromOptions(opts, constant.IsUnreadCount, false) diff --git a/pkg/msgprocessor/options.go b/pkg/msgprocessor/options.go index 406145ef4..c1c1bc662 100644 --- a/pkg/msgprocessor/options.go +++ b/pkg/msgprocessor/options.go @@ -44,7 +44,7 @@ func NewOptions(opts ...OptionsOpt) Options { func NewMsgOptions() Options { options := make(map[string]bool, 11) options[constant.IsOfflinePush] = false - return make(map[string]bool) + return options } func WithOptions(options Options, opts ...OptionsOpt) Options { From c503011be6263123080d86a077d192f0e5084e3e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 11 May 2026 17:03:08 +0800 Subject: [PATCH 39/39] BusyLineUserIDList --- internal/rpc/rtc/signal.go | 60 +++++++++++++++++------ pkg/common/storage/controller/rtc.go | 6 +++ pkg/common/storage/database/mgo/signal.go | 38 ++++++++++++++ pkg/common/storage/database/signal.go | 3 ++ 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index a46aa3f27..be4b8ea50 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -108,6 +108,17 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, } } + // 检测哪些被叫用户正忙(已在通话中),记录到 BusyLineUserIDList + busyUserIDs, err := s.db.GetBusyUserIDs(ctx, inv.InviteeUserIDList) + if err != nil { + log.ZWarn(ctx, "handleInvite: GetBusyUserIDs failed (non-fatal)", err) + } + busySet := make(map[string]struct{}, len(busyUserIDs)) + for _, uid := range busyUserIDs { + busySet[uid] = struct{}{} + } + inv.BusyLineUserIDList = busyUserIDs + // 从主叫用户资料获取铃声 URL,注入到邀请信息中,被叫方收到后播放主叫方铃声 if inviterInfo, err := s.userClient.GetUserInfo(ctx, req.UserID); err == nil && inviterInfo.CallRingtoneURL != "" { inv.CallerRingtoneURL = inviterInfo.CallRingtoneURL @@ -151,6 +162,10 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, } for _, inviteeID := range inv.InviteeUserIDList { + if _, busy := busySet[inviteeID]; busy { + log.ZInfo(ctx, "handleInvite: skip busy invitee", "inviteeID", inviteeID) + continue + } 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.ZError(ctx, "sendSignalingNotification to invitee failed", err, "inviteeID", inviteeID) @@ -160,10 +175,11 @@ func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, log.ZDebug(ctx, "handleInvite", "token", token, "roomID", inv.RoomID, "liveURL", s.config.RpcConfig.LiveKit.ExternalAddress) return &rtc.SignalInviteResp{ - Token: token, - RoomID: inv.RoomID, - LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, - CalleeRingtoneURL: calleeRingtoneURL, + Token: token, + RoomID: inv.RoomID, + LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + BusyLineUserIDList: busyUserIDs, + CalleeRingtoneURL: calleeRingtoneURL, }, nil } @@ -178,6 +194,17 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi inv.InviterUserID = req.UserID inv.InitiateTime = time.Now().UnixMilli() + // 检测哪些被叫用户正忙(已在通话中),记录到 BusyLineUserIDList + busyUserIDs, err := s.db.GetBusyUserIDs(ctx, inv.InviteeUserIDList) + if err != nil { + log.ZWarn(ctx, "handleInviteInGroup: GetBusyUserIDs failed (non-fatal)", err) + } + busySet := make(map[string]struct{}, len(busyUserIDs)) + for _, uid := range busyUserIDs { + busySet[uid] = struct{}{} + } + inv.BusyLineUserIDList = busyUserIDs + // 从主叫用户资料获取铃声 URL,注入到邀请信息中,被叫方收到后播放主叫方铃声 if inviterInfo, err := s.userClient.GetUserInfo(ctx, req.UserID); err == nil && inviterInfo.CallRingtoneURL != "" { inv.CallerRingtoneURL = inviterInfo.CallRingtoneURL @@ -227,16 +254,21 @@ func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInvi log.ZInfo(ctx, "handleInviteInGroup: skipping invitee (call setting blocked)", "inviteeID", inviteeID) continue } + if _, busy := busySet[inviteeID]; busy { + log.ZInfo(ctx, "handleInviteInGroup: skip busy invitee", "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) } } return &rtc.SignalInviteInGroupResp{ - Token: token, - RoomID: inv.RoomID, - LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, - CalleeRingtoneURL: calleeRingtoneURL, + Token: token, + RoomID: inv.RoomID, + LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress, + BusyLineUserIDList: busyUserIDs, + CalleeRingtoneURL: calleeRingtoneURL, }, nil } @@ -297,12 +329,12 @@ func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, 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) - } - } + // 接受邀请后不删除 invitation:通话仍在进行,双方应被标记为忙线(BusyLineUserIDList)。 + // invitation 的清理由以下路径负责: + // - 主动挂断:handleHungUp → DeleteInvitation + // - 主叫取消:handleCancel → DeleteInvitation + // - 被叫拒绝:handleReject → DeleteInvitation / RemoveInvitee + // - 异常中断:MongoDB TTL 索引(expire_at 字段)自动清理 return &rtc.SignalAcceptResp{ Token: token, diff --git a/pkg/common/storage/controller/rtc.go b/pkg/common/storage/controller/rtc.go index 75b5d83f4..8e0e46557 100644 --- a/pkg/common/storage/controller/rtc.go +++ b/pkg/common/storage/controller/rtc.go @@ -31,6 +31,8 @@ type RtcDatabase interface { RemoveInvitee(ctx context.Context, roomID string, userID string) error GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) GetInvitationsByRoomIDs(ctx context.Context, roomIDs []string) ([]*model.SignalInvitation, error) + // GetBusyUserIDs returns the subset of userIDs that are currently in an active call. + GetBusyUserIDs(ctx context.Context, userIDs []string) ([]string, error) CreateRecord(ctx context.Context, record *model.SignalRecord) error SearchRecords(ctx context.Context, sendID, recvID string, sessionType int32, startTime, endTime int64, pagination pagination.Pagination) (int64, []*model.SignalRecord, error) @@ -73,6 +75,10 @@ func (r *rtcDatabase) GetInvitationsByRoomIDs(ctx context.Context, roomIDs []str return r.db.GetInvitationsByRoomIDs(ctx, roomIDs) } +func (r *rtcDatabase) GetBusyUserIDs(ctx context.Context, userIDs []string) ([]string, error) { + return r.db.GetBusyUserIDs(ctx, userIDs) +} + func (r *rtcDatabase) CreateRecord(ctx context.Context, record *model.SignalRecord) error { return r.db.CreateRecord(ctx, record) } diff --git a/pkg/common/storage/database/mgo/signal.go b/pkg/common/storage/database/mgo/signal.go index 4a84b2cc9..0eb7f8b0e 100644 --- a/pkg/common/storage/database/mgo/signal.go +++ b/pkg/common/storage/database/mgo/signal.go @@ -117,6 +117,44 @@ func (s *signalMgo) GetInvitationsByRoomIDs(ctx context.Context, roomIDs []strin return mongoutil.Find[*model.SignalInvitation](ctx, s.invColl, bson.M{"room_id": bson.M{"$in": roomIDs}}) } +func (s *signalMgo) GetBusyUserIDs(ctx context.Context, userIDs []string) ([]string, error) { + if len(userIDs) == 0 { + return nil, nil + } + filter := bson.M{ + "$or": bson.A{ + bson.M{"inviter_user_id": bson.M{"$in": userIDs}}, + bson.M{"invitee_user_id_list": bson.M{"$in": userIDs}}, + }, + } + invitations, err := mongoutil.Find[*model.SignalInvitation](ctx, s.invColl, filter, + options.Find().SetProjection(bson.M{"inviter_user_id": 1, "invitee_user_id_list": 1}), + ) + if err != nil { + return nil, err + } + requested := make(map[string]struct{}, len(userIDs)) + for _, uid := range userIDs { + requested[uid] = struct{}{} + } + busySet := make(map[string]struct{}) + for _, inv := range invitations { + if _, ok := requested[inv.InviterUserID]; ok { + busySet[inv.InviterUserID] = struct{}{} + } + for _, uid := range inv.InviteeUserIDList { + if _, ok := requested[uid]; ok { + busySet[uid] = struct{}{} + } + } + } + busy := make([]string, 0, len(busySet)) + for uid := range busySet { + busy = append(busy, uid) + } + return busy, nil +} + func (s *signalMgo) CreateRecord(ctx context.Context, record *model.SignalRecord) error { return mongoutil.InsertMany(ctx, s.recColl, []*model.SignalRecord{record}) } diff --git a/pkg/common/storage/database/signal.go b/pkg/common/storage/database/signal.go index 24b83dcbc..dbc24834b 100644 --- a/pkg/common/storage/database/signal.go +++ b/pkg/common/storage/database/signal.go @@ -38,6 +38,9 @@ type SignalDatabase interface { GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) // GetInvitationsByRoomIDs retrieves invitations for the given room IDs. GetInvitationsByRoomIDs(ctx context.Context, roomIDs []string) ([]*model.SignalInvitation, error) + // GetBusyUserIDs returns the subset of userIDs that are currently involved in an active call + // (either as inviter or as invitee in a pending invitation). + GetBusyUserIDs(ctx context.Context, userIDs []string) ([]string, error) // CreateRecord stores a completed call record. CreateRecord(ctx context.Context, record *model.SignalRecord) error