fix: align IM client interaction edge cases

This commit is contained in:
richjohnrambo
2026-09-13 10:06:52 +08:00
parent e5fbca735d
commit d107fba8bf
5 changed files with 137 additions and 21 deletions
+8 -3
View File
@@ -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
}
+16 -3
View File
@@ -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
}
+86 -12
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+24
View File
@@ -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{