feat: 新增群组成员数量统计功能并在接口中返回

实现了群组成员数量统计的接口,包含数据库层统计逻辑,在群组详情、设置等接口中返回实时成员数,同时修复了测试用例中参数空格问题。
This commit is contained in:
Austin
2026-10-13 17:58:05 +08:00
parent 43808c36d1
commit e8804883fc
7 changed files with 120 additions and 14 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ func TestAdminInternalClientLatestAppRelease(t *testing.T) {
config: adminInternalAPIConfig{BaseURL: server.URL, ServiceName: "im-server", Secret: "test-secret"},
http: server.Client(),
}
release, err := client.LatestAppRelease(" IOS ", "STABLE")
release, err := client.LatestAppRelease(" IM ", " IOS ", "STABLE")
if err != nil {
t.Fatal(err)
}
+1
View File
@@ -405,6 +405,7 @@ type TenantBusinessAdapter interface {
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
TenantGroupMembersCount(t.TenantID, []string) (map[string]int64, error)
TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error)
TenantUsersShareOpenPrivateGroup(t.TenantID, t.Uid, t.Uid) (bool, error)
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
+44
View File
@@ -394,6 +394,50 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
return items, next, rows.Err()
}
// TenantGroupMembersCount counts active members for the given topics using the
// same visibility rules as TenantGroupMemberList: membership active, subscription
// not deleted, user not deleted. Returns counts keyed by topic; topics with no
// active members are absent from the map.
func (a *adapter) TenantGroupMembersCount(id t.TenantID, topics []string) (map[string]int64, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupMembersCount(id, topics)
}
if len(topics) == 0 {
return map[string]int64{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(topics)), ",")
args := make([]any, 0, len(topics)+3)
args = append(args, t.StateDeleted, id, t.GroupMemberStatusActive)
for _, topic := range topics {
args = append(args, topic)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryContext(ctx, tenantShardSQL(id, `SELECT gm.topic, COUNT(DISTINCT gm.user_id) FROM im_group_members AS gm
JOIN im_subscriptions AS s ON s.tenant_id=gm.tenant_id AND s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.tenant_id=gm.tenant_id AND u.id=gm.user_id AND u.state!=?
WHERE gm.tenant_id=? AND gm.member_status=? AND gm.topic IN (`+placeholders+`)
GROUP BY gm.topic`), args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := make(map[string]int64, len(topics))
for rows.Next() {
var topic string
var count int64
if err = rows.Scan(&topic, &count); err != nil {
return nil, err
}
counts[topic] = count
}
return counts, rows.Err()
}
func (a *adapter) TenantUsersShareOpenPrivateGroup(id t.TenantID, userID, peerID t.Uid) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
+42 -11
View File
@@ -4217,6 +4217,25 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
items = append(items, item)
seen++
}
if len(items) > 0 {
listTopics := make([]string, 0, len(items))
for _, item := range items {
if topic, ok := item["topic"].(string); ok && topic != "" {
listTopics = append(listTopics, topic)
}
}
if counts, err := store.GroupBusiness.MembersCount(principal.TenantID, listTopics); err == nil {
for _, item := range items {
if topic, ok := item["topic"].(string); ok {
if count, ok := counts[topic]; ok {
item["member_count"] = count
}
}
}
} else {
logs.Warn.Printf("serveIMGroupList: failed to count members for %d topics: %v", len(listTopics), err)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
@@ -4310,7 +4329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub, groupMemberCountFor(principal.TenantID, topicName)))
}
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
@@ -4607,7 +4626,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
payload := groupDetailPayload(group, selfSub)
payload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
if settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
@@ -4703,7 +4722,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupDetailPayload(updated, selfSub)
payload := groupDetailPayload(updated, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
@@ -4769,7 +4788,7 @@ func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic stri
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupSettingsPayload(group, selfSub)
payload := groupSettingsPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
@@ -4946,7 +4965,7 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
groupPayload := groupDetailPayload(group, selfSub)
groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
@@ -6088,7 +6107,7 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
status = "joined"
}
groupPayload := groupDetailPayload(group, selfSub)
groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, claims.Topic))
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
@@ -7505,15 +7524,27 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group
return payload
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
// groupMemberCountFor returns the live member count for a single group topic,
// mirroring the visibility rules of the group members list endpoint.
func groupMemberCountFor(tenantID types.TenantID, topic string) int64 {
if topic == "" {
return 0
}
counts, err := store.GroupBusiness.MembersCount(tenantID, []string{topic})
if err != nil {
logs.Warn.Printf("group[%s]: failed to count members: %v", topic, err)
return 0
}
return counts[topic]
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any {
public := map[string]any{}
topic := ""
memberCount := 0
ownerID := ""
if group != nil {
public = groupPublicMap(group.Public)
topic = group.Id
memberCount = group.SubCnt
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
ownerID = owner.UserId()
}
@@ -7542,8 +7573,8 @@ func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[str
}
}
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
data := groupDetailPayload(group, selfSub)
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any {
data := groupDetailPayload(group, selfSub, memberCount)
private := map[string]any{}
if selfSub != nil {
private = groupPrivateMap(selfSub.Private)
+9 -1
View File
@@ -273,6 +273,7 @@ type testGroupBusinessStore struct {
listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions
listMembersNext string
membersCounts map[string]int64
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode
@@ -354,6 +355,13 @@ func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts ty
return s.listMembers, s.listMembersNext, nil
}
func (s *testGroupBusinessStore) MembersCount(_ types.TenantID, _ []string) (map[string]int64, error) {
if s.membersCounts == nil {
return map[string]int64{}, nil
}
return s.membersCounts, nil
}
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if s.updateMemberErr != nil {
return nil, s.updateMemberErr
@@ -730,7 +738,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
return privacy, nil
}
func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) {
func (s *testAppReleaseStore) Latest(_, platform, channel string) (*types.AppRelease, error) {
if s.err != nil {
return nil, s.err
}
@@ -37,7 +37,7 @@ func (p localCryptoProvider) RegistrationSMSEnabled() (bool, error) {
func (p localCryptoProvider) ThirdPartyConfig(string, ...string) (map[string]string, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) LatestAppRelease(string, string) (*types.AppRelease, error) {
func (p localCryptoProvider) LatestAppRelease(string, string, string) (*types.AppRelease, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationProvider() (*types.ModerationProviderConfig, error) {
+22
View File
@@ -1892,6 +1892,7 @@ type GroupBusinessPersistenceInterface interface {
UpsertMember(types.TenantID, *types.GroupMember) (*types.GroupMember, error)
GetMember(types.TenantID, string, types.Uid) (*types.GroupMember, error)
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
MembersCount(types.TenantID, []string) (map[string]int64, error)
UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error)
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error)
@@ -2064,6 +2065,27 @@ func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid
return member, err
}
func (groupBusinessMapper) MembersCount(tenantID types.TenantID, topics []string) (map[string]int64, error) {
cleaned := make([]string, 0, len(topics))
seen := make(map[string]bool, len(topics))
for _, topic := range topics {
topic = strings.TrimSpace(topic)
if topic == "" || seen[topic] {
continue
}
seen[topic] = true
cleaned = append(cleaned, topic)
}
if len(cleaned) == 0 {
return map[string]int64{}, nil
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupMembersCount(tenantID, cleaned)
}
func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
if strings.TrimSpace(topic) == "" {
return nil, "", types.ErrMalformed