fix: 优化群成员分页并补齐头像资料

This commit is contained in:
richjohnrambo
2026-09-09 09:38:20 +08:00
parent aae13c9265
commit 9ac2b412cc
7 changed files with 167 additions and 61 deletions
+9 -3
View File
@@ -3340,8 +3340,11 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
// Fetch all subscribed users. The number of users is not large.
q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private,
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
FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid
WHERE s.topic=?`
args := []any{topic}
if !keepDeleted {
@@ -3394,18 +3397,21 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
var lastSeen sql.NullTime
var userAgent string
var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
for rows.Next() {
if err = rows.Scan(
&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
&sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
&public, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
break
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(common.FromJSON(public))
sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
+9 -3
View File
@@ -1077,8 +1077,11 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
}
tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private,
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
FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid
WHERE s.tenant_id=? AND s.topic=?`
args := []any{id, topic}
if !keepDeleted {
@@ -1119,14 +1122,17 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
var sub t.Subscription
var lastSeen sql.NullTime
var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
&public, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(common.FromJSON(public))
sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
+45 -11
View File
@@ -248,30 +248,49 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 30
}
where := []string{"topic=?", "member_status=?"}
memberIndex := "idx_im_group_members_topic_status"
where := []string{"gm.topic=?", "gm.member_status=?"}
args := []any{topic, t.GroupMemberStatusActive}
if opts.Role != "" {
where = append(where, "role=?")
memberIndex = "idx_im_group_members_topic_role"
where = append(where, "gm.role=?")
args = append(args, opts.Role)
}
if opts.Query != "" {
where = append(where, "nickname LIKE ?")
args = append(args, "%"+opts.Query+"%")
like := "%" + opts.Query + "%"
where = append(where, `(gm.nickname LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')) LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')) LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')) LIKE ?
OR u.username LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
}
if opts.Cursor != "" {
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
if err != nil || parsed <= 0 {
if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id>?")
args = append(args, parsed)
if parsed > 0 {
where = append(where, "gm.id>?")
args = append(args, parsed)
}
}
if opts.Mentionable {
where = append(where, "INSTR(s.modewant,'J')>0 AND INSTR(s.modegiven,'J')>0")
}
args = append(args, opts.Limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_members WHERE "+strings.Join(where, " AND ")+" ORDER BY id LIMIT ?", args...)
rows, err := a.db.QueryxContext(ctx, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,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
FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`)
JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.id=gm.user_id AND u.state!=?
LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`, append([]any{t.StateDeleted}, args...)...)
if err != nil {
return nil, "", err
}
@@ -279,15 +298,30 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
items := make([]t.GroupMember, 0, opts.Limit)
next := ""
for rows.Next() {
var item t.GroupMember
if err = rows.StructScan(&item); err != nil {
row := struct {
t.GroupMember
Public any
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
item := row.GroupMember
scanGroupMember(&item)
if len(items) == opts.Limit {
next = strconv.FormatInt(item.ID, 10)
if len(items) > 0 {
next = strconv.FormatInt(items[len(items)-1].ID, 10)
}
continue
}
item.Public = profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
item.Trusted = common.FromJSON(row.Trusted)
items = append(items, item)
}
return items, next, rows.Err()
+53 -29
View File
@@ -4807,8 +4807,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
if cursor != "" {
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
}
if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
@@ -4822,25 +4828,22 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{
Role: roleFilter,
EligibleRole: eligibleRole,
Query: query,
Cursor: cursor,
Limit: limit,
Mentionable: mentionableOnly,
})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, member := range members {
access := member.ModeGiven & member.ModeWant
mentionable := access.IsJoiner() && access.IsReader()
role := groupMemberRole(access)
if roleFilter != "" && role != roleFilter {
continue
}
if mentionableOnly && !mentionable {
continue
}
item := groupMemberPayload(member, mentionable)
access := groupMemberAccess(member)
item := groupBusinessMemberPayload(member)
if eligibleRole != "" {
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
item["eligible"] = eligible
@@ -4855,19 +4858,7 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
item["ineligible_reason"] = reason
}
}
if query != "" && !groupMemberMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
@@ -7320,6 +7311,38 @@ func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any
return data
}
func groupBusinessMemberPayload(member types.GroupMember) map[string]any {
access := groupMemberAccess(member)
user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted}
data := userSummaryPayload(user, "")
if nickname := strings.TrimSpace(member.Nickname); nickname != "" {
data["group_nickname"] = nickname
}
data["role"] = groupMemberRole(access)
data["mentionable"] = access.IsJoiner() && access.IsReader()
data["acs"] = map[string]any{
"mode": access.String(),
"want": member.ModeWant.String(),
"given": member.ModeGiven.String(),
}
return data
}
func groupMemberAccess(member types.GroupMember) types.AccessMode {
access := member.ModeGiven & member.ModeWant
if access != types.ModeNone {
return access
}
switch member.Role {
case types.GroupMemberRoleOwner:
return types.ModeCFull
case types.GroupMemberRoleAdmin:
return types.ModeCPublic | types.ModeApprove
default:
return types.ModeCPublic
}
}
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true)
data["topic"] = topic
@@ -7771,16 +7794,17 @@ func groupMemberMatches(item map[string]any, query string) bool {
}
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
limit int, cursor string, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
"v3",
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strings.TrimSpace(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
+31 -13
View File
@@ -345,6 +345,9 @@ type testGroupBusinessStore struct {
mutes []types.GroupMute
files []types.GroupFile
invitations []types.GroupInvitation
listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions
listMembersNext string
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode
@@ -402,8 +405,9 @@ func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid t
return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil
}
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, _ types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
return nil, "", nil
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
s.listMembersOpts = opts
return s.listMembers, s.listMembersNext, nil
}
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
@@ -3877,14 +3881,14 @@ func TestServeIMGroupMembers(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
groupStore := &testGroupBusinessStore{listMembersNext: "101"}
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.GroupBusiness = oldGroupBusiness
}()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{
@@ -3894,13 +3898,18 @@ func TestServeIMGroupMembers(t *testing.T) {
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
alice.SetPublic(map[string]any{"display_name": "Alice"})
bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin}
bob.SetPublic(map[string]any{"display_name": "Bob"})
topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
groupStore.listMembers = []types.GroupMember{{
ID: 101,
Topic: "grpMention",
UserID: types.Uid(101),
Role: types.GroupMemberRoleMember,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
Public: map[string]any{"display_name": "Alice", "avatar_file_id": "alice.jpg"},
MemberStatus: types.GroupMemberStatusActive,
}}
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true&limit=20&cursor=99", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -3911,12 +3920,21 @@ func TestServeIMGroupMembers(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true {
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" ||
items[0].(map[string]any)["avatar_file_id"] != "alice.jpg" ||
items[0].(map[string]any)["mentionable"] != true {
t.Fatalf("unexpected members response: %#v", data)
}
if data["next_cursor"] != "101" {
t.Fatalf("unexpected cursor: %#v", data)
}
if data["permissions"].(map[string]any)["can_mention_all"] != true {
t.Fatalf("expected can_mention_all permission: %#v", data)
}
if groupStore.listMembersOpts.Query != "ali" || !groupStore.listMembersOpts.Mentionable ||
groupStore.listMembersOpts.Limit != 20 || groupStore.listMembersOpts.Cursor != "99" {
t.Fatalf("members options were not forwarded: %#v", groupStore.listMembersOpts)
}
}
func TestGroupMemberPayloadUserIDFormats(t *testing.T) {
+15 -2
View File
@@ -2904,7 +2904,7 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100)
if err != nil {
logs.Warn.Printf("profile %s: refresh friend subscription projections failed: %v", uid, err)
return
break
}
for _, item := range items {
if item.Friendship.Topic == "" || item.Friendship.PeerID.IsZero() {
@@ -2915,10 +2915,23 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
}
}
if nextCursor == "" {
return
break
}
cursor = nextCursor
}
if topics, err := Users.GetTopics(tenantID, uid, &types.QueryOpt{Limit: 1000}); err != nil {
logs.Warn.Printf("profile %s: refresh group member projections failed: %v", uid, err)
} else {
for _, sub := range topics {
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
continue
}
if err := Subs.Update(tenantID, sub.Topic, uid, map[string]any{"Public": public}); err != nil {
logs.Warn.Printf("profile %s: refresh group subscription %s public failed: %v", uid, sub.Topic, err)
}
invalidateGroupMemberPages(tenantID, sub.Topic)
}
}
}
func userProfilePublicProjection(profile *types.UserProfile) map[string]any {
+5
View File
@@ -73,6 +73,10 @@ type GroupMember struct {
Role string `db:"role"`
MemberStatus string `db:"member_status"`
Nickname string `db:"nickname"`
ModeWant AccessMode `db:"modewant"`
ModeGiven AccessMode `db:"modegiven"`
Public any `db:"public"`
Trusted any `db:"trusted"`
Pinned bool `db:"pinned"`
MuteNotifications bool `db:"mute_notifications"`
JoinSource string `db:"join_source"`
@@ -105,6 +109,7 @@ type GroupMemberListOptions struct {
Query string
Cursor string
Limit int
Mentionable bool
}
type GroupInvitation struct {