feat: 新增群组成员数量统计功能并在接口中返回
实现了群组成员数量统计的接口,包含数据库层统计逻辑,在群组详情、设置等接口中返回实时成员数,同时修复了测试用例中参数空格问题。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user