diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb5541..6a111f07 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -330,6 +330,15 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/audit-logs` | 审计日志查询 | +### 平台设置(需 SUPER_ADMIN) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/admin/settings/personal-namespace` | 读取「新账号自动建命名空间」策略 | +| PUT | `/api/v1/admin/settings/personal-namespace` | 更新该策略(写审计日志) | + +详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。 + ## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN) | 方法 | 路径 | 说明 | 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..d43ca819 --- /dev/null +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -0,0 +1,132 @@ +# 注册时自动创建个人命名空间 + +## 背景 + +自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能, +而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。 + +在此之前 SkillHub 没有任何「全局设置」机制——只有按用户维度的通知偏好, +凡是部署级开关都只能靠配置文件加环境变量,改一次要重启。 +本次改动同时补上这两块:一个通用的设置存储,和第一个使用它的功能。 + +## 一、通用设置存储(`system_setting`) + +```sql +CREATE TABLE system_setting ( + setting_key VARCHAR(128) PRIMARY KEY, + setting_value JSONB NOT NULL, + updated_by VARCHAR(128) REFERENCES user_account(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +一行存一组设置,值是 JSON 文档,因此一组设置增加字段不需要新的迁移。 + +`SystemSettingService` 的读取接口强制调用方传入默认值: + +```java + T get(String settingKey, Class type, T defaults) +``` + +这带来两个性质: + +- **管理员没动过的设置组不存在数据库行**,读取时回落到部署的配置文件默认值。 + 纯配置化的部署可以完全不碰控制台,行为与本功能上线前一致。 +- **存量文档解析失败时同样回落到默认值**,并打一条 WARN 日志。 + 一行损坏的设置不应该让登录这种关键路径挂掉。 + +设置组用 `@JsonIgnoreProperties(ignoreUnknown = true)`, +滚动升级时旧节点读到新节点写入的文档不会报错。 + +## 二、自动创建个人命名空间 + +### 「私有」在当前模型里的含义 + +命名空间没有可见性字段——只有 `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` | +| 控制台 | 启用开关、slug 模板、显示名模板 | `${username}` | + +**默认关闭**:升级不应该让现有部署突然开始建命名空间。 + +模板刻意**不放在 `application.yml`**:它们含 `${...}`, +Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。 +模板的默认值写在 `PersonalNamespaceProvisioningProperties` 的 Java 字段里, +运行期改动走控制台。 + +## 四、审计 + +`PUT /api/v1/admin/settings/personal-namespace` 写一条审计日志, +action 为 `SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE`,target type `SYSTEM_SETTING`, +detail 中包含改动前后的完整设置。 + +## 五、后续可以复用的地方 + +`system_setting` 是通用的。最直接的下一个使用者是 +[#318](https://github.com/iflytek/skillhub/issues/318)(管理员开关本地注册)—— +目前只能靠在网关层挡 `/api/v1/auth/local/register`。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java new file mode 100644 index 00000000..d6ec50e2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.PersonalNamespaceSettingsAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Platform-wide settings an operator can change without redeploying. + */ +@RestController +@RequestMapping("/api/v1/admin/settings") +public class AdminSystemSettingController extends BaseApiController { + + private final PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService; + + public AdminSystemSettingController(PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.personalNamespaceSettingsAppService = personalNamespaceSettingsAppService; + } + + @GetMapping("/personal-namespace") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse getPersonalNamespaceSettings() { + return ok("response.success.read", personalNamespaceSettingsAppService.get()); + } + + @PutMapping("/personal-namespace") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse updatePersonalNamespaceSettings( + @Valid @RequestBody PersonalNamespaceSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", personalNamespaceSettingsAppService.update( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java new file mode 100644 index 00000000..d09dbd8c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param supportedPlaceholders placeholder names the templates accept, so the console can document + * them without hard-coding the list + */ +public record PersonalNamespaceSettingsResponse( + boolean enabled, + String slugTemplate, + String displayNameTemplate, + List supportedPlaceholders +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java new file mode 100644 index 00000000..331d6a4c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PersonalNamespaceSettingsUpdateRequest( + @NotNull Boolean enabled, + @NotBlank @Size(max = 128) String slugTemplate, + @NotBlank @Size(max = 128) String displayNameTemplate +) {} 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/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java new file mode 100644 index 00000000..b9a32784 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Exposes the personal-namespace provisioning policy to the admin console. + */ +@Service +public class PersonalNamespaceSettingsAppService { + + private static final String AUDIT_TARGET_TYPE = "SYSTEM_SETTING"; + private static final String AUDIT_ACTION_UPDATE = "SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE"; + + private static final List SUPPORTED_PLACEHOLDERS = List.of( + PersonalNamespaceSettings.PLACEHOLDER_USERNAME, + PersonalNamespaceSettings.PLACEHOLDER_EMAIL_PREFIX, + PersonalNamespaceSettings.PLACEHOLDER_USER_ID); + + private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + private final ObjectMapper objectMapper; + + public PersonalNamespaceSettingsAppService( + PersonalNamespaceProvisioningService personalNamespaceProvisioningService, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor, + ObjectMapper objectMapper) { + this.personalNamespaceProvisioningService = personalNamespaceProvisioningService; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PersonalNamespaceSettingsResponse get() { + return toResponse(personalNamespaceProvisioningService.currentSettings()); + } + + @Transactional + public PersonalNamespaceSettingsResponse update(PersonalNamespaceSettingsUpdateRequest request, + String actorUserId, + AuditRequestContext auditContext) { + PersonalNamespaceSettings previous = personalNamespaceProvisioningService.currentSettings(); + PersonalNamespaceSettings updated = new PersonalNamespaceSettings( + Boolean.TRUE.equals(request.enabled()), + request.slugTemplate().trim(), + request.displayNameTemplate().trim()); + + personalNamespaceProvisioningService.updateSettings(updated, actorUserId); + recordAudit(actorUserId, auditContext, previous, updated); + return toResponse(updated); + } + + private PersonalNamespaceSettingsResponse toResponse(PersonalNamespaceSettings settings) { + return new PersonalNamespaceSettingsResponse( + settings.enabled(), + settings.slugTemplate(), + settings.displayNameTemplate(), + SUPPORTED_PLACEHOLDERS); + } + + private void recordAudit(String actorUserId, + AuditRequestContext auditContext, + PersonalNamespaceSettings previous, + PersonalNamespaceSettings updated) { + Map detail = new LinkedHashMap<>(); + detail.put("before", describe(previous)); + detail.put("after", describe(updated)); + auditLogService.record( + actorUserId, + AUDIT_ACTION_UPDATE, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + + private Map describe(PersonalNamespaceSettings settings) { + Map described = new LinkedHashMap<>(); + described.put("enabled", settings.enabled()); + described.put("slugTemplate", settings.slugTemplate()); + described.put("displayNameTemplate", settings.displayNameTemplate()); + return described; + } + + private String toJson(Map detail) { + try { + return objectMapper.writeValueAsString(detail); + } catch (Exception e) { + return null; + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 10e9638e..e7abdd12 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -116,6 +116,15 @@ 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. An administrator can override + # this from the admin console, and the stored choice then wins over this file. + # + # The slug and display-name templates are deliberately not configurable here: they contain + # ${...} placeholders, which Spring would try to resolve as property references. Set them in + # the admin console instead; their defaults live in PersonalNamespaceProvisioningProperties. + personal-provisioning: + enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:false} 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-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java new file mode 100644 index 00000000..a0bf3c3f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PersonalNamespaceSettingsAppServiceTest { + + @Mock + private PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + + @Mock + private AuditLogService auditLogService; + + @Mock + private RequestIdAccessor requestIdAccessor; + + private PersonalNamespaceSettingsAppService service; + + @BeforeEach + void setUp() { + service = new PersonalNamespaceSettingsAppService( + personalNamespaceProvisioningService, + auditLogService, + requestIdAccessor, + new ObjectMapper()); + when(requestIdAccessor.current()).thenReturn("req-1"); + } + + @Test + void getExposesTheEffectiveSettingsAndSupportedPlaceholders() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(true, "${username}", "${username}")); + + PersonalNamespaceSettingsResponse response = service.get(); + + assertThat(response.enabled()).isTrue(); + assertThat(response.slugTemplate()).isEqualTo("${username}"); + assertThat(response.supportedPlaceholders()) + .containsExactly("username", "email_prefix", "user_id"); + } + + @Test + void updateTrimsTemplatesBeforeStoringThem() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(true, " ${username}-space ", " ${username} "), + "usr_admin", + new AuditRequestContext("10.0.0.1", "curl/8")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PersonalNamespaceSettings.class); + verify(personalNamespaceProvisioningService).updateSettings(captor.capture(), eq("usr_admin")); + assertThat(captor.getValue().enabled()).isTrue(); + assertThat(captor.getValue().slugTemplate()).isEqualTo("${username}-space"); + assertThat(captor.getValue().displayNameTemplate()).isEqualTo("${username}"); + } + + @Test + void updateRecordsAnAuditEntryWithBeforeAndAfter() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(true, "${username}-space", "${username}"), + "usr_admin", + new AuditRequestContext("10.0.0.1", "curl/8")); + + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); + verify(auditLogService).record( + eq("usr_admin"), + eq("SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE"), + eq("SYSTEM_SETTING"), + isNull(), + eq("req-1"), + eq("10.0.0.1"), + eq("curl/8"), + detailCaptor.capture()); + assertThat(detailCaptor.getValue()) + .contains("\"before\"") + .contains("\"after\"") + .contains("${username}-space"); + } + + @Test + void updateToleratesAMissingAuditContext() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(false, "${username}", "${username}"), + "usr_admin", + null); + + verify(auditLogService).record(any(), any(), any(), isNull(), any(), isNull(), isNull(), any()); + } +} 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 2a4fae8b..8e922431 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 @@ -6,10 +6,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; @@ -27,15 +29,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 @@ -65,6 +70,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 79e5df74..7c9281c3 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 @@ -16,6 +16,7 @@ import com.iflytek.skillhub.auth.oauth.AccountPendingException; 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; @@ -29,6 +30,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) @@ -46,11 +48,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 @@ -77,6 +83,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..5a9619d3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java @@ -0,0 +1,104 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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())); + + 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(); + } +} 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..ec0ca6e5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Deployment defaults for personal namespace provisioning. + * + *

These apply until an administrator saves the setting in the admin console, after which the + * stored value wins. Deployments that manage configuration purely through files can therefore keep + * doing so and never touch the console. + */ +@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 = false; + + /** + * Kept out of {@code application.yml}: the {@code ${...}} placeholders would be resolved as + * Spring property references. Operators change the templates in the admin console, so these + * defaults only apply until someone does. + */ + private String slugTemplate = "${username}"; + + 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..6e61b533 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java @@ -0,0 +1,122 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +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 { + + public static final String SETTING_KEY = "namespace.personal-provisioning"; + + /** + * 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 SystemSettingService systemSettingService; + private final PersonalNamespaceProvisioningProperties defaults; + private final NamespaceService namespaceService; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + + public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService, + PersonalNamespaceProvisioningProperties defaults, + NamespaceService namespaceService, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository) { + this.systemSettingService = systemSettingService; + this.defaults = defaults; + this.namespaceService = namespaceService; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + } + + /** + * Returns the effective policy: the administrator's stored choice, or the deployment defaults. + */ + public PersonalNamespaceSettings currentSettings() { + return systemSettingService.get(SETTING_KEY, PersonalNamespaceSettings.class, defaults.toSettings()); + } + + @Transactional + public PersonalNamespaceSettings updateSettings(PersonalNamespaceSettings settings, String actorUserId) { + return systemSettingService.put(SETTING_KEY, settings, actorUserId); + } + + /** + * 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 (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..62f817d7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java @@ -0,0 +1,23 @@ +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"; +} 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..5aca736d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java @@ -0,0 +1,173 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +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 SystemSettingService systemSettingService; + + @Mock + private NamespaceService namespaceService; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + private PersonalNamespaceProvisioningService service; + + @BeforeEach + void setUp() { + service = new PersonalNamespaceProvisioningService( + systemSettingService, + new PersonalNamespaceProvisioningProperties(), + namespaceService, + namespaceRepository, + namespaceMemberRepository); + } + + private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) { + when(systemSettingService.get(eq(PersonalNamespaceProvisioningService.SETTING_KEY), + eq(PersonalNamespaceSettings.class), any())) + .thenReturn(new PersonalNamespaceSettings(enabled, slugTemplate, 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 defaultsAreDisabledSoUpgradesDoNotStartCreatingNamespaces() { + assertEquals(false, 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()); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5e4c7756..972d013f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -47,6 +47,8 @@ import type { LabelDefinition, LabelItem, BatchMemberResponse, + PersonalNamespaceSettings, + PersonalNamespaceSettingsInput, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1443,6 +1445,24 @@ export const adminApi = { body: JSON.stringify({ comment }), }) }, + + async getPersonalNamespaceSettings(): Promise { + return fetchJson('/api/v1/admin/settings/personal-namespace') + }, + + async updatePersonalNamespaceSettings( + request: PersonalNamespaceSettingsInput, + ): Promise { + return fetchJson('/api/v1/admin/settings/personal-namespace', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + enabled: request.enabled, + slugTemplate: request.slugTemplate.trim(), + displayNameTemplate: request.displayNameTemplate.trim(), + }), + }) + }, } export const notificationApi = { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 3c4f5b8e..482e6c1e 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -372,6 +372,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/personal-namespace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPersonalNamespaceSettings"]; + put: operations["updatePersonalNamespaceSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/namespaces/{slug}/members/{userId}/role": { parameters: { query?: never; @@ -3710,6 +3726,26 @@ export interface components { AdminUserRoleUpdateRequest: { role: string; }; + PersonalNamespaceSettingsUpdateRequest: { + enabled: boolean; + slugTemplate: string; + displayNameTemplate: string; + }; + ApiResponsePersonalNamespaceSettingsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PersonalNamespaceSettingsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PersonalNamespaceSettingsResponse: { + enabled?: boolean; + slugTemplate?: string; + displayNameTemplate?: string; + supportedPlaceholders?: string[]; + }; AdminLabelUpdateRequest: { /** @enum {string} */ type: "RECOMMENDED" | "PRIVILEGED"; @@ -6392,6 +6428,50 @@ export interface operations { }; }; }; + getPersonalNamespaceSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceSettingsResponse"]; + }; + }; + }; + }; + updatePersonalNamespaceSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonalNamespaceSettingsUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceSettingsResponse"]; + }; + }; + }; + }; updateMemberRole_2: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3f9663e6..c2d0c2e1 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -583,3 +583,16 @@ export interface NotificationPreferenceItem { export interface NotificationUnreadCount { count: number } + +export interface PersonalNamespaceSettings { + enabled: boolean + slugTemplate: string + displayNameTemplate: string + supportedPlaceholders: string[] +} + +export interface PersonalNamespaceSettingsInput { + enabled: boolean + slugTemplate: string + displayNameTemplate: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index bb1aac30..3f8c6196 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -151,6 +151,11 @@ const AdminNamespacesPage = createRoleProtectedRouteComponent( 'AdminNamespacesPage', ['SUPER_ADMIN'], ) +const AdminSettingsPage = createRoleProtectedRouteComponent( + () => import('@/pages/admin/settings'), + 'AdminSettingsPage', + ['SUPER_ADMIN'], +) function DefaultNotFound() { return ( @@ -457,6 +462,13 @@ const adminNamespacesRoute = createRoute({ component: AdminNamespacesPage, }) +const adminSettingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'admin/settings', + beforeLoad: requireAuth, + component: AdminSettingsPage, +}) + const routeTree = rootRoute.addChildren([ landingRoute, skillsRoute, @@ -494,6 +506,7 @@ const routeTree = rootRoute.addChildren([ adminAuditLogRoute, adminLabelsRoute, adminNamespacesRoute, + adminSettingsRoute, ]) export const router = createRouter({ diff --git a/web/src/features/admin/use-personal-namespace-settings.ts b/web/src/features/admin/use-personal-namespace-settings.ts new file mode 100644 index 00000000..5864c831 --- /dev/null +++ b/web/src/features/admin/use-personal-namespace-settings.ts @@ -0,0 +1,24 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' +import type { PersonalNamespaceSettings, PersonalNamespaceSettingsInput } from '@/api/types' + +const QUERY_KEY = ['admin', 'settings', 'personal-namespace'] + +export function usePersonalNamespaceSettings() { + return useQuery({ + queryKey: QUERY_KEY, + queryFn: () => adminApi.getPersonalNamespaceSettings(), + }) +} + +export function useUpdatePersonalNamespaceSettings() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: PersonalNamespaceSettingsInput) => + adminApi.updatePersonalNamespaceSettings(request), + onSuccess: (settings) => { + queryClient.setQueryData(QUERY_KEY, settings) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dcfc28bf..0127214e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1338,7 +1338,8 @@ "notifications": "Notification Settings", "accounts": "Account Merge", "logout": "Logout", - "namespacesAdmin": "Namespace management" + "namespacesAdmin": "Namespace management", + "platformSettings": "Platform settings" } }, "footer": { @@ -1644,5 +1645,29 @@ "unfreezeErrorTitle": "Failed to unfreeze namespace", "archiveErrorTitle": "Failed to archive namespace", "restoreErrorTitle": "Failed to restore namespace" + }, + "adminSettings": { + "title": "Platform settings", + "subtitle": "Settings that apply to the whole deployment.", + "personalNamespaceTitle": "Personal namespace on registration", + "personalNamespaceDescription": "Give every newly activated account a namespace of its own. The account is the only member and holds the owner role. Existing accounts are not affected.", + "enabledLabel": "Automatic creation", + "enabledOn": "Enabled", + "enabledOff": "Disabled", + "slugTemplateLabel": "Namespace slug template", + "displayNameTemplateLabel": "Namespace display name template", + "placeholderHint": "Placeholders: {{placeholders}}", + "slugPreview": "Example slug: {{slug}}", + "slugRulesHint": "Slugs are lowercased, and anything other than a letter or digit becomes a hyphen. A number is appended when the slug is already taken or reserved.", + "displayNamePreview": "Example display name: {{displayName}}", + "loading": "Loading...", + "saveAction": "Save", + "saving": "Saving...", + "saveSuccessTitle": "Settings saved", + "saveSuccessDescription": "Accounts activated from now on use the updated policy.", + "saveErrorTitle": "Could not save settings", + "validationTitle": "Check the form", + "validationTemplateRequired": "Templates cannot be empty.", + "fallbackErrorDescription": "Please try again." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e2846cae..4d75f11a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1338,7 +1338,8 @@ "notifications": "通知设置", "accounts": "账号合并", "logout": "退出登录", - "namespacesAdmin": "命名空间管理" + "namespacesAdmin": "命名空间管理", + "platformSettings": "平台设置" } }, "footer": { @@ -1644,5 +1645,29 @@ "unfreezeErrorTitle": "解冻命名空间失败", "archiveErrorTitle": "归档命名空间失败", "restoreErrorTitle": "恢复命名空间失败" + }, + "adminSettings": { + "title": "平台设置", + "subtitle": "作用于整个部署的全局设置。", + "personalNamespaceTitle": "注册时自动创建个人命名空间", + "personalNamespaceDescription": "为每个新激活的账号创建一个专属命名空间:该账号是唯一成员,并拥有所有者角色。已有账号不受影响。", + "enabledLabel": "自动创建", + "enabledOn": "已启用", + "enabledOff": "已禁用", + "slugTemplateLabel": "命名空间标识模板", + "displayNameTemplateLabel": "命名空间显示名模板", + "placeholderHint": "可用占位符:{{placeholders}}", + "slugPreview": "标识示例:{{slug}}", + "slugRulesHint": "标识会转为小写,字母和数字以外的字符会变成连字符;标识已被占用或属于保留字时会自动追加数字后缀。", + "displayNamePreview": "显示名示例:{{displayName}}", + "loading": "加载中...", + "saveAction": "保存", + "saving": "保存中...", + "saveSuccessTitle": "设置已保存", + "saveSuccessDescription": "此后激活的账号将采用新的策略。", + "saveErrorTitle": "保存设置失败", + "validationTitle": "请检查表单", + "validationTemplateRequired": "模板不能为空。", + "fallbackErrorDescription": "请稍后重试。" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx new file mode 100644 index 00000000..33bd4853 --- /dev/null +++ b/web/src/pages/admin/settings.test.tsx @@ -0,0 +1,80 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const usePersonalNamespaceSettingsMock = vi.fn() + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), + } +}) + +vi.mock('@/shared/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/features/admin/use-personal-namespace-settings', () => ({ + usePersonalNamespaceSettings: () => usePersonalNamespaceSettingsMock(), + useUpdatePersonalNamespaceSettings: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +import { AdminSettingsPage, previewSlug, renderTemplate } from './settings' + +describe('previewSlug', () => { + it('lowercases and hyphenates the rendered template', () => { + expect(previewSlug('${username}')).toBe('li-wei') + }) + + it('shows that underscores become hyphens', () => { + expect(previewSlug('${username}_space')).toBe('li-wei-space') + }) + + it('collapses repeated separators and trims the edges', () => { + expect(previewSlug('--${username}...space--')).toBe('li-wei-space') + }) + + it('keeps an unknown placeholder visible instead of dropping it', () => { + expect(renderTemplate('${nickname}')).toBe('${nickname}') + }) + + it('renders the email prefix placeholder', () => { + expect(previewSlug('${email_prefix}')).toBe('li-wei') + }) +}) + +describe('AdminSettingsPage', () => { + beforeEach(() => { + usePersonalNamespaceSettingsMock.mockReturnValue({ + data: { + enabled: true, + slugTemplate: '${username}', + displayNameTemplate: '${username}', + supportedPlaceholders: ['username', 'email_prefix', 'user_id'], + }, + isLoading: false, + }) + }) + + it('renders the personal namespace section', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('adminSettings.personalNamespaceTitle') + expect(html).toContain('adminSettings.slugTemplateLabel') + }) + + it('shows a loading state while the settings are fetched', () => { + usePersonalNamespaceSettingsMock.mockReturnValue({ data: undefined, isLoading: true }) + + const html = renderToStaticMarkup() + + expect(html).toContain('adminSettings.loading') + }) +}) diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx new file mode 100644 index 00000000..5e0e35c2 --- /dev/null +++ b/web/src/pages/admin/settings.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import type { PersonalNamespaceSettingsInput } from '@/api/types' +import { + usePersonalNamespaceSettings, + useUpdatePersonalNamespaceSettings, +} from '@/features/admin/use-personal-namespace-settings' + +/** + * Sample account used for the live template preview. + */ +const PREVIEW_OWNER: Record = { + username: 'Li.Wei', + email_prefix: 'li.wei', + user_id: 'usr_4f9c2a1b', +} + +export function renderTemplate(template: string): string { + return template.replace(/\$\{([a-z_]+)}/g, (match, name: string) => PREVIEW_OWNER[name] ?? match) +} + +/** + * Mirrors the server's slug rules so operators can see the effect of a template — in particular + * that underscores and dots become hyphens — before saving it. + */ +export function previewSlug(template: string): string { + return renderTemplate(template) + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + .replace(/-{2,}/g, '-') +} + +export function AdminSettingsPage() { + const { t } = useTranslation() + const { data: settings, isLoading } = usePersonalNamespaceSettings() + const updateMutation = useUpdatePersonalNamespaceSettings() + + const [form, setForm] = useState({ + enabled: false, + slugTemplate: '${username}', + displayNameTemplate: '${username}', + }) + + useEffect(() => { + if (settings) { + setForm({ + enabled: settings.enabled, + slugTemplate: settings.slugTemplate, + displayNameTemplate: settings.displayNameTemplate, + }) + } + }, [settings]) + + const slugPreview = previewSlug(form.slugTemplate) + const displayNamePreview = renderTemplate(form.displayNameTemplate).trim() + const placeholders = settings?.supportedPlaceholders ?? Object.keys(PREVIEW_OWNER) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!form.slugTemplate.trim() || !form.displayNameTemplate.trim()) { + toast.error(t('adminSettings.validationTitle'), t('adminSettings.validationTemplateRequired')) + return + } + + try { + await updateMutation.mutateAsync(form) + toast.success(t('adminSettings.saveSuccessTitle'), t('adminSettings.saveSuccessDescription')) + } catch (error) { + toast.error( + t('adminSettings.saveErrorTitle'), + error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'), + ) + } + } + + return ( +

+
+

{t('adminSettings.title')}

+

{t('adminSettings.subtitle')}

+
+ + +
+

{t('adminSettings.personalNamespaceTitle')}

+

+ {t('adminSettings.personalNamespaceDescription')} +

+
+ + {isLoading ? ( +
{t('adminSettings.loading')}
+ ) : ( +
+
+ + +
+ +
+ + + setForm((current) => ({ ...current, slugTemplate: event.target.value })) + } + /> +

+ {t('adminSettings.placeholderHint', { placeholders: placeholders.map((name) => `\${${name}}`).join(', ') })} +

+

+ {t('adminSettings.slugPreview', { slug: slugPreview || '—' })} +

+

{t('adminSettings.slugRulesHint')}

+
+ +
+ + + setForm((current) => ({ ...current, displayNameTemplate: event.target.value })) + } + /> +

+ {t('adminSettings.displayNamePreview', { displayName: displayNamePreview || '—' })} +

+
+ +
+ +
+
+ )} +
+
+ ) +} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index bd3f4cba..2139142f 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -195,6 +195,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.namespacesAdmin')} ) : null} + {isSuperAdmin ? ( + + {t('user.menu.platformSettings')} + + ) : null} {isAuditor ? ( {t('user.menu.auditLog')}