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 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 {@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 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());
+ }
+}