From 5c276596b81066b422702571d74a146d95e7fc5f Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 15:37:47 +0800 Subject: [PATCH] fix: support removing group members --- docs/IM-前后端对接文档.md | 1 + server/im_http.go | 41 ++++++++++++++++++++++++++++++++++- server/im_http_test.go | 45 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index fecb6c1..740d66a 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2185,6 +2185,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` | +| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` | | `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | | `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | | `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | diff --git a/server/im_http.go b/server/im_http.go index 184682f..ac5d7c8 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4138,7 +4138,7 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin } func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) { - if req.Method != http.MethodGet { + if req.Method != http.MethodGet && req.Method != http.MethodDelete { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } @@ -4163,6 +4163,20 @@ func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) return } + if req.Method == http.MethodDelete { + if ok, reason := canRemoveIMGroupMember(selfSub, principal.Uid, *member); !ok { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": memberID.UserId(), "reason": reason}) + return + } + if err := store.GroupBusiness.Leave(principal.TenantID, topic, memberID, time.Now().UTC().Round(time.Millisecond)); err != nil { + writeIMRelationError(wrt, req, err) + return + } + publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left", + map[string]any{"user_id": memberID.UserId(), "removed_by": principal.Uid.UserId(), "member_sync": "single"}) + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "user_id": memberID.UserId(), "status": "left"}) + return + } memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false) if err != nil { writeIMRelationError(wrt, req, err) @@ -7111,6 +7125,31 @@ func canMuteIMGroupMember(tenantID types.TenantID, topic string, selfSub *types. return true, "" } +func canRemoveIMGroupMember(selfSub *types.Subscription, actor types.Uid, target types.GroupMember) (bool, string) { + if actor.IsZero() || target.UserID.IsZero() { + return false, "invalid_user" + } + if actor == target.UserID { + return false, "self" + } + actorAccess := types.ModeNone + if selfSub != nil { + actorAccess = selfSub.ModeGiven & selfSub.ModeWant + } + if !actorAccess.IsAdmin() { + return false, "not_admin" + } + switch groupMemberRole(groupMemberAccess(target)) { + case "owner": + return false, "owner" + case "admin": + if !actorAccess.IsOwner() { + return false, "admin" + } + } + return true, "" +} + func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { data := groupMemberPayload(*sub, true, ctx.Remark) data["topic"] = topic diff --git a/server/im_http_test.go b/server/im_http_test.go index 9d63484..f17aceb 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -283,6 +283,8 @@ type testGroupBusinessStore struct { updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch updatedRoleMode types.AccessMode + leftTopic string + leftUID types.Uid updateSettingsErr error updateMemberErr error } @@ -546,7 +548,9 @@ func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, in return true, nil } -func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error { +func (s *testGroupBusinessStore) Leave(_ types.TenantID, topic string, uid types.Uid, _ time.Time) error { + s.leftTopic = topic + s.leftUID = uid return nil } @@ -3127,6 +3131,45 @@ func TestServeIMGroupMemberReturnsSingleSnapshot(t *testing.T) { } } +func TestServeIMGroupMemberDeleteRemovesMember(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldGroupBusiness := store.GroupBusiness + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Subs = subs + target := types.Uid(101) + groupStore := &testGroupBusinessStore{member: &types.GroupMember{ + ID: 101, Topic: "grpMention", UserID: target, Role: types.GroupMemberRoleMember, + MemberStatus: types.GroupMemberStatusActive, Version: 3, + }} + store.GroupBusiness = groupStore + t.Cleanup(func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }) + + ownerSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} + subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(ownerSub, nil) + + req := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpMention/members/"+target.UserId(), nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if groupStore.leftTopic != "grpMention" || groupStore.leftUID != target { + t.Fatalf("member was not removed: topic=%q uid=%s", groupStore.leftTopic, groupStore.leftUID.UserId()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["topic"] != "grpMention" || data["user_id"] != target.UserId() || data["status"] != "left" { + t.Fatalf("unexpected delete response: %#v", data) + } +} + func TestGroupMemberPayloadUserIDFormats(t *testing.T) { raw := types.Uid(101) fromRaw := groupMemberPayload(types.Subscription{