Feat/namespace member display username (#236)

* 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.

* test(namespace): add displayName/email assertions and new test coverage

- Controller: add displayName and email assertions to addMember and updateMemberRole tests
- QueryAppService: add listMembers tests for batch user enrichment and null degradation
- CommandAppService: add addMember/updateMemberRole tests for displayName/email population and graceful degradation

Addresses PR #236 review feedback comments:
- Issue 2: Controller tests now assert displayName and email from mocked UserAccount
- Issue 3: Service layer tests now cover batch query and null user degradation
This commit is contained in:
Seasoning 2026-04-08 20:02:20 +08:00 committed by GitHub
parent 010c1a4e46
commit 739e21e0fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 236 additions and 11 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())
@ -203,7 +211,9 @@ class NamespacePortalControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("user-2"))
.andExpect(jsonPath("$.data.role").value("ADMIN"));
.andExpect(jsonPath("$.data.role").value("ADMIN"))
.andExpect(jsonPath("$.data.displayName").value("Alice"))
.andExpect(jsonPath("$.data.email").value("alice@example.com"));
}
@Test
@ -224,9 +234,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())
@ -239,7 +252,9 @@ class NamespacePortalControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("user-2"))
.andExpect(jsonPath("$.data.role").value("OWNER"));
.andExpect(jsonPath("$.data.role").value("OWNER"))
.andExpect(jsonPath("$.data.displayName").value("Alice"))
.andExpect(jsonPath("$.data.email").value("alice@example.com"));
}
@Test

View file

@ -9,17 +9,24 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
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.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.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
import com.iflytek.skillhub.dto.NamespaceRequest;
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
import com.iflytek.skillhub.exception.ForbiddenException;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import java.util.Set;
class NamespacePortalCommandAppServiceTest {
@ -28,11 +35,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
@ -71,4 +80,92 @@ class NamespacePortalCommandAppServiceTest {
namespace.setType(NamespaceType.TEAM);
return namespace;
}
@Test
void addMember_populatesDisplayNameAndEmail() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN);
ReflectionTestUtils.setField(member, "id", 10L);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("user-2"))
.thenReturn(Optional.of(user));
MemberResponse result = service.addMember("team-a", "user-2", NamespaceRole.ADMIN, "owner-1");
assertThat(result.userId()).isEqualTo("user-2");
assertThat(result.displayName()).isEqualTo("Alice");
assertThat(result.email()).isEqualTo("alice@example.com");
assertThat(result.role()).isEqualTo(NamespaceRole.ADMIN);
}
@Test
void addMember_withoutUserAccount_degradesGracefully() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER);
ReflectionTestUtils.setField(member, "id", 20L);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("ghost"))
.thenReturn(Optional.empty());
MemberResponse result = service.addMember("team-a", "ghost", NamespaceRole.MEMBER, "owner-1");
assertThat(result.userId()).isEqualTo("ghost");
assertThat(result.displayName()).isNull();
assertThat(result.email()).isNull();
}
@Test
void updateMemberRole_populatesDisplayNameAndEmail() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER);
ReflectionTestUtils.setField(member, "id", 10L);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("user-2"))
.thenReturn(Optional.of(user));
MemberResponse result = service.updateMemberRole(
"team-a", "user-2",
new UpdateMemberRoleRequest(NamespaceRole.OWNER),
"owner-1"
);
assertThat(result.userId()).isEqualTo("user-2");
assertThat(result.displayName()).isEqualTo("Alice");
assertThat(result.email()).isEqualTo("alice@example.com");
assertThat(result.role()).isEqualTo(NamespaceRole.OWNER);
}
@Test
void updateMemberRole_withoutUserAccount_degradesGracefully() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER);
ReflectionTestUtils.setField(member, "id", 20L);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("ghost"))
.thenReturn(Optional.empty());
MemberResponse result = service.updateMemberRole(
"team-a", "ghost",
new UpdateMemberRoleRequest(NamespaceRole.ADMIN),
"owner-1"
);
assertThat(result.userId()).isEqualTo("ghost");
assertThat(result.displayName()).isNull();
assertThat(result.email()).isNull();
}
}

View file

@ -1,19 +1,28 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy;
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.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.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.PageResponse;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@ -25,11 +34,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
@ -67,4 +78,48 @@ class NamespacePortalQueryAppServiceTest {
namespace.setType(NamespaceType.TEAM);
return namespace;
}
@Test
void listMembers_withUserAccount_returnsDisplayNameAndEmail() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN);
ReflectionTestUtils.setField(member, "id", 10L);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1));
when(userAccountRepository.findByIdIn(List.of("user-2")))
.thenReturn(List.of(user));
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
assertThat(result.items()).hasSize(1);
MemberResponse mr = result.items().get(0);
assertThat(mr.userId()).isEqualTo("user-2");
assertThat(mr.displayName()).isEqualTo("Alice");
assertThat(mr.email()).isEqualTo("alice@example.com");
assertThat(mr.role()).isEqualTo(NamespaceRole.ADMIN);
}
@Test
void listMembers_withoutUserAccount_returnsNullFields() {
Namespace ns = namespace(1L, "team-a");
NamespaceMember member = new NamespaceMember(1L, "ghost-user", NamespaceRole.MEMBER);
ReflectionTestUtils.setField(member, "id", 20L);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1));
when(userAccountRepository.findByIdIn(List.of("ghost-user")))
.thenReturn(List.of());
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
assertThat(result.items()).hasSize(1);
MemberResponse mr = result.items().get(0);
assertThat(mr.userId()).isEqualTo("ghost-user");
assertThat(mr.displayName()).isNull();
assertThat(mr.email()).isNull();
}
}

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">