Merge pull request #712 from Vast-Stars/feat/personal-namespace-provisioning

feat(namespace): auto-provision a personal namespace on registration
This commit is contained in:
XiaoSeS 2026-08-28 17:04:21 +08:00 • committed by GitHub
commit f993ad6533
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 856 additions and 9 deletions

View file

@ -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`。

View file

@ -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.
*
* <p>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);
}
}
}

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View file

@ -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<UserActivatedEvent> 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(

View file

@ -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<UserActivatedEvent> 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");

View file

@ -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.
*
* <p>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) {
}

View file

@ -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.
*
* <p>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<String, String> 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();
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.domain.namespace;
/**
* The account a personal namespace is being created for.
*
* <p>{@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) {
}

View file

@ -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.
*
* <p>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);
}
}

View file

@ -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.
*
* <p>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.
*
* <p>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<Namespace> 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;
}
}

View file

@ -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";
}

View file

@ -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.
*
* <p>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;
}
}
}

View file

@ -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"));
}
}

View file

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