feat: display username and email in namespace member management

MemberResponse DTO now includes displayName and email fields.
NamespacePortalQueryAppService batch-loads UserAccount data via
findByIdIn to avoid N+1 queries. Frontend member table shows
username (with userId as fallback subtitle) and email columns
instead of raw user IDs.
This commit is contained in:
chenbaowang 2026-04-03 09:49:25 +08:00 committed by Rsweater
parent 010c1a4e46
commit 03ac86f19d
11 changed files with 84 additions and 9 deletions

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import java.time.Instant;
@ -9,6 +10,8 @@ public record MemberResponse(
Long id,
Long namespaceId,
String userId,
String displayName,
String email,
NamespaceRole role,
Instant createdAt,
Instant updatedAt
@ -18,6 +21,21 @@ public record MemberResponse(
member.getId(),
member.getNamespaceId(),
member.getUserId(),
null,
null,
member.getRole(),
member.getCreatedAt(),
member.getUpdatedAt()
);
}
public static MemberResponse from(NamespaceMember member, UserAccount user) {
return new MemberResponse(
member.getId(),
member.getNamespaceId(),
member.getUserId(),
user != null ? user.getDisplayName() : null,
user != null ? user.getEmail() : null,
member.getRole(),
member.getCreatedAt(),
member.getUpdatedAt()

View file

@ -7,6 +7,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
@ -28,15 +30,18 @@ public class NamespacePortalCommandAppService {
private final NamespaceRepository namespaceRepository;
private final NamespaceGovernanceService namespaceGovernanceService;
private final NamespaceMemberService namespaceMemberService;
private final UserAccountRepository userAccountRepository;
public NamespacePortalCommandAppService(NamespaceService namespaceService,
NamespaceRepository namespaceRepository,
NamespaceGovernanceService namespaceGovernanceService,
NamespaceMemberService namespaceMemberService) {
NamespaceMemberService namespaceMemberService,
UserAccountRepository userAccountRepository) {
this.namespaceService = namespaceService;
this.namespaceRepository = namespaceRepository;
this.namespaceGovernanceService = namespaceGovernanceService;
this.namespaceMemberService = namespaceMemberService;
this.userAccountRepository = userAccountRepository;
}
@Transactional
@ -135,7 +140,8 @@ public class NamespacePortalCommandAppService {
role,
operatorUserId
);
return MemberResponse.from(member);
UserAccount user = userAccountRepository.findById(memberUserId).orElse(null);
return MemberResponse.from(member, user);
}
@Transactional
@ -157,7 +163,7 @@ public class NamespacePortalCommandAppService {
request.role(),
operatorUserId
);
return MemberResponse.from(member);
return MemberResponse.from(member, userAccountRepository.findById(userId).orElse(null));
}
private boolean canCreateNamespace(PlatformPrincipal principal) {

View file

@ -8,6 +8,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.MyNamespaceResponse;
import com.iflytek.skillhub.dto.NamespaceResponse;
@ -15,6 +17,8 @@ import com.iflytek.skillhub.dto.PageResponse;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -31,15 +35,18 @@ public class NamespacePortalQueryAppService {
private final NamespaceService namespaceService;
private final NamespaceMemberService namespaceMemberService;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final UserAccountRepository userAccountRepository;
public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository,
NamespaceService namespaceService,
NamespaceMemberService namespaceMemberService,
NamespaceAccessPolicy namespaceAccessPolicy) {
NamespaceAccessPolicy namespaceAccessPolicy,
UserAccountRepository userAccountRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceService = namespaceService;
this.namespaceMemberService = namespaceMemberService;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.userAccountRepository = userAccountRepository;
}
@Transactional(readOnly = true)
@ -78,6 +85,18 @@ public class NamespacePortalQueryAppService {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceService.assertMember(namespace.getId(), userId);
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
return PageResponse.from(members.map(MemberResponse::from));
List<String> memberUserIds = members.getContent().stream()
.map(NamespaceMember::getUserId)
.toList();
Map<String, UserAccount> userMap = memberUserIds.isEmpty()
? Map.of()
: userAccountRepository.findByIdIn(memberUserIds).stream()
.collect(Collectors.toMap(UserAccount::getId, Function.identity()));
return PageResponse.from(members.map(member ->
MemberResponse.from(member, userMap.get(member.getUserId()))
));
}
}

View file

@ -12,6 +12,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
import org.junit.jupiter.api.Test;
@ -71,6 +73,9 @@ class NamespacePortalControllerTest {
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private UserAccountRepository userAccountRepository;
@Test
void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
@ -188,9 +193,12 @@ class NamespacePortalControllerTest {
void addMember_returnsCreatedMember() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1"))
.willReturn(member);
given(userAccountRepository.findById("user-2"))
.willReturn(java.util.Optional.of(user));
mockMvc.perform(post("/api/v1/namespaces/team-a/members")
.with(csrf())
@ -224,9 +232,12 @@ class NamespacePortalControllerTest {
void updateMemberRole_returnsUpdatedMember() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
given(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1"))
.willReturn(member);
given(userAccountRepository.findById("user-2"))
.willReturn(java.util.Optional.of(user));
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a/members/user-2/role")
.with(csrf())

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
import com.iflytek.skillhub.dto.NamespaceRequest;
import com.iflytek.skillhub.exception.ForbiddenException;
@ -28,11 +29,13 @@ class NamespacePortalCommandAppServiceTest {
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class);
private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
private final NamespacePortalCommandAppService service = new NamespacePortalCommandAppService(
namespaceService,
namespaceRepository,
namespaceGovernanceService,
namespaceMemberService
namespaceMemberService,
userAccountRepository
);
@Test

View file

@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@ -25,11 +26,13 @@ class NamespacePortalQueryAppServiceTest {
private final NamespaceService namespaceService = mock(NamespaceService.class);
private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class);
private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
private final NamespacePortalQueryAppService service = new NamespacePortalQueryAppService(
namespaceRepository,
namespaceService,
namespaceMemberService,
namespaceAccessPolicy
namespaceAccessPolicy,
userAccountRepository
);
@Test

View file

@ -3037,6 +3037,8 @@ export interface components {
/** Format: int64 */
namespaceId?: number;
userId?: string;
displayName?: string;
email?: string;
/** @enum {string} */
role?: "OWNER" | "ADMIN" | "MEMBER";
/** Format: date-time */

View file

@ -116,6 +116,8 @@ export interface ManagedNamespace extends Namespace {
export interface NamespaceMember {
id: number
userId: string
displayName?: string
email?: string
role: NamespaceRole
createdAt: string
}

View file

@ -983,6 +983,8 @@
"savingRole": "Saving...",
"changeRole": "Change role",
"colUserId": "User ID",
"colUsername": "Username",
"colEmail": "Email",
"colRole": "Role",
"colJoinedAt": "Joined At",
"colActions": "Actions",

View file

@ -983,6 +983,8 @@
"savingRole": "保存中...",
"changeRole": "修改角色",
"colUserId": "用户 ID",
"colUsername": "用户名",
"colEmail": "邮箱",
"colRole": "角色",
"colJoinedAt": "加入时间",
"colActions": "操作",

View file

@ -178,7 +178,8 @@ export function NamespaceMembersPage() {
<table className="w-full">
<thead>
<tr className="border-b border-border/40">
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colUserId')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colUsername')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colEmail')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colRole')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colJoinedAt')}</th>
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th>
@ -193,7 +194,13 @@ export function NamespaceMembersPage() {
return (
<tr key={member.id} className="border-b border-border/40 last:border-b-0 hover:bg-secondary/30 transition-colors">
<td className="p-4 font-medium font-mono">{member.userId}</td>
<td className="p-4">
<div className="flex flex-col">
<span className="font-medium">{member.displayName || member.userId}</span>
<span className="text-xs text-muted-foreground">{member.userId}</span>
</div>
</td>
<td className="p-4 text-sm text-muted-foreground">{member.email || '-'}</td>
<td className="p-4">
{canManageMembers && !isOwner ? (
<div className="flex items-center gap-2">