From 693ce30ab13fb2b31d1c3607c30674363f7536ed Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 26 Aug 2026 12:26:53 +0800 Subject: [PATCH 1/2] fix: deduplicate direct member project names in convertModelUser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve Members→projects initial value for other callers; dedupe by project name defensively before ListUsers batch overwrite. Fixes actiontech/dms-ee#980 Co-authored-by: Cursor --- internal/dms/storage/convert.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/dms/storage/convert.go b/internal/dms/storage/convert.go index 6ace5ece..d69a91c5 100644 --- a/internal/dms/storage/convert.go +++ b/internal/dms/storage/convert.go @@ -255,9 +255,15 @@ func convertModelUser(u *model.User) (*biz.User, error) { } projects := make([]string, 0) + seenProjects := make(map[string]struct{}) for _, member := range u.Members { if member != nil && member.Project != nil { - projects = append(projects, member.Project.Name) + name := member.Project.Name + if _, ok := seenProjects[name]; ok { + continue + } + seenProjects[name] = struct{}{} + projects = append(projects, name) } } From ac19a7b9ce2dfeaaef303aabce3b588cb05e4eb6 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 26 Aug 2026 12:26:55 +0800 Subject: [PATCH 2/2] fix: align ListUsers projects with direct members and member groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch UNION query (members ∪ member_group_users) fills projects per page without N+1 GetUserProject calls; semantics match GetUserProject. Fixes actiontech/dms-ee#980 Co-authored-by: Cursor --- internal/dms/storage/user.go | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/internal/dms/storage/user.go b/internal/dms/storage/user.go index 65f16566..83eb892e 100644 --- a/internal/dms/storage/user.go +++ b/internal/dms/storage/user.go @@ -189,9 +189,82 @@ func (d *UserRepo) ListUsers(ctx context.Context, opt *biz.ListUsersOption) (use } users = append(users, ds) } + + // Batch fill projects = direct members ∪ member groups (same semantics as GetUserProject; no N+1). + uids := make([]string, 0, len(users)) + for _, u := range users { + if u != nil && u.UID != "" { + uids = append(uids, u.UID) + } + } + projectNamesByUID, err := d.listProjectNamesByUserUIDs(ctx, uids) + if err != nil { + return nil, 0, pkgErr.WrapStorageErr(d.log, err) + } + for _, u := range users { + if u == nil { + continue + } + if names, ok := projectNamesByUID[u.UID]; ok { + u.Projects = names + } else { + u.Projects = []string{} + } + } return users, total, nil } +type userProjectNameRow struct { + UserUID string `gorm:"column:user_uid"` + ProjectName string `gorm:"column:project_name"` +} + +// listProjectNamesByUserUIDs returns project names per user UID in one query +// (members UNION member_groups via member_group_users), matching GetUserProject set semantics. +func (d *UserRepo) listProjectNamesByUserUIDs(ctx context.Context, uids []string) (map[string][]string, error) { + result := make(map[string][]string) + if len(uids) == 0 { + return result, nil + } + + var rows []userProjectNameRow + if err := transaction(d.log, ctx, d.db, func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Raw(` + SELECT m.user_uid AS user_uid, n.name AS project_name + FROM projects n + JOIN members m ON n.uid = m.project_uid + WHERE m.user_uid IN (?) + UNION + SELECT mgu.user_uid AS user_uid, n.name AS project_name + FROM projects n + JOIN member_groups mg ON n.uid = mg.project_uid + JOIN member_group_users mgu ON mgu.member_group_uid = mg.uid + WHERE mgu.user_uid IN (?) + `, uids, uids).Scan(&rows).Error; err != nil { + return fmt.Errorf("failed to list project names by user uids: %v", err) + } + return nil + }); err != nil { + return nil, err + } + + seen := make(map[string]map[string]struct{}, len(uids)) + for _, row := range rows { + if row.UserUID == "" || row.ProjectName == "" { + continue + } + if _, ok := seen[row.UserUID]; !ok { + seen[row.UserUID] = make(map[string]struct{}) + } + if _, dup := seen[row.UserUID][row.ProjectName]; dup { + continue + } + seen[row.UserUID][row.ProjectName] = struct{}{} + result[row.UserUID] = append(result[row.UserUID], row.ProjectName) + } + return result, nil +} + func (d *UserRepo) CountUsers(ctx context.Context, opts []constant.FilterCondition) (total int64, err error) { if err := transaction(d.log, ctx, d.db, func(tx *gorm.DB) error { // find total