diff --git a/docs/2026-08-13-personal-namespace-provisioning.md b/docs/2026-08-13-personal-namespace-provisioning.md new file mode 100644 index 00000000..fe67433b --- /dev/null +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -0,0 +1,85 @@ +# 注册时自动创建个人命名空间 + +## 背景 + +自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能, +而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。 + +## 一、自动创建个人命名空间 + +### 「私有」在当前模型里的含义 + +命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型, +技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。 +本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。 + +如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。 + +### 触发时机 + +在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`: + +| 入口 | 位置 | +|------|------| +| 本地注册 | `LocalAuthService.register` | +| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) | +| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) | + +第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号, +真正可用是在管理员审批那一刻。 + +### 为什么走事件 + AFTER_COMMIT + +`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener` +(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束: + +``` +namespace.created_by REFERENCES user_account(id) +namespace_member.user_id REFERENCES user_account(id) +``` + +- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚, + 用户会因为「命名空间没建成」而登不上来。 +- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行, + 外键检查会阻塞在外层事务的行锁上,形成互等。 + +放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间, +监听器捕获异常并记 WARN。 + +监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。 + +### 命名模板 + +两个模板,占位符语法 `${...}`: + +| 占位符 | 取值 | +|--------|------| +| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID | +| `${email_prefix}` | 邮箱 `@` 之前的部分 | +| `${user_id}` | 平台内部用户 ID | + +未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。 + +slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 +字母数字以外的字符变连字符、去掉首尾与重复连字符。 +**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。 +冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用, +依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。 +`admin` 这类保留字因此自然落到 `admin-2`。 + +幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。 +解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。 + +## 二、配置 + +| 位置 | 项 | 默认 | +|------|-----|------| +| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` | +| 配置文件/环境变量 | 启用开关 | `true` | + +默认只对新激活账号生效,不回填已有账号;如需关闭可设置环境变量。 + +模板刻意**不放在 `application.yml`**:它们含 `${...}`, +Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。 +模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`, +如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java new file mode 100644 index 00000000..94f99d11 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceOwner; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Creates a newly activated account's own namespace once the account itself is committed. + * + *

Runs synchronously rather than on the event executor so the namespace exists by the time the + * user's next request arrives, and swallows failures so a naming clash or a database hiccup costs + * the user a namespace rather than their registration or login. + */ +@Component +public class PersonalNamespaceProvisioningListener { + + private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningListener.class); + + private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + + public PersonalNamespaceProvisioningListener( + PersonalNamespaceProvisioningService personalNamespaceProvisioningService) { + this.personalNamespaceProvisioningService = personalNamespaceProvisioningService; + } + + @TransactionalEventListener + public void onUserActivated(UserActivatedEvent event) { + try { + personalNamespaceProvisioningService.provisionFor( + new PersonalNamespaceOwner(event.userId(), event.username(), event.email())); + } catch (RuntimeException e) { + log.warn("Personal namespace provisioning failed for user {}; the account is unaffected", + event.userId(), e); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index b3e1bdc7..703dbdc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.repository.RoleRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -18,6 +19,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -44,16 +46,19 @@ public class AdminUserAppService { private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; private final RoleRepository roleRepository; + private final ApplicationEventPublisher eventPublisher; public AdminUserAppService( AdminUserSearchRepository adminUserSearchRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, - RoleRepository roleRepository) { + RoleRepository roleRepository, + ApplicationEventPublisher eventPublisher) { this.adminUserSearchRepository = adminUserSearchRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.roleRepository = roleRepository; + this.eventPublisher = eventPublisher; } @Transactional(readOnly = true) @@ -109,8 +114,13 @@ public class AdminUserAppService { UserAccount user = loadUser(userId); rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); + UserStatus previousStatus = user.getStatus(); user.setStatus(nextStatus); userAccountRepository.save(user); + if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) { + eventPublisher.publishEvent( + new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail())); + } return new AdminUserMutationResponse(user.getId(), null, nextStatus.name()); } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 04e70dcc..6ef559c1 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -118,6 +118,11 @@ skillhub: code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M} email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local} email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub} + namespace: + # Whether a newly activated account gets a namespace of its own. + # Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties. + personal-provisioning: + enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:true} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 8296f940..4ea162a9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.repository.AdminUserSearchRepository; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -35,11 +36,13 @@ class AdminUserAppServiceTest { private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class); private final RoleRepository roleRepository = mock(RoleRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); private final AdminUserAppService service = new AdminUserAppService( adminUserSearchRepository, userAccountRepository, userRoleBindingRepository, - roleRepository + roleRepository, + eventPublisher ); @Test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 49fa3dc7..e34530ab 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -10,10 +10,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.UUID; @@ -31,15 +33,18 @@ public class IdentityBindingService { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final ApplicationEventPublisher eventPublisher; public IdentityBindingService(IdentityBindingRepository bindingRepo, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - GlobalNamespaceMembershipService globalNamespaceMembershipService) { + GlobalNamespaceMembershipService globalNamespaceMembershipService, + ApplicationEventPublisher eventPublisher) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.eventPublisher = eventPublisher; } @Transactional @@ -70,6 +75,8 @@ public class IdentityBindingService { user = userRepo.save(user); if (initialStatus == UserStatus.ACTIVE) { globalNamespaceMembershipService.ensureMember(user.getId()); + eventPublisher.publishEvent( + new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email())); } binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867..5d0dad22 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -16,6 +17,7 @@ import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -44,6 +46,7 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final ApplicationEventPublisher eventPublisher; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +54,8 @@ public class LocalAuthService { GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + ApplicationEventPublisher eventPublisher) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +63,7 @@ public class LocalAuthService { this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.eventPublisher = eventPublisher; } /** @@ -100,6 +105,7 @@ public class LocalAuthService { passwordEncoder.encode(password) )); globalNamespaceMembershipService.ensureMember(user.getId()); + eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail)); return buildPrincipal(user); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java index 75dda2de..c72c8022 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.auth.oauth.SystemAccountLoginException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -31,6 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -48,11 +50,15 @@ class IdentityBindingServiceTest { @Mock private GlobalNamespaceMembershipService globalNamespaceMembershipService; + @Mock + private ApplicationEventPublisher eventPublisher; + private IdentityBindingService service; @BeforeEach void setUp() { - service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); + service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, + globalNamespaceMembershipService, eventPublisher); } @Test @@ -79,6 +85,44 @@ class IdentityBindingServiceTest { assertThat(principal.oauthProvider()).isEqualTo("github"); } + @Test + void bindOrCreate_publishesActivationForActiveNewUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + service.bindOrCreate(claims, UserStatus.ACTIVE); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().username()).isEqualTo("alice"); + assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com"); + } + + @Test + void bindOrCreate_doesNotPublishActivationForReturningUsers() { + OAuthClaims claims = new OAuthClaims("github", "gh_1", "alice@example.com", true, "alice", Map.of()); + UserAccount existing = new UserAccount("usr_1", "alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")) + .thenReturn(Optional.of(new IdentityBinding("usr_1", "github", "gh_1", "alice"))); + when(userRepo.findById("usr_1")).thenReturn(Optional.of(existing)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + service.bindOrCreate(claims, UserStatus.ACTIVE); + + verify(eventPublisher, never()).publishEvent(any(UserActivatedEvent.class)); + } + @Test void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { OAuthClaims claims = new OAuthClaims( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b6eaf5af..f11b9116 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -28,6 +29,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; @@ -51,6 +53,9 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private ApplicationEventPublisher eventPublisher; + private LocalAuthService service; @BeforeEach @@ -62,7 +67,8 @@ class LocalAuthServiceTest { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + eventPublisher ); } @@ -86,6 +92,22 @@ class LocalAuthServiceTest { verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); } + @Test + void register_publishesActivationWithTheNormalizedUsername() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty()); + given(passwordEncoder.encode("Abcd123!")).willReturn("encoded"); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of()); + + service.register("Alice", "Abcd123!", "alice@example.com"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().username()).isEqualTo("alice"); + assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com"); + } + @Test void login_withValidPassword_resetsCounters() { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java new file mode 100644 index 00000000..4af259bd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.event; + +/** + * Published when an account becomes usable — local registration, the first login through an + * external identity provider, or an administrator approving or re-enabling an account. + * + *

Listeners must be idempotent: re-enabling a previously disabled account publishes the event + * again. + * + * @param username the name the authentication path knows the user by, or {@code null} + */ +public record UserActivatedEvent(String userId, String username, String email) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java new file mode 100644 index 00000000..82f14a6e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.UUID; + +/** + * Renders the operator-configured name templates for a personal namespace. + * + *

Templates use {@code ${placeholder}} syntax. Unknown placeholders are left untouched so a typo + * shows up in the resulting name instead of silently disappearing. + */ +final class PersonalNamespaceNaming { + + /** + * Longest slug {@link SlugValidator} accepts, minus room for a de-duplication suffix. + */ + private static final int SLUG_BASE_BUDGET = 59; + + /** + * Matches the {@code display_name} column width. + */ + private static final int DISPLAY_NAME_LIMIT = 128; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([a-z_]+)}"); + + private PersonalNamespaceNaming() { + } + + /** + * Substitutes placeholders in {@code template} using {@code owner}. + */ + static String render(String template, PersonalNamespaceOwner owner) { + if (template == null || template.isBlank()) { + return ""; + } + Map values = Map.of( + PersonalNamespaceSettings.PLACEHOLDER_USERNAME, username(owner), + PersonalNamespaceSettings.PLACEHOLDER_EMAIL_PREFIX, emailPrefix(owner), + PersonalNamespaceSettings.PLACEHOLDER_USER_ID, blankToEmpty(owner.userId()), + PersonalNamespaceSettings.PLACEHOLDER_RANDOM, randomSuffix()); + + Matcher matcher = PLACEHOLDER.matcher(template); + StringBuilder rendered = new StringBuilder(); + while (matcher.find()) { + String replacement = values.get(matcher.group(1)); + matcher.appendReplacement(rendered, + Matcher.quoteReplacement(replacement != null ? replacement : matcher.group())); + } + matcher.appendTail(rendered); + return rendered.toString(); + } + + /** + * Renders {@code template} into a slug base, falling back to the user id when the template + * cannot produce anything usable. + */ + static String slugBase(String template, PersonalNamespaceOwner owner) { + String candidate = truncateSlug(SlugValidator.normalize(render(template, owner))); + if (candidate.length() >= 2) { + return candidate; + } + String fallback = truncateSlug(SlugValidator.normalize(owner.userId())); + return fallback.length() >= 2 ? fallback : "user"; + } + + /** + * Renders {@code template} into a display name, falling back to the slug that was chosen. + */ + static String displayName(String template, PersonalNamespaceOwner owner, String slug) { + String rendered = render(template, owner).trim(); + if (rendered.isEmpty()) { + return slug; + } + return rendered.length() > DISPLAY_NAME_LIMIT ? rendered.substring(0, DISPLAY_NAME_LIMIT) : rendered; + } + + private static String username(PersonalNamespaceOwner owner) { + if (owner.username() != null && !owner.username().isBlank()) { + return owner.username().trim(); + } + String emailPrefix = emailPrefix(owner); + return !emailPrefix.isEmpty() ? emailPrefix : blankToEmpty(owner.userId()); + } + + private static String emailPrefix(PersonalNamespaceOwner owner) { + String email = owner.email(); + if (email == null || email.isBlank()) { + return ""; + } + int at = email.indexOf('@'); + return (at > 0 ? email.substring(0, at) : email).trim(); + } + + private static String truncateSlug(String slug) { + if (slug.length() <= SLUG_BASE_BUDGET) { + return slug; + } + return SlugValidator.normalize(slug.substring(0, SLUG_BASE_BUDGET)); + } + + private static String blankToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private static String randomSuffix() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toLowerCase(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java new file mode 100644 index 00000000..8ae5b395 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.namespace; + +/** + * The account a personal namespace is being created for. + * + *

{@code username} is whatever the authentication path calls a user name — the local login name, + * or the provider login for an external identity. It is absent for accounts that have neither. + */ +public record PersonalNamespaceOwner(String userId, String username, String email) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java new file mode 100644 index 00000000..6c8f536f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java @@ -0,0 +1,55 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Deployment defaults for personal namespace provisioning. + * + *

Deployments can disable provisioning with the environment-backed {@code enabled} property. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.namespace.personal-provisioning") +public class PersonalNamespaceProvisioningProperties { + + /** + * Off by default: existing deployments must not start creating namespaces after an upgrade. + */ + private boolean enabled = true; + + /** + * Templates remain code defaults because Spring treats {@code ${...}} in YAML as property + * references. + */ + private String slugTemplate = "personal-${random}"; + + private String displayNameTemplate = "${username}-个人空间"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSlugTemplate() { + return slugTemplate; + } + + public void setSlugTemplate(String slugTemplate) { + this.slugTemplate = slugTemplate; + } + + public String getDisplayNameTemplate() { + return displayNameTemplate; + } + + public void setDisplayNameTemplate(String displayNameTemplate) { + this.displayNameTemplate = displayNameTemplate; + } + + public PersonalNamespaceSettings toSettings() { + return new PersonalNamespaceSettings(enabled, slugTemplate, displayNameTemplate); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java new file mode 100644 index 00000000..8efd576a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * Gives each newly activated account a namespace of its own, when the operator has asked for it. + * + *

The namespace is an ordinary team namespace whose only member is its owner, which is what + * "private" means in this model: there is no namespace-level visibility flag, and skill visibility + * stays a property of each skill. + * + *

Provisioning deliberately runs in its own transaction, after the account has been committed. + * {@code namespace.created_by} and {@code namespace_member.user_id} both reference + * {@code user_account(id)}, so creating the namespace inside the still-open registration + * transaction would either join that transaction — letting a naming clash roll back the + * registration — or, if suspended, block on the uncommitted account row. Running afterwards keeps a + * failure here from costing the user their account; see + * {@code PersonalNamespaceProvisioningListener}. + */ +@Service +public class PersonalNamespaceProvisioningService { + + /** + * Upper bound on de-duplication suffixes before giving up on a slug base. + */ + private static final int MAX_SLUG_ATTEMPTS = 64; + + private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningService.class); + + private final PersonalNamespaceProvisioningProperties defaults; + private final NamespaceService namespaceService; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; + + public PersonalNamespaceProvisioningService(PersonalNamespaceProvisioningProperties defaults, + NamespaceService namespaceService, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository) { + this.defaults = defaults; + this.namespaceService = namespaceService; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + /** + * Returns the effective policy: the administrator's stored choice, or the deployment defaults. + */ + public PersonalNamespaceSettings currentSettings() { + return defaults.toSettings(); + } + + /** + * Creates the owner's namespace, or returns empty when provisioning is off, the owner already + * has one, or no acceptable slug is available. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional provisionFor(PersonalNamespaceOwner owner) { + PersonalNamespaceSettings settings = currentSettings(); + if (!settings.enabled()) { + return Optional.empty(); + } + if (userAccountRepository.findById(owner.userId()) + .map(com.iflytek.skillhub.domain.user.UserAccount::isSystemAccount) + .orElse(false)) { + log.info("Skipping personal namespace for system account {}", owner.userId()); + return Optional.empty(); + } + if (alreadyOwnsNamespace(owner.userId())) { + return Optional.empty(); + } + + String slug = allocateSlug(settings.slugTemplate(), owner); + if (slug == null) { + log.warn("No namespace slug available for user {} from template '{}'; skipping provisioning", + owner.userId(), settings.slugTemplate()); + return Optional.empty(); + } + + String displayName = PersonalNamespaceNaming.displayName(settings.displayNameTemplate(), owner, slug); + Namespace namespace = namespaceService.createNamespace(slug, displayName, null, owner.userId()); + log.info("Provisioned personal namespace '{}' for user {}", slug, owner.userId()); + return Optional.of(namespace); + } + + /** + * Treats owning any non-global namespace as "already has a personal namespace", which keeps a + * repeated activation from handing the same user a second one. + */ + private boolean alreadyOwnsNamespace(String userId) { + return namespaceMemberRepository.findByUserId(userId).stream() + .filter(member -> member.getRole() == NamespaceRole.OWNER) + .map(member -> namespaceRepository.findById(member.getNamespaceId())) + .flatMap(Optional::stream) + .anyMatch(namespace -> namespace.getType() != NamespaceType.GLOBAL); + } + + /** + * Returns the first free slug for the owner, or {@code null} when every candidate is taken or + * rejected — for example when the template renders to a reserved word for many users. + */ + private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner) { + String base = PersonalNamespaceNaming.slugBase(slugTemplate, owner); + for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + String candidate = attempt == 1 ? base : base + "-" + attempt; + if (SlugValidator.isValid(candidate) && namespaceRepository.findBySlug(candidate).isEmpty()) { + return candidate; + } + } + return null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java new file mode 100644 index 00000000..65ca6e56 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Operator-controlled policy for giving each new account its own namespace. + * + * @param slugTemplate template for the namespace slug, e.g. {@code ${username}-space} + * @param displayNameTemplate template for the namespace display name + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PersonalNamespaceSettings( + boolean enabled, + String slugTemplate, + String displayNameTemplate) { + + /** + * Supported placeholders, in the order they are documented to operators. + */ + public static final String PLACEHOLDER_USERNAME = "username"; + public static final String PLACEHOLDER_EMAIL_PREFIX = "email_prefix"; + public static final String PLACEHOLDER_USER_ID = "user_id"; + public static final String PLACEHOLDER_RANDOM = "random"; +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java index 9417325f..d4ffaca1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java @@ -44,12 +44,37 @@ public class SlugValidator { if (raw == null) { throw new DomainBadRequestException("error.slug.blank"); } - String slug = raw.trim().toLowerCase() + String slug = normalize(raw); + validate(slug); + return slug; + } + + /** + * Applies the slug character rules without asserting the result is usable. + * + *

Callers that generate candidate slugs — rather than accepting one from a user — need to + * inspect and adjust the result (append a suffix, truncate) before validating it. + */ + public static String normalize(String raw) { + if (raw == null) { + return ""; + } + return raw.trim().toLowerCase() .replaceAll("[^\\p{L}\\p{N}\\p{So}]+", "-") .replaceAll("^-+", "") .replaceAll("-+$", "") .replaceAll("-{2,}", "-"); - validate(slug); - return slug; + } + + /** + * Returns whether {@code slug} would pass {@link #validate(String)}. + */ + public static boolean isValid(String slug) { + try { + validate(slug); + return true; + } catch (DomainBadRequestException e) { + return false; + } } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java new file mode 100644 index 00000000..655378e3 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PersonalNamespaceNamingTest { + + private static final PersonalNamespaceOwner ALICE = + new PersonalNamespaceOwner("usr_0f2a", "Alice.Wang", "alice.wang@example.com"); + + @Test + void rendersEachSupportedPlaceholder() { + assertEquals("Alice.Wang", PersonalNamespaceNaming.render("${username}", ALICE)); + assertEquals("alice.wang", PersonalNamespaceNaming.render("${email_prefix}", ALICE)); + assertEquals("usr_0f2a", PersonalNamespaceNaming.render("${user_id}", ALICE)); + } + + @Test + void leavesUnknownPlaceholdersInPlaceSoTyposAreVisible() { + assertEquals("Alice.Wang-${nickname}", PersonalNamespaceNaming.render("${username}-${nickname}", ALICE)); + } + + @Test + void slugBaseAppliesSlugCharacterRules() { + assertEquals("alice-wang", PersonalNamespaceNaming.slugBase("${username}", ALICE)); + assertEquals("alice-wang-space", PersonalNamespaceNaming.slugBase("${username}-space", ALICE)); + } + + @Test + void slugBaseRewritesUnderscoresBecauseSlugsDisallowThem() { + String slug = PersonalNamespaceNaming.slugBase("${username}_space", ALICE); + + assertEquals("alice-wang-space", slug); + assertTrue(SlugValidator.isValid(slug)); + } + + @Test + void slugBaseFallsBackToEmailPrefixWhenUsernameIsMissing() { + PersonalNamespaceOwner noUsername = new PersonalNamespaceOwner("usr_1", null, "bob@example.com"); + + assertEquals("bob", PersonalNamespaceNaming.slugBase("${username}", noUsername)); + } + + @Test + void slugBaseFallsBackToUserIdWhenTemplateRendersNothingUsable() { + PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_abc123", null, null); + + assertEquals("usr-abc123", PersonalNamespaceNaming.slugBase("${username}", anonymous)); + } + + @Test + void slugBaseLeavesRoomForADeduplicationSuffix() { + PersonalNamespaceOwner longName = new PersonalNamespaceOwner("usr_1", "a".repeat(200), null); + + String base = PersonalNamespaceNaming.slugBase("${username}", longName); + + assertTrue(base.length() <= 59, "base was " + base.length() + " chars"); + assertTrue(SlugValidator.isValid(base + "-64")); + } + + @Test + void displayNameFallsBackToTheSlugWhenTemplateRendersBlank() { + PersonalNamespaceOwner noEmail = new PersonalNamespaceOwner("usr_1", "alice", null); + + assertEquals("chosen-slug", + PersonalNamespaceNaming.displayName("${email_prefix}", noEmail, "chosen-slug")); + } + + @Test + void usernameFallsBackToTheUserIdWhenNothingElseIsKnown() { + PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_1", null, null); + + assertEquals("usr_1", PersonalNamespaceNaming.render("${username}", anonymous)); + } + + @Test + void displayNameKeepsHumanReadableCharacters() { + assertEquals("Alice.Wang's space", + PersonalNamespaceNaming.displayName("${username}'s space", ALICE, "alice-wang")); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java new file mode 100644 index 00000000..d2066061 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java @@ -0,0 +1,185 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PersonalNamespaceProvisioningServiceTest { + + private static final PersonalNamespaceOwner ALICE = + new PersonalNamespaceOwner("usr_alice", "alice", "alice@example.com"); + + @Mock + private NamespaceService namespaceService; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; + + private PersonalNamespaceProvisioningService service; + private PersonalNamespaceProvisioningProperties properties; + + @BeforeEach + void setUp() { + properties = new PersonalNamespaceProvisioningProperties(); + service = new PersonalNamespaceProvisioningService( + properties, + namespaceService, + namespaceRepository, + namespaceMemberRepository, + userAccountRepository); + } + + private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) { + properties.setEnabled(enabled); + properties.setSlugTemplate(slugTemplate); + properties.setDisplayNameTemplate(displayNameTemplate); + } + + private void ownsNothing() { + when(namespaceMemberRepository.findByUserId(ALICE.userId())).thenReturn(List.of()); + } + + /** + * Mirrors {@link NamespaceService#createNamespace} returning the namespace it persisted. + */ + private void namespaceCreationSucceeds() { + when(namespaceService.createNamespace(any(), any(), any(), any())) + .thenAnswer(invocation -> new Namespace( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(3))); + } + + @Test + void doesNothingWhenProvisioningIsDisabled() { + withSettings(false, "${username}", "${username}"); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void skipsSystemAccount() { + withSettings(true, "personal-${random}", "${username}-个人空间"); + when(userAccountRepository.findById("usr_system")) + .thenReturn(Optional.of(com.iflytek.skillhub.domain.user.UserAccount + .systemAccount("usr_system", "system", null, null))); + + assertTrue(service.provisionFor(new PersonalNamespaceOwner("usr_system", "system", null)).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void defaultsAreEnabledForNewAccountProvisioning() { + assertEquals(true, new PersonalNamespaceProvisioningProperties().isEnabled()); + } + + @Test + void createsNamespaceFromTheConfiguredTemplate() { + withSettings(true, "${username}-space", "${username}'s space"); + ownsNothing(); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice-space")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace("alice-space", "alice's space", null, "usr_alice"); + } + + @Test + void appendsSuffixWhenTheSlugIsAlreadyTaken() { + withSettings(true, "${username}", "${username}"); + ownsNothing(); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.of(new Namespace("alice", "Alice", "usr_x"))); + when(namespaceRepository.findBySlug("alice-2")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice-2"), any(), isNull(), eq("usr_alice")); + } + + @Test + void skipsReservedSlugsInsteadOfFailing() { + PersonalNamespaceOwner admin = new PersonalNamespaceOwner("usr_admin", "admin", null); + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(admin.userId())).thenReturn(List.of()); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("admin-2")).thenReturn(Optional.empty()); + + service.provisionFor(admin); + + verify(namespaceService).createNamespace(eq("admin-2"), any(), isNull(), eq("usr_admin")); + } + + @Test + void skipsWhenTheUserAlreadyOwnsANamespace() { + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(7L, ALICE.userId(), NamespaceRole.OWNER))); + when(namespaceRepository.findById(7L)) + .thenReturn(Optional.of(new Namespace("alice", "Alice", ALICE.userId()))); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void globalMembershipDoesNotCountAsOwningANamespace() { + withSettings(true, "${username}", "${username}"); + Namespace global = new Namespace("global", "Global", "usr_system"); + global.setType(NamespaceType.GLOBAL); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(1L, ALICE.userId(), NamespaceRole.OWNER))); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(global)); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void plainMembershipDoesNotCountAsOwningANamespace() { + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(3L, ALICE.userId(), NamespaceRole.MEMBER))); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void givesUpQuietlyWhenEveryCandidateSlugIsTaken() { + withSettings(true, "${username}", "${username}"); + ownsNothing(); + when(namespaceRepository.findBySlug(any())) + .thenReturn(Optional.of(new Namespace("taken", "Taken", "usr_x"))); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } +}