From d107fba8bf4b34d623efdd83ffeb97372a909db7 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 13 Sep 2026 10:06:52 +0800 Subject: [PATCH] fix: align IM client interaction edge cases --- server/db/mysql/adapter.go | 11 +++- server/db/mysql/tenant_adapter.go | 19 +++++- server/im_http.go | 98 ++++++++++++++++++++++++---- server/push/jpush/push_jpush.go | 6 +- server/push/jpush/push_jpush_test.go | 24 +++++++ 5 files changed, 137 insertions(+), 21 deletions(-) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index b63de30..096acf4 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -3432,9 +3432,13 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( if len(usrq) > 0 { q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, - p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at - FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)` - q, args, _ = sqlx.In(q, usrq) + p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at, + fs.remark AS friendship_remark + FROM im_users AS u + LEFT JOIN im_user_profiles AS p ON p.user_id=u.id + LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=? + WHERE u.id IN (?)` + q, args, _ = sqlx.In(q, store.DecodeUid(uid), t.RelationshipFriend, usrq) if !keepDeleted { // Optionally skip deleted users. q += " AND u.state!=?" @@ -3466,6 +3470,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( sub.SetTrusted(common.FromJSON(usr2.Trusted)) sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon) sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent) + sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(usr2.ID).UserId(), usr2.FriendshipRemark.String) join[joinOn] = sub p2pResolved[joinOn] = true } diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 06347f7..cef1606 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -68,6 +68,7 @@ type profileUserRow struct { ProfileRegion sql.NullString `db:"profile_region"` ProfilePublicAccount sql.NullString `db:"profile_public_account"` ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"` + FriendshipRemark sql.NullString `db:"friendship_remark"` } func (row profileUserRow) effectiveUpdatedAt() time.Time { @@ -151,6 +152,10 @@ func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender } func ensureP2PPrivatePeer(private any, peerID string) any { + return ensureP2PPrivatePeerRemark(private, peerID, "") +} + +func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any { if strings.TrimSpace(peerID) == "" { return private } @@ -161,6 +166,9 @@ func ensureP2PPrivatePeer(private any, peerID string) any { if _, ok := data["peer"]; !ok { data["peer"] = peerID } + if remark = strings.TrimSpace(remark); remark != "" { + data["remark"] = remark + } return data } @@ -1050,9 +1058,13 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool if len(usrq) > 0 { q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, - p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at - FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)` - q, args, _ = sqlx.In(q, id, usrq) + p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at, + fs.remark AS friendship_remark + FROM im_users AS u + LEFT JOIN im_user_profiles AS p ON p.user_id=u.id + LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=? + WHERE u.tenant_id=? AND u.id IN (?)` + q, args, _ = sqlx.In(q, decodeUID(uid), t.RelationshipFriend, id, usrq) if !keepDeleted { q += " AND u.state!=?" args = append(args, t.StateDeleted) @@ -1075,6 +1087,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool sub.SetTrusted(common.FromJSON(user.Trusted)) sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon) sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent) + sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(user.ID).UserId(), user.FriendshipRemark.String) join[joinOn] = sub p2pResolved[joinOn] = true } diff --git a/server/im_http.go b/server/im_http.go index 2e24568..eff4a51 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -45,6 +45,7 @@ const ( imReasonRealNameRequired = "REALNAME_REQUIRED" imReasonActionToken = "ACTION_TOKEN_INVALID" imReasonPermissionDenied = "PERMISSION_DENIED" + imReasonForbidden = "FORBIDDEN" imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED" imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED" imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT" @@ -587,6 +588,7 @@ type imSystemMessageAction struct { Type string `json:"type"` Label string `json:"label,omitempty"` TargetID string `json:"target_id,omitempty"` + MinRole string `json:"min_role,omitempty"` } func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { @@ -4430,15 +4432,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { return } if tail == "messages" { - if req.Method != http.MethodGet { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - if topic == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) - return - } - writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false}) + serveIMConversationMessages(wrt, req, principal, topic) return } if tail != "context" { @@ -4466,6 +4460,52 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } +func serveIMConversationMessages(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) { + if topic == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + topic = normalizeConversationSettingsTopic(topic, principal.Uid) + switch req.Method { + case http.MethodGet: + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false}) + case http.MethodDelete: + sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if sub == nil || !(sub.ModeGiven&sub.ModeWant).IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonForbidden, "permission denied", nil) + return + } + top, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if top == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "conversation not found", nil) + return + } + delID := top.DelId + 1 + if delID <= 0 { + delID = int(time.Now().Unix()) + } + var ranges []types.Range + if top.SeqId > 0 { + ranges = []types.Range{{Low: 1, Hi: top.SeqId + 1}} + } + if err = store.Messages.DeleteList(principal.TenantID, topic, delID, principal.Uid, 0, ranges); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "cleared": true, "deleted_id": delID}) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) { if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) @@ -4904,7 +4944,7 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { serveIMGroupOwnerTransfer(wrt, req, topic) return } - if tail == "mutes" || strings.HasPrefix(tail, "mutes/") { + if tail == "mutes" || tail == "mutes/batch" || strings.HasPrefix(tail, "mutes/") { serveIMGroupMutes(wrt, req, topic, tail) return } @@ -5928,9 +5968,24 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st []imSystemMessageActor{ {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, }, - []imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic}}, + []imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic, MinRole: "admin"}}, map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version}) } + if body.MemberPrivateChat != nil && imReliableHTTPEventsEnabled(req) { + template := "group_member_private_chat_disabled" + text := "群成员私信权限已关闭,群成员头像和名片不可点击查看。" + if *body.MemberPrivateChat { + template = "group_member_private_chat_enabled" + text = "群成员私信权限已开启,群成员头像和名片可点击查看。" + } + publishIMConversationSystemNotice(principal.TenantID, topic, + "system_notice", template, text, + []imSystemMessageActor{ + {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, + }, + nil, + map[string]any{"member_private_chat": *body.MemberPrivateChat, "version": settings.Version}) + } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) default: @@ -6086,7 +6141,7 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor}) - case tail == "mutes" && req.Method == http.MethodPost: + case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost: var body imGroupMuteBatchRequest if !decodeIMJSONBody(wrt, req, &body) { return @@ -6411,6 +6466,10 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier s return } if !imPrivacyAllowsProfile(privacy, ctx.Relationship) { + if ctx.Relationship == types.RelationshipBlockedByMe { + writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx)) + return + } writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil) return } @@ -9749,6 +9808,21 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st return data } +func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { + data := userSummaryPayload(user, ctx.Remark) + data["relationship"] = ctx.Relationship + data["topic"] = ctx.Topic + data["capabilities"] = map[string]any{ + "can_send": false, + "can_call": false, + "can_add_friend": false, + } + if ctx.Remark != "" { + data["remark"] = ctx.Remark + } + return data +} + func copyOptionalProfileFields(data map[string]any, public map[string]any) { for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} { if value, ok := public[key]; ok { diff --git a/server/push/jpush/push_jpush.go b/server/push/jpush/push_jpush.go index b9a9bbe..37e8a11 100644 --- a/server/push/jpush/push_jpush.go +++ b/server/push/jpush/push_jpush.go @@ -347,10 +347,10 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [ var targets []t.Uid for _, uid := range uids { recipient := rcpt.To[uid] - if recipient.Delivered > 0 { - continue - } userData := cloneData(data) + if recipient.Delivered > 0 { + userData["silent"] = "true" + } topic := rcpt.Payload.Topic var audience map[string][]string if useAlias { diff --git a/server/push/jpush/push_jpush_test.go b/server/push/jpush/push_jpush_test.go index f8e4509..c95debf 100644 --- a/server/push/jpush/push_jpush_test.go +++ b/server/push/jpush/push_jpush_test.go @@ -146,6 +146,30 @@ func TestPreparePayloadsCanUseRegistrationID(t *testing.T) { } } +func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) { + oldTokens := store.DevicePushTokens + uid := types.Uid(42) + store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{ + uid: {{DeviceId: "push-token", Platform: "ios"}}, + }} + defer func() { store.DevicePushTokens = oldTokens }() + useAlias := false + config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias} + payloads, _ := preparePayloads(&push.Receipt{ + TenantID: 7, + To: map[types.Uid]push.Recipient{ + uid: {Delivered: 1}, + }, + Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()}, + }, config) + if len(payloads) != 1 { + t.Fatalf("unexpected payload count: %d", len(payloads)) + } + if payloads[0].Message == nil || payloads[0].Message.Extras["silent"] != "true" { + t.Fatalf("expected silent payload for delivered recipient: %#v", payloads[0]) + } +} + func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) { config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60} payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{