From 68f120c5e17142d65a4013c2831074320737df0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Thu, 13 Aug 2026 18:17:02 +0800 Subject: [PATCH 1/7] feat(admin): add system settings storage SkillHub has no mechanism for settings an operator can change without a redeploy: the only per-deployment knobs live in application.yml, and the only stored preferences are per-user notification preferences. Add a generic store. One row holds one setting group serialized as JSON, so a group can gain fields without a schema migration. Reads take the caller's defaults: T get(String settingKey, Class type, T defaults) which gives two properties worth keeping: - A group nobody has overridden has no row, and resolves to whatever the deployment configured. Configuration-file-only deployments keep working exactly as before, and an upgrade changes no behaviour. - A stored document that can no longer be parsed also falls back to the defaults, with a warning. One malformed row must not take down the flows that read settings, such as login. Groups are deserialized with unknown fields ignored so a rolling upgrade can read documents written by a newer node. No consumer yet; the following commit adds the first one. --- .../db/migration/V44__system_setting.sql | 15 +++ .../domain/setting/SystemSetting.java | 70 +++++++++++ .../setting/SystemSettingRepository.java | 8 ++ .../domain/setting/SystemSettingService.java | 78 +++++++++++++ .../skillhub/domain/setting/package-info.java | 10 ++ .../setting/SystemSettingServiceTest.java | 110 ++++++++++++++++++ .../infra/jpa/SystemSettingJpaRepository.java | 13 +++ 7 files changed, 304 insertions(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql new file mode 100644 index 000000000..11f01954b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql @@ -0,0 +1,15 @@ +-- V44__system_setting.sql +-- +-- Operator-configurable platform settings. +-- +-- Each row holds one setting group as a JSON document so a group can gain +-- fields without a schema migration. A row is written only when an operator +-- overrides a group; an absent row means "use the configured defaults", which +-- keeps configuration-file-only deployments working unchanged. + +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 +); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java new file mode 100644 index 000000000..2abc026b1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.domain.setting; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; + +/** + * One operator-configurable setting group, stored as a JSON document. + */ +@Entity +@Table(name = "system_setting") +public class SystemSetting { + + @Id + @Column(name = "setting_key", nullable = false, length = 128) + private String settingKey; + + @Column(name = "setting_value", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private String settingValue; + + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected SystemSetting() { + } + + public SystemSetting(String settingKey, String settingValue, String updatedBy, Instant updatedAt) { + this.settingKey = settingKey; + this.settingValue = settingValue; + this.updatedBy = updatedBy; + this.updatedAt = updatedAt; + } + + public String getSettingKey() { + return settingKey; + } + + public String getSettingValue() { + return settingValue; + } + + public void setSettingValue(String settingValue) { + this.settingValue = settingValue; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java new file mode 100644 index 000000000..ee6522cf8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.setting; + +import java.util.Optional; + +public interface SystemSettingRepository { + Optional findBySettingKey(String settingKey); + SystemSetting save(SystemSetting setting); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java new file mode 100644 index 000000000..6badc519b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.domain.setting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +/** + * Reads and writes operator-configurable setting groups. + * + *

Every read carries the caller's defaults so that a deployment which has never touched a group + * — or which configures it entirely through {@code application.yml} — behaves exactly as it did + * before the group existed. + */ +@Service +public class SystemSettingService { + + private static final Logger log = LoggerFactory.getLogger(SystemSettingService.class); + + private final SystemSettingRepository systemSettingRepository; + private final ObjectMapper objectMapper; + private final Clock clock; + + public SystemSettingService(SystemSettingRepository systemSettingRepository, + ObjectMapper objectMapper, + Clock clock) { + this.systemSettingRepository = systemSettingRepository; + this.objectMapper = objectMapper; + this.clock = clock; + } + + /** + * Returns the stored group, or {@code defaults} when the group has never been overridden. + * + *

A stored document that can no longer be parsed also falls back to {@code defaults}: a + * malformed row must not take down the flows that read settings, such as login. + */ + @Transactional(readOnly = true) + public T get(String settingKey, Class type, T defaults) { + Optional stored = systemSettingRepository.findBySettingKey(settingKey); + if (stored.isEmpty()) { + return defaults; + } + try { + return objectMapper.readValue(stored.get().getSettingValue(), type); + } catch (Exception e) { + log.warn("Falling back to defaults for system setting '{}': stored value is not readable as {}", + settingKey, type.getSimpleName(), e); + return defaults; + } + } + + /** + * Overwrites a setting group and records who changed it. + */ + @Transactional + public T put(String settingKey, T value, String updatedBy) { + String json; + try { + json = objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalArgumentException("System setting '" + settingKey + "' is not serializable", e); + } + Instant now = Instant.now(clock); + SystemSetting setting = systemSettingRepository.findBySettingKey(settingKey) + .orElseGet(() -> new SystemSetting(settingKey, json, updatedBy, now)); + setting.setSettingValue(json); + setting.setUpdatedBy(updatedBy); + setting.setUpdatedAt(now); + systemSettingRepository.save(setting); + return value; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java new file mode 100644 index 000000000..6f4038ce7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java @@ -0,0 +1,10 @@ +/** + * Operator-configurable platform settings. + * + *

Settings are grouped: one {@link com.iflytek.skillhub.domain.setting.SystemSetting} row holds + * one group serialized as JSON. Callers read a group through + * {@link com.iflytek.skillhub.domain.setting.SystemSettingService} with a typed default, so a group + * that has never been overridden resolves to the deployment's configured defaults rather than to + * {@code null}. + */ +package com.iflytek.skillhub.domain.setting; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java new file mode 100644 index 000000000..581fd3fd1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.domain.setting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +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 java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SystemSettingServiceTest { + + private static final String KEY = "demo.group"; + private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z"); + + @JsonIgnoreProperties(ignoreUnknown = true) + record DemoSettings(boolean enabled, String template) { + } + + @Mock + private SystemSettingRepository systemSettingRepository; + + private SystemSettingService service; + + @BeforeEach + void setUp() { + service = new SystemSettingService( + systemSettingRepository, + new ObjectMapper(), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void getReturnsDefaultsWhenGroupWasNeverOverridden() { + DemoSettings defaults = new DemoSettings(false, "${username}"); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty()); + + assertSame(defaults, service.get(KEY, DemoSettings.class, defaults)); + } + + @Test + void getReturnsStoredGroupWhenPresent() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"${username}-space\"}", "usr_1", NOW))); + + DemoSettings resolved = service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}")); + + assertEquals(new DemoSettings(true, "${username}-space"), resolved); + } + + @Test + void getIgnoresUnknownFieldsSoOlderNodesCanReadNewerDocuments() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"x\",\"addedLater\":42}", "usr_1", NOW))); + + assertEquals(new DemoSettings(true, "x"), + service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}"))); + } + + @Test + void getFallsBackToDefaultsWhenStoredDocumentIsMalformed() { + DemoSettings defaults = new DemoSettings(false, "${username}"); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "not json", "usr_1", NOW))); + + assertSame(defaults, service.get(KEY, DemoSettings.class, defaults)); + } + + @Test + void putStoresSerializedGroupWithActorAndTimestamp() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty()); + + service.put(KEY, new DemoSettings(true, "${username}"), "usr_admin"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SystemSetting.class); + verify(systemSettingRepository).save(captor.capture()); + SystemSetting saved = captor.getValue(); + assertEquals(KEY, saved.getSettingKey()); + assertEquals("{\"enabled\":true,\"template\":\"${username}\"}", saved.getSettingValue()); + assertEquals("usr_admin", saved.getUpdatedBy()); + assertEquals(NOW, saved.getUpdatedAt()); + } + + @Test + void putOverwritesExistingRowInPlace() { + SystemSetting existing = new SystemSetting(KEY, "{\"enabled\":false,\"template\":\"old\"}", + "usr_previous", NOW.minusSeconds(60)); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(existing)); + + service.put(KEY, new DemoSettings(true, "new"), "usr_admin"); + + verify(systemSettingRepository).save(any(SystemSetting.class)); + assertEquals("{\"enabled\":true,\"template\":\"new\"}", existing.getSettingValue()); + assertEquals("usr_admin", existing.getUpdatedBy()); + assertEquals(NOW, existing.getUpdatedAt()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java new file mode 100644 index 000000000..82d4754a1 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.setting.SystemSetting; +import com.iflytek.skillhub.domain.setting.SystemSettingRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SystemSettingJpaRepository extends JpaRepository, SystemSettingRepository { + Optional findBySettingKey(String settingKey); +} From ae23d1a051e4a460a8974a1091c30304bdb31bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Thu, 13 Aug 2026 18:17:25 +0800 Subject: [PATCH 2/7] feat(namespace): auto-provision a personal namespace on registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted deployments want every new account to have somewhere of its own to publish, without asking an administrator for a namespace first and without pushing drafts into `global`. Add an operator-controlled policy, off by default so upgrading changes no behaviour. When enabled, an account that becomes usable gets a namespace it owns. "Private" here means a team namespace whose only member is that account: namespaces have no visibility flag, and skill visibility stays a property of each skill. Trigger points. UserActivatedEvent is published wherever an account first becomes usable: - LocalAuthService.register - IdentityBindingService.bindOrCreate, for ACTIVE first logins - AdminUserAppService.updateUserStatus, on a transition into ACTIVE The third matters for deployments that gate access behind approval: those accounts are created PENDING at the first OAuth attempt and only become usable when an administrator approves them. Why an AFTER_COMMIT listener rather than a call alongside GlobalNamespaceMembershipService.ensureMember. Both namespace.created_by and namespace_member.user_id reference user_account(id), which rules out each obvious alternative: - Joining the registration transaction lets a slug clash roll the registration back, so a namespace failure costs the user their account — or, on OAuth, their login. - Suspending it with REQUIRES_NEW leaves the new transaction unable to see the uncommitted user_account row, so the foreign key check blocks on the outer transaction's row lock and the two wait on each other. Provisioning after commit avoids both. The listener is deliberately not @Async, so the namespace exists by the time the user's next request arrives, and it swallows failures. Naming. Two templates over ${username}, ${email_prefix} and ${user_id}; unknown placeholders are left in place so a typo is visible rather than silently dropped. ${username} falls back to the email local part and then to the user id. Slugs go through the existing slug rules, which is why the console renders a live preview: underscores are not legal in a slug, so `${username}_space` yields `alice-space`. A taken or reserved candidate gets a numeric suffix, so `admin` becomes `admin-2`. Owning any non-global namespace already skips provisioning, which keeps re-enabling an account from handing out a second one. The templates are not exposed in application.yml: they contain ${...}, which Spring would resolve as property references, and Boot 3.2 predates placeholder escaping. Only the enable flag lives there; templates are set in the console and default from PersonalNamespaceProvisioningProperties. Updating the policy writes an audit entry with the before and after. --- docs/06-api-design.md | 9 + ...6-08-13-personal-namespace-provisioning.md | 132 +++++++++++++ .../admin/AdminSystemSettingController.java | 51 ++++++ .../PersonalNamespaceSettingsResponse.java | 14 ++ ...ersonalNamespaceSettingsUpdateRequest.java | 11 ++ ...PersonalNamespaceProvisioningListener.java | 40 ++++ .../skillhub/service/AdminUserAppService.java | 12 +- .../PersonalNamespaceSettingsAppService.java | 108 +++++++++++ .../src/main/resources/application.yml | 9 + .../service/AdminUserAppServiceTest.java | 5 +- ...rsonalNamespaceSettingsAppServiceTest.java | 120 ++++++++++++ .../auth/identity/IdentityBindingService.java | 9 +- .../skillhub/auth/local/LocalAuthService.java | 8 +- .../identity/IdentityBindingServiceTest.java | 46 ++++- .../auth/local/LocalAuthServiceTest.java | 24 ++- .../domain/event/UserActivatedEvent.java | 13 ++ .../namespace/PersonalNamespaceNaming.java | 104 +++++++++++ .../namespace/PersonalNamespaceOwner.java | 10 + ...rsonalNamespaceProvisioningProperties.java | 58 ++++++ .../PersonalNamespaceProvisioningService.java | 122 ++++++++++++ .../namespace/PersonalNamespaceSettings.java | 23 +++ .../domain/namespace/SlugValidator.java | 31 +++- .../PersonalNamespaceNamingTest.java | 83 +++++++++ ...sonalNamespaceProvisioningServiceTest.java | 173 ++++++++++++++++++ web/src/api/client.ts | 20 ++ web/src/api/generated/schema.d.ts | 80 ++++++++ web/src/api/types.ts | 13 ++ web/src/app/router.tsx | 13 ++ .../admin/use-personal-namespace-settings.ts | 24 +++ web/src/i18n/locales/en.json | 27 ++- web/src/i18n/locales/zh.json | 27 ++- web/src/pages/admin/settings.test.tsx | 80 ++++++++ web/src/pages/admin/settings.tsx | 169 +++++++++++++++++ web/src/shared/components/user-menu.tsx | 5 + 34 files changed, 1662 insertions(+), 11 deletions(-) create mode 100644 docs/2026-08-13-personal-namespace-provisioning.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java create mode 100644 web/src/features/admin/use-personal-namespace-settings.ts create mode 100644 web/src/pages/admin/settings.test.tsx create mode 100644 web/src/pages/admin/settings.tsx diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb55418..6a111f074 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 000000000..d43ca8197 --- /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 000000000..d6ec50e2e --- /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 000000000..d09dbd8c8 --- /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 000000000..331d6a4c7 --- /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 000000000..94f99d11b --- /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 b3e1bdc74..703dbdc59 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.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.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 AdminUserMutationResponse updateUserStatus(String userId, String status) 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 000000000..b9a32784e --- /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 10e9638eb..e7abdd12e 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 8296f940e..4ea162a92 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.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 000000000..a0bf3c3fb --- /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 2a4fae8b3..8e9224311 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.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 PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat 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 df0b1867b..5d0dad226 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.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.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 LocalAuthService(LocalCredentialRepository credentialRepository, 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 LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.eventPublisher = eventPublisher; } /** @@ -100,6 +105,7 @@ public PlatformPrincipal register(String username, String password, String email 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 79e5df746..7c9281c32 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.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.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 @@ void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { 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 b6eaf5afc..f11b91162 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.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.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 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + eventPublisher ); } @@ -86,6 +92,22 @@ void register_createsUserAndCredential() { 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 000000000..4af259bd1 --- /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 000000000..5a9619d39 --- /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 000000000..8ae5b395d --- /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 000000000..ec0ca6e50 --- /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 000000000..6e61b5337 --- /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 000000000..62f817d7c --- /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 9417325f7..d4ffaca1e 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 static String slugify(String raw) { 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 000000000..655378e39 --- /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 000000000..5aca736d5 --- /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 5e4c77567..972d013f2 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 3c4f5b8e2..482e6c1e7 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 3f9663e67..c2d0c2e10 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 bb1aac30c..3f8c6196a 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 000000000..5864c8317 --- /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 dcfc28bfe..0127214eb 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 e2846cae8..4d75f11a3 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 000000000..33bd48537 --- /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 000000000..5e0e35c25 --- /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 bd3f4cba0..2139142fa 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')} From 2d50437e4f2882ffaf5e3b7c7f72e85145ef2101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Sat, 15 Aug 2026 01:11:51 +0800 Subject: [PATCH 3/7] feat(namespace): backfill personal namespaces for existing accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning provisioning on only affects accounts activated afterwards, which on a registry that has already been running means nobody. The first person to hit this on our deployment was the operator who enabled it: they signed in, got no namespace, and had no way to find out why. Two fixes. Backfill. POST /api/v1/admin/settings/personal-namespace/backfill walks the active accounts and gives a namespace to those without one, skipping system accounts and anyone who already owns a non-global namespace. Details worth knowing: - dryRun reports the plan — each account and the slug it would take — without writing. The console requires a preview before it will enable the apply button. - The response lists only accounts that changed or could not be placed; the rest are counted, so an operator reads the work rather than the whole directory. - A run stops at a per-run account cap and reports truncated rather than looking like it covered everything. - Slugs promised earlier in a run are reserved, so one batch cannot hand the same slug to two accounts. - Not @Transactional: each namespace is created in its own transaction, so an account that cannot be placed does not discard the rest of the run. Diagnosability. The skip paths — provisioning disabled, account already owns a namespace, no slug available — were silent returns, which is why "nothing happened and I cannot tell why" was the actual user experience. They now log their reason; account activation is rare enough that the extra lines cost nothing. --- docs/06-api-design.md | 1 + ...6-08-13-personal-namespace-provisioning.md | 23 ++++ .../admin/AdminSystemSettingController.java | 17 +++ .../dto/PersonalNamespaceBackfillRequest.java | 8 ++ .../PersonalNamespaceBackfillResponse.java | 21 +++ .../PersonalNamespaceSettingsAppService.java | 56 ++++++++ ...rsonalNamespaceSettingsAppServiceTest.java | 51 ++++++++ .../PersonalNamespaceBackfillEntry.java | 22 ++++ .../PersonalNamespaceBackfillReport.java | 21 +++ .../PersonalNamespaceProvisioningService.java | 120 +++++++++++++++++- ...sonalNamespaceProvisioningServiceTest.java | 110 +++++++++++++++- web/src/api/client.ts | 12 ++ web/src/api/generated/schema.d.ts | 69 ++++++++++ web/src/api/types.ts | 16 +++ .../admin/use-personal-namespace-settings.ts | 19 ++- web/src/i18n/locales/en.json | 21 ++- web/src/i18n/locales/zh.json | 21 ++- web/src/pages/admin/settings.test.tsx | 17 +++ web/src/pages/admin/settings.tsx | 103 ++++++++++++++- 19 files changed, 719 insertions(+), 9 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6a111f074..579b62696 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -336,6 +336,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/settings/personal-namespace` | 读取「新账号自动建命名空间」策略 | | PUT | `/api/v1/admin/settings/personal-namespace` | 更新该策略(写审计日志) | +| POST | `/api/v1/admin/settings/personal-namespace/backfill` | 为已有账号补建;`dryRun=true` 只返回计划,不写库 | 详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。 diff --git a/docs/2026-08-13-personal-namespace-provisioning.md b/docs/2026-08-13-personal-namespace-provisioning.md index d43ca8197..c09487387 100644 --- a/docs/2026-08-13-personal-namespace-provisioning.md +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -105,6 +105,29 @@ slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。 解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。 +### 已有账号的补建 + +只在「账号第一次变得可用」触发有个后果:**在一个已经跑了一段时间的部署上打开开关,等于对现有的人全部无效**。 +这不是理论问题——本功能上线后第一个来问「为什么我没有 namespace」的,就是站点管理员自己。 + +所以提供 `POST /api/v1/admin/settings/personal-namespace/backfill`: + +- 遍历 ACTIVE 账号,跳过系统账号和已拥有非 global 命名空间的账号 +- `dryRun=true` 时只返回计划(每个账号将拿到的 slug),不写任何东西; + 控制台强制先预览、后执行 +- 返回体只列出「会被改动」和「放不下」的账号,其余只给计数—— + 管理员看到的是待办,不是整个通讯录 +- 单次运行有账号数上限,达到上限时返回 `truncated=true` 而不是假装跑完了 +- 一次运行内已经许诺出去的 slug 会被预留,避免同一批里把同一个 slug 发给两个人 +- **不加 `@Transactional`**:每个命名空间各自一个事务, + 某个账号放不下不会把整批已建好的回滚掉 + +### 可诊断性 + +三条跳过路径——开关关闭、账号已有命名空间、没有可用 slug——都记 INFO/WARN 日志。 +最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。 +账号激活本身是低频事件,多两行日志的代价可以忽略。 + ## 三、配置 | 位置 | 项 | 默认 | 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 index d6ec50e2e..4b4735de2 100644 --- 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 @@ -4,6 +4,8 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; import com.iflytek.skillhub.service.AuditRequestContext; @@ -13,6 +15,7 @@ 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.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -48,4 +51,18 @@ public ApiResponse updatePersonalNamespaceSet return ok("response.success.updated", personalNamespaceSettingsAppService.update( request, principal.userId(), AuditRequestContext.from(httpRequest))); } + + /** + * Gives existing accounts the namespace they would have received had provisioning been on when + * they first signed in. Send {@code dryRun} to see the plan first. + */ + @PostMapping("/personal-namespace/backfill") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse backfillPersonalNamespaces( + @Valid @RequestBody PersonalNamespaceBackfillRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success", personalNamespaceSettingsAppService.backfill( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java new file mode 100644 index 000000000..04df3456c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * @param dryRun when true, report the accounts that would get a namespace without creating any + */ +public record PersonalNamespaceBackfillRequest(@NotNull Boolean dryRun) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java new file mode 100644 index 000000000..4b81391ab --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were changed, or could not be placed + */ +public record PersonalNamespaceBackfillResponse( + boolean dryRun, + int scannedAccounts, + int alreadyProvisioned, + int systemAccountsSkipped, + boolean truncated, + List entries) { + + /** + * @param outcome one of {@code PLANNED}, {@code CREATED}, {@code NO_SLUG} + */ + public record Entry(String userId, String displayName, String slug, String outcome) {} +} 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 index b9a32784e..7f3868e96 100644 --- 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 @@ -2,8 +2,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillEntry; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; import com.iflytek.skillhub.observability.RequestIdAccessor; @@ -22,6 +26,7 @@ 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 String AUDIT_ACTION_BACKFILL = "SYSTEM_SETTING_PERSONAL_NAMESPACE_BACKFILL"; private static final List SUPPORTED_PLACEHOLDERS = List.of( PersonalNamespaceSettings.PLACEHOLDER_USERNAME, @@ -64,6 +69,57 @@ public PersonalNamespaceSettingsResponse update(PersonalNamespaceSettingsUpdateR return toResponse(updated); } + /** + * Runs the backfill over existing accounts. A dry run writes nothing and is not audited; an + * applied run records what it created. + */ + public PersonalNamespaceBackfillResponse backfill(PersonalNamespaceBackfillRequest request, + String actorUserId, + AuditRequestContext auditContext) { + boolean dryRun = Boolean.TRUE.equals(request.dryRun()); + PersonalNamespaceBackfillReport report = personalNamespaceProvisioningService.backfill(dryRun); + + if (!dryRun) { + recordBackfillAudit(actorUserId, auditContext, report); + } + return new PersonalNamespaceBackfillResponse( + report.dryRun(), + report.scannedAccounts(), + report.alreadyProvisioned(), + report.systemAccountsSkipped(), + report.truncated(), + report.entries().stream() + .map(entry -> new PersonalNamespaceBackfillResponse.Entry( + entry.userId(), entry.displayName(), entry.slug(), entry.outcome().name())) + .toList()); + } + + private void recordBackfillAudit(String actorUserId, + AuditRequestContext auditContext, + PersonalNamespaceBackfillReport report) { + Map detail = new LinkedHashMap<>(); + detail.put("scannedAccounts", report.scannedAccounts()); + detail.put("alreadyProvisioned", report.alreadyProvisioned()); + detail.put("truncated", report.truncated()); + detail.put("created", report.entries().stream() + .filter(entry -> entry.outcome() == PersonalNamespaceBackfillEntry.Outcome.CREATED) + .map(entry -> Map.of("userId", entry.userId(), "slug", entry.slug())) + .toList()); + detail.put("unplaced", report.entries().stream() + .filter(entry -> entry.outcome() == PersonalNamespaceBackfillEntry.Outcome.NO_SLUG) + .map(PersonalNamespaceBackfillEntry::userId) + .toList()); + auditLogService.record( + actorUserId, + AUDIT_ACTION_BACKFILL, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + private PersonalNamespaceSettingsResponse toResponse(PersonalNamespaceSettings settings) { return new PersonalNamespaceSettingsResponse( settings.enabled(), 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 index a0bf3c3fb..eedc14957 100644 --- 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 @@ -2,8 +2,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillEntry; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; import com.iflytek.skillhub.observability.RequestIdAccessor; @@ -20,9 +24,12 @@ 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; +import java.util.List; + @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class PersonalNamespaceSettingsAppServiceTest { @@ -105,6 +112,50 @@ void updateRecordsAnAuditEntryWithBeforeAndAfter() { .contains("${username}-space"); } + @Test + void backfillDryRunIsNotAudited() { + when(personalNamespaceProvisioningService.backfill(true)).thenReturn( + new PersonalNamespaceBackfillReport(true, 3, 1, 0, false, List.of( + new PersonalNamespaceBackfillEntry("usr_1", "alice", "alice", + PersonalNamespaceBackfillEntry.Outcome.PLANNED)))); + + PersonalNamespaceBackfillResponse response = service.backfill( + new PersonalNamespaceBackfillRequest(true), "usr_admin", null); + + assertThat(response.dryRun()).isTrue(); + assertThat(response.entries()).singleElement() + .satisfies(entry -> assertThat(entry.outcome()).isEqualTo("PLANNED")); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void backfillRecordsWhatItCreated() { + when(personalNamespaceProvisioningService.backfill(false)).thenReturn( + new PersonalNamespaceBackfillReport(false, 3, 1, 0, false, List.of( + new PersonalNamespaceBackfillEntry("usr_1", "alice", "alice", + PersonalNamespaceBackfillEntry.Outcome.CREATED), + new PersonalNamespaceBackfillEntry("usr_2", "admin", null, + PersonalNamespaceBackfillEntry.Outcome.NO_SLUG)))); + + service.backfill(new PersonalNamespaceBackfillRequest(false), "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_BACKFILL"), + eq("SYSTEM_SETTING"), + isNull(), + eq("req-1"), + eq("10.0.0.1"), + eq("curl/8"), + detailCaptor.capture()); + assertThat(detailCaptor.getValue()) + .contains("\"scannedAccounts\":3") + .contains("usr_1") + .contains("\"unplaced\":[\"usr_2\"]"); + } + @Test void updateToleratesAMissingAuditContext() { when(personalNamespaceProvisioningService.currentSettings()) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java new file mode 100644 index 000000000..944c273f9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.domain.namespace; + +/** + * One account a backfill run acted on, or wanted to act on. + * + * @param slug the slug that was taken, or would be; {@code null} when none was available + */ +public record PersonalNamespaceBackfillEntry( + String userId, + String displayName, + String slug, + Outcome outcome) { + + public enum Outcome { + /** Dry run: this account would get {@code slug}. */ + PLANNED, + /** The namespace was created. */ + CREATED, + /** Every candidate slug was taken or rejected, so the account was left alone. */ + NO_SLUG + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java new file mode 100644 index 000000000..a84e2e319 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * Outcome of a backfill run over existing accounts. + * + *

{@code entries} lists only the accounts a run would change or could not place, so an operator + * reads the work rather than the whole directory; accounts that already have a namespace are + * counted instead. + * + * @param truncated whether the run stopped at its per-run account cap, leaving accounts unvisited + */ +public record PersonalNamespaceBackfillReport( + boolean dryRun, + int scannedAccounts, + int alreadyProvisioned, + int systemAccountsSkipped, + boolean truncated, + List entries) { +} 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 index 6e61b5337..b5b3bf817 100644 --- 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 @@ -1,13 +1,23 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; import java.util.Optional; +import java.util.Set; /** * Gives each newly activated account a namespace of its own, when the operator has asked for it. @@ -34,6 +44,15 @@ public class PersonalNamespaceProvisioningService { */ private static final int MAX_SLUG_ATTEMPTS = 64; + /** + * Per-run account cap, so an unexpectedly large directory cannot turn one click into an + * unbounded job. A run that hits it reports {@code truncated} rather than pretending it + * covered everything. + */ + private static final int MAX_BACKFILL_ACCOUNTS = 5000; + + private static final int BACKFILL_PAGE_SIZE = 200; + private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningService.class); private final SystemSettingService systemSettingService; @@ -41,17 +60,20 @@ public class PersonalNamespaceProvisioningService { private final NamespaceService namespaceService; private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService, PersonalNamespaceProvisioningProperties defaults, NamespaceService namespaceService, NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.systemSettingService = systemSettingService; this.defaults = defaults; this.namespaceService = namespaceService; this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } /** @@ -74,13 +96,16 @@ public PersonalNamespaceSettings updateSettings(PersonalNamespaceSettings settin public Optional provisionFor(PersonalNamespaceOwner owner) { PersonalNamespaceSettings settings = currentSettings(); if (!settings.enabled()) { + log.info("Skipping personal namespace for user {}: provisioning is disabled", owner.userId()); return Optional.empty(); } if (alreadyOwnsNamespace(owner.userId())) { + log.info("Skipping personal namespace for user {}: already owns a non-global namespace", + owner.userId()); return Optional.empty(); } - String slug = allocateSlug(settings.slugTemplate(), owner); + String slug = allocateSlug(settings.slugTemplate(), owner, Set.of()); if (slug == null) { log.warn("No namespace slug available for user {} from template '{}'; skipping provisioning", owner.userId(), settings.slugTemplate()); @@ -93,6 +118,88 @@ public Optional provisionFor(PersonalNamespaceOwner owner) { return Optional.of(namespace); } + /** + * Gives existing accounts the namespace they would have received had provisioning been on when + * they first signed in. + * + *

Turning the setting on only affects accounts activated afterwards, which on a registry + * that has been running for a while means nobody. This walks the active accounts and fills the + * gap. + * + *

Deliberately not {@code @Transactional}: each namespace is created in its own transaction, + * so one account that cannot be placed does not discard the rest of the run. + * + * @param dryRun report what would happen without writing anything + */ + public PersonalNamespaceBackfillReport backfill(boolean dryRun) { + PersonalNamespaceSettings settings = currentSettings(); + List entries = new ArrayList<>(); + Set reserved = new HashSet<>(); + int scanned = 0; + int alreadyProvisioned = 0; + int systemAccounts = 0; + boolean truncated = false; + + for (int page = 0; !truncated; page++) { + Page batch = userAccountRepository.search(null, UserStatus.ACTIVE, + PageRequest.of(page, BACKFILL_PAGE_SIZE, Sort.by("id"))); + if (batch.isEmpty()) { + break; + } + for (UserAccount user : batch) { + if (scanned >= MAX_BACKFILL_ACCOUNTS) { + truncated = true; + break; + } + scanned++; + if (user.isSystemAccount()) { + systemAccounts++; + continue; + } + if (alreadyOwnsNamespace(user.getId())) { + alreadyProvisioned++; + continue; + } + entries.add(placeAccount(user, settings, reserved, dryRun)); + } + if (!batch.hasNext()) { + break; + } + } + + log.info("Personal namespace backfill ({}): scanned {}, already provisioned {}, acted on {}{}", + dryRun ? "dry run" : "applied", scanned, alreadyProvisioned, entries.size(), + truncated ? ", stopped at the per-run cap" : ""); + return new PersonalNamespaceBackfillReport( + dryRun, scanned, alreadyProvisioned, systemAccounts, truncated, List.copyOf(entries)); + } + + private PersonalNamespaceBackfillEntry placeAccount(UserAccount user, + PersonalNamespaceSettings settings, + Set reserved, + boolean dryRun) { + PersonalNamespaceOwner owner = + new PersonalNamespaceOwner(user.getId(), user.getDisplayName(), user.getEmail()); + String slug = allocateSlug(settings.slugTemplate(), owner, reserved); + if (slug == null) { + log.warn("Backfill found no available slug for user {} from template '{}'", + user.getId(), settings.slugTemplate()); + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), null, + PersonalNamespaceBackfillEntry.Outcome.NO_SLUG); + } + reserved.add(slug); + if (dryRun) { + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), slug, + PersonalNamespaceBackfillEntry.Outcome.PLANNED); + } + + String displayName = PersonalNamespaceNaming.displayName(settings.displayNameTemplate(), owner, slug); + namespaceService.createNamespace(slug, displayName, null, user.getId()); + log.info("Backfilled personal namespace '{}' for user {}", slug, user.getId()); + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), slug, + PersonalNamespaceBackfillEntry.Outcome.CREATED); + } + /** * 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. @@ -108,12 +215,17 @@ private boolean alreadyOwnsNamespace(String userId) { /** * 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. + * + * @param reserved slugs already promised to earlier owners in this run but not yet persisted, + * so a batch cannot hand the same slug to two accounts */ - private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner) { + private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner, Set reserved) { 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()) { + if (!reserved.contains(candidate) + && SlugValidator.isValid(candidate) + && namespaceRepository.findBySlug(candidate).isEmpty()) { return candidate; } } 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 index 5aca736d5..559ff2717 100644 --- 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 @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; 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 org.springframework.data.domain.PageImpl; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -37,6 +42,9 @@ class PersonalNamespaceProvisioningServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private UserAccountRepository userAccountRepository; + private PersonalNamespaceProvisioningService service; @BeforeEach @@ -46,7 +54,17 @@ void setUp() { new PersonalNamespaceProvisioningProperties(), namespaceService, namespaceRepository, - namespaceMemberRepository); + namespaceMemberRepository, + userAccountRepository); + } + + private UserAccount account(String id, String displayName, String email) { + return new UserAccount(id, displayName, email, null); + } + + private void directoryContains(UserAccount... users) { + when(userAccountRepository.search(isNull(), eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(users))); } private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) { @@ -160,6 +178,96 @@ void plainMembershipDoesNotCountAsOwningANamespace() { verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); } + @Test + void backfillDryRunPlansWithoutCreatingAnything() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertTrue(report.dryRun()); + assertEquals(1, report.scannedAccounts()); + assertEquals(1, report.entries().size()); + assertEquals("alice", report.entries().getFirst().slug()); + assertEquals(PersonalNamespaceBackfillEntry.Outcome.PLANNED, report.entries().getFirst().outcome()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillCreatesNamespacesForAccountsThatHaveNone() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + namespaceCreationSucceeds(); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertFalse(report.dryRun()); + assertEquals(PersonalNamespaceBackfillEntry.Outcome.CREATED, report.entries().getFirst().outcome()); + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void backfillCountsAccountsThatAlreadyHaveANamespaceInsteadOfListingThem() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")) + .thenReturn(List.of(new NamespaceMember(7L, "usr_alice", NamespaceRole.OWNER))); + when(namespaceRepository.findById(7L)) + .thenReturn(Optional.of(new Namespace("alice", "Alice", "usr_alice"))); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.alreadyProvisioned()); + assertTrue(report.entries().isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillLeavesSystemAccountsAlone() { + withSettings(true, "${username}", "${username}"); + directoryContains(UserAccount.systemAccount( + "builtin-skill-publisher", "Built-in Skill Publisher", null, null)); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.systemAccountsSkipped()); + assertTrue(report.entries().isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillDoesNotPromiseTheSameSlugToTwoAccountsInOneRun() { + withSettings(true, "${username}", "${username}"); + directoryContains( + account("usr_1", "alice", "alice@example.com"), + account("usr_2", "Alice", "alice2@example.com")); + when(namespaceMemberRepository.findByUserId(any())).thenReturn(List.of()); + when(namespaceRepository.findBySlug(any())).thenReturn(Optional.empty()); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertEquals(List.of("alice", "alice-2"), + report.entries().stream().map(PersonalNamespaceBackfillEntry::slug).toList()); + } + + @Test + void backfillReportsAccountsItCannotPlace() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug(any())) + .thenReturn(Optional.of(new Namespace("taken", "Taken", "usr_x"))); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertEquals(PersonalNamespaceBackfillEntry.Outcome.NO_SLUG, report.entries().getFirst().outcome()); + assertEquals(null, report.entries().getFirst().slug()); + } + @Test void givesUpQuietlyWhenEveryCandidateSlugIsTaken() { withSettings(true, "${username}", "${username}"); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 972d013f2..906947e3f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -49,6 +49,7 @@ import type { BatchMemberResponse, PersonalNamespaceSettings, PersonalNamespaceSettingsInput, + PersonalNamespaceBackfillResult, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1463,6 +1464,17 @@ export const adminApi = { }), }) }, + + async backfillPersonalNamespaces(dryRun: boolean): Promise { + return fetchJson( + '/api/v1/admin/settings/personal-namespace/backfill', + { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ dryRun }), + }, + ) + }, } export const notificationApi = { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 482e6c1e7..1bfb84d39 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1588,6 +1588,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/personal-namespace/backfill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillPersonalNamespaces"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/search/rebuild": { parameters: { query?: never; @@ -4169,6 +4185,35 @@ export interface components { comment?: string; disposition?: string; }; + PersonalNamespaceBackfillRequest: { + dryRun: boolean; + }; + ApiResponsePersonalNamespaceBackfillResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PersonalNamespaceBackfillResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Entry: { + userId?: string; + displayName?: string; + slug?: string; + outcome?: string; + }; + PersonalNamespaceBackfillResponse: { + dryRun?: boolean; + /** Format: int32 */ + scannedAccounts?: number; + /** Format: int32 */ + alreadyProvisioned?: number; + /** Format: int32 */ + systemAccountsSkipped?: number; + truncated?: boolean; + entries?: components["schemas"]["Entry"][]; + }; ProfileReviewRejectRequest: { comment: string; }; @@ -8626,6 +8671,30 @@ export interface operations { }; }; }; + backfillPersonalNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonalNamespaceBackfillRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceBackfillResponse"]; + }; + }; + }; + }; rebuildAll: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c2d0c2e10..9733263a2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -596,3 +596,19 @@ export interface PersonalNamespaceSettingsInput { slugTemplate: string displayNameTemplate: string } + +export interface PersonalNamespaceBackfillEntry { + userId: string + displayName: string | null + slug: string | null + outcome: 'PLANNED' | 'CREATED' | 'NO_SLUG' +} + +export interface PersonalNamespaceBackfillResult { + dryRun: boolean + scannedAccounts: number + alreadyProvisioned: number + systemAccountsSkipped: number + truncated: boolean + entries: PersonalNamespaceBackfillEntry[] +} diff --git a/web/src/features/admin/use-personal-namespace-settings.ts b/web/src/features/admin/use-personal-namespace-settings.ts index 5864c8317..eca994026 100644 --- a/web/src/features/admin/use-personal-namespace-settings.ts +++ b/web/src/features/admin/use-personal-namespace-settings.ts @@ -1,6 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { adminApi } from '@/api/client' -import type { PersonalNamespaceSettings, PersonalNamespaceSettingsInput } from '@/api/types' +import type { + PersonalNamespaceBackfillResult, + PersonalNamespaceSettings, + PersonalNamespaceSettingsInput, +} from '@/api/types' const QUERY_KEY = ['admin', 'settings', 'personal-namespace'] @@ -22,3 +26,16 @@ export function useUpdatePersonalNamespaceSettings() { }, }) } + +export function useBackfillPersonalNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (dryRun: boolean) => adminApi.backfillPersonalNamespaces(dryRun), + onSuccess: (result) => { + if (!result.dryRun) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] }) + } + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0127214eb..8f5d9cf92 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1668,6 +1668,25 @@ "saveErrorTitle": "Could not save settings", "validationTitle": "Check the form", "validationTemplateRequired": "Templates cannot be empty.", - "fallbackErrorDescription": "Please try again." + "fallbackErrorDescription": "Please try again.", + "backfillTitle": "Existing accounts", + "backfillDescription": "Turning the setting on only affects accounts activated afterwards. Run this once to give the accounts that already exist the namespace they would have received.", + "backfillPreviewAction": "Preview", + "backfillApplyAction": "Create {{count}} namespaces", + "backfillPreviewFirstHint": "Preview first — the create button stays disabled until you do.", + "backfillSummary": "Scanned {{scanned}} accounts · {{already}} already had one · {{acted}} to act on", + "backfillTruncated": "Stopped at the per-run cap. Run it again to continue.", + "backfillNothingToDo": "Every account already has a namespace.", + "backfillColumnUser": "Account", + "backfillColumnSlug": "Slug", + "backfillColumnOutcome": "Outcome", + "backfillOutcome": { + "PLANNED": "Would be created", + "CREATED": "Created", + "NO_SLUG": "No slug available" + }, + "backfillDoneTitle": "Backfill finished", + "backfillDoneDescription": "Created {{count}} namespaces.", + "backfillErrorTitle": "Backfill failed" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 4d75f11a3..74bb82adb 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1668,6 +1668,25 @@ "saveErrorTitle": "保存设置失败", "validationTitle": "请检查表单", "validationTemplateRequired": "模板不能为空。", - "fallbackErrorDescription": "请稍后重试。" + "fallbackErrorDescription": "请稍后重试。", + "backfillTitle": "为现有账号补建", + "backfillDescription": "打开开关只对之后激活的账号生效。已经存在的账号需要执行一次补建,才会拿到本该属于他们的命名空间。", + "backfillPreviewAction": "预览", + "backfillApplyAction": "创建 {{count}} 个命名空间", + "backfillPreviewFirstHint": "请先预览——未预览前创建按钮不可用。", + "backfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已有 · {{acted}} 个待处理", + "backfillTruncated": "已达单次上限而停止,再执行一次可继续。", + "backfillNothingToDo": "所有账号都已经有命名空间了。", + "backfillColumnUser": "账号", + "backfillColumnSlug": "标识", + "backfillColumnOutcome": "结果", + "backfillOutcome": { + "PLANNED": "将创建", + "CREATED": "已创建", + "NO_SLUG": "无可用标识" + }, + "backfillDoneTitle": "补建完成", + "backfillDoneDescription": "已创建 {{count}} 个命名空间。", + "backfillErrorTitle": "补建失败" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx index 33bd48537..58e745861 100644 --- a/web/src/pages/admin/settings.test.tsx +++ b/web/src/pages/admin/settings.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/shared/lib/toast', () => ({ vi.mock('@/features/admin/use-personal-namespace-settings', () => ({ usePersonalNamespaceSettings: () => usePersonalNamespaceSettingsMock(), useUpdatePersonalNamespaceSettings: () => ({ mutateAsync: vi.fn(), isPending: false }), + useBackfillPersonalNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), })) import { AdminSettingsPage, previewSlug, renderTemplate } from './settings' @@ -70,6 +71,22 @@ describe('AdminSettingsPage', () => { expect(html).toContain('adminSettings.slugTemplateLabel') }) + it('offers the backfill for accounts that already exist', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('adminSettings.backfillTitle') + expect(html).toContain('adminSettings.backfillPreviewAction') + }) + + it('keeps the apply button disabled until a preview has been run', () => { + const html = renderToStaticMarkup() + + const applyIndex = html.indexOf('adminSettings.backfillApplyAction') + expect(applyIndex).toBeGreaterThan(-1) + // The apply button carries `disabled` because no preview result exists yet. + expect(html.lastIndexOf('disabled', applyIndex)).toBeGreaterThan(-1) + }) + it('shows a loading state while the settings are fetched', () => { usePersonalNamespaceSettingsMock.mockReturnValue({ data: undefined, isLoading: true }) diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx index 5e0e35c25..61cc95ac6 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -6,8 +6,17 @@ 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' +import type { PersonalNamespaceBackfillResult, PersonalNamespaceSettingsInput } from '@/api/types' +import { + useBackfillPersonalNamespaces, usePersonalNamespaceSettings, useUpdatePersonalNamespaceSettings, } from '@/features/admin/use-personal-namespace-settings' @@ -43,6 +52,8 @@ export function AdminSettingsPage() { const { t } = useTranslation() const { data: settings, isLoading } = usePersonalNamespaceSettings() const updateMutation = useUpdatePersonalNamespaceSettings() + const backfillMutation = useBackfillPersonalNamespaces() + const [backfill, setBackfill] = useState(null) const [form, setForm] = useState({ enabled: false, @@ -64,6 +75,29 @@ export function AdminSettingsPage() { const displayNamePreview = renderTemplate(form.displayNameTemplate).trim() const placeholders = settings?.supportedPlaceholders ?? Object.keys(PREVIEW_OWNER) + const runBackfill = async (dryRun: boolean) => { + try { + const result = await backfillMutation.mutateAsync(dryRun) + setBackfill(result) + if (!dryRun) { + const created = result.entries.filter((entry) => entry.outcome === 'CREATED').length + toast.success( + t('adminSettings.backfillDoneTitle'), + t('adminSettings.backfillDoneDescription', { count: created }), + ) + } + } catch (error) { + toast.error( + t('adminSettings.backfillErrorTitle'), + error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'), + ) + } + } + + const plannedCount = backfill?.dryRun + ? backfill.entries.filter((entry) => entry.outcome === 'PLANNED').length + : 0 + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() @@ -164,6 +198,73 @@ export function AdminSettingsPage() { )} + + +

+

{t('adminSettings.backfillTitle')}

+

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

+
+ +
+ + +
+

{t('adminSettings.backfillPreviewFirstHint')}

+ + {backfill ? ( +
+

+ {t('adminSettings.backfillSummary', { + scanned: backfill.scannedAccounts, + already: backfill.alreadyProvisioned, + acted: backfill.entries.length, + })} +

+ {backfill.truncated ? ( +

{t('adminSettings.backfillTruncated')}

+ ) : null} + {backfill.entries.length === 0 ? ( +

{t('adminSettings.backfillNothingToDo')}

+ ) : ( +
+ + + + {t('adminSettings.backfillColumnUser')} + {t('adminSettings.backfillColumnSlug')} + {t('adminSettings.backfillColumnOutcome')} + + + + {backfill.entries.map((entry) => ( + + {entry.displayName || entry.userId} + {entry.slug ?? '—'} + {t(`adminSettings.backfillOutcome.${entry.outcome}`)} + + ))} + +
+
+ )} +
+ ) : null} + ) } From 639e081ca7c22f28d9ddf290d39f2e013aed2cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Sat, 15 Aug 2026 18:10:43 +0800 Subject: [PATCH 4/7] fix(namespace): stop the backfill from querying with a null keyword The backfill preview returned 500 on PostgreSQL: SQLState 42883: function lower(bytea) does not exist It reused UserAccountRepository.search(keyword, status, pageable) with a null keyword. That query compares the keyword with lower(...), and a null bind leaves PostgreSQL to infer the parameter type as bytea, so lower() has no matching signature. Nothing had exercised that branch before: the admin user list goes through AdminUserSearchRepository, and the member-candidate lookup always passes a real keyword. The backfill was the first caller to pass null. Give callers that want every account in a status a query without a keyword to bind, rather than papering over the null with a cast or an empty string. Neither test layer would have caught this. The unit tests mock the repository, and the integration tests run on H2 in PostgreSQL mode, which accepts the null-typed bind that PostgreSQL rejects. Verified instead against a real PostgreSQL: preview, apply, and a second preview showing alreadyProvisioned with nothing left to do, with namespace_member rows confirming each owner holds OWNER on a TEAM namespace. --- .../PersonalNamespaceProvisioningService.java | 2 +- .../skillhub/domain/user/UserAccountRepository.java | 11 +++++++++++ .../PersonalNamespaceProvisioningServiceTest.java | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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 index b5b3bf817..8c42bac9f 100644 --- 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 @@ -141,7 +141,7 @@ public PersonalNamespaceBackfillReport backfill(boolean dryRun) { boolean truncated = false; for (int page = 0; !truncated; page++) { - Page batch = userAccountRepository.search(null, UserStatus.ACTIVE, + Page batch = userAccountRepository.findByStatus(UserStatus.ACTIVE, PageRequest.of(page, BACKFILL_PAGE_SIZE, Sort.by("id"))); if (batch.isEmpty()) { break; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index c0f4f2956..bcfbb3b34 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -14,5 +14,16 @@ public interface UserAccountRepository { List findByIdIn(List ids); Optional findByEmailIgnoreCase(String email); Page search(String keyword, UserStatus status, Pageable pageable); + + /** + * Lists accounts in one status. + * + *

Separate from {@link #search} on purpose: that query compares the keyword with + * {@code lower(...)}, and passing a null keyword leaves PostgreSQL to infer the bind type as + * {@code bytea}, which fails with "function lower(bytea) does not exist". Callers that want + * every account in a status have no keyword to give, so they get a query without one. + */ + Page findByStatus(UserStatus status, Pageable pageable); + UserAccount save(UserAccount user); } 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 index 559ff2717..e3b8503a0 100644 --- 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 @@ -63,7 +63,7 @@ private UserAccount account(String id, String displayName, String email) { } private void directoryContains(UserAccount... users) { - when(userAccountRepository.search(isNull(), eq(UserStatus.ACTIVE), any())) + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) .thenReturn(new PageImpl<>(List.of(users))); } From 4fe6948f87b0a509700f6056d2468e9850babd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Sun, 16 Aug 2026 19:20:23 +0800 Subject: [PATCH 5/7] fix(web): stop the settings form from reverting the saved value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling personal namespace provisioning appeared to save — the request succeeded and the row held enabled=true — but a refresh showed it disabled again, and the template inputs stayed greyed out. The form mounted before the fetched settings reached it. Radix's Select keeps a hidden native whose options only exist while the dropdown is mounted, so + * changing the controlled value afterwards landed on "" and fired onValueChange(""), which read + * as "disabled" and silently reverted the server's answer. + */ + it('keeps the enabled setting the server returned', async () => { + render() + + const slugTemplate = (await screen.findByLabelText( + 'adminSettings.slugTemplateLabel', + )) as HTMLInputElement + + await waitFor(() => { + expect(slugTemplate.disabled).toBe(false) + }) + // The trigger renders the selected item's label, so it must read "enabled". + const trigger = document.querySelector('#personal-namespace-enabled') + expect(trigger?.textContent).toContain('adminSettings.enabledOn') }) it('shows a loading state while the settings are fetched', () => { usePersonalNamespaceSettingsMock.mockReturnValue({ data: undefined, isLoading: true }) - const html = renderToStaticMarkup() + render() + + expect(screen.getByText('adminSettings.loading')).toBeDefined() + }) + + it('offers the backfill for accounts that already exist', async () => { + render() + + expect(await screen.findByText('adminSettings.backfillTitle')).toBeDefined() + expect(screen.getByRole('button', { name: 'adminSettings.backfillPreviewAction' })).toBeDefined() + }) + + it('keeps the apply button disabled until a preview has been run', async () => { + render() - expect(html).toContain('adminSettings.loading') + const apply = await screen.findByRole('button', { name: /backfillApplyAction/ }) + expect((apply as HTMLButtonElement).disabled).toBe(true) }) }) diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx index 61cc95ac6..3036b3b27 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -55,24 +55,28 @@ export function AdminSettingsPage() { const backfillMutation = useBackfillPersonalNamespaces() const [backfill, setBackfill] = useState(null) - const [form, setForm] = useState({ - enabled: false, - slugTemplate: '${username}', - displayNameTemplate: '${username}', - }) + // Null until the server answers. The form must not mount before then: Radix's + // Select keeps a hidden native - setForm((current) => ({ ...current, enabled: value === 'enabled' })) - } + onValueChange={(value) => { + // Ignore anything that is not a real choice; see the note on `form`. + if (value !== 'enabled' && value !== 'disabled') { + return + } + setForm((current) => + current ? { ...current, enabled: value === 'enabled' } : current, + ) + }} > @@ -161,7 +174,9 @@ export function AdminSettingsPage() { value={form.slugTemplate} disabled={!form.enabled} onChange={(event) => - setForm((current) => ({ ...current, slugTemplate: event.target.value })) + setForm((current) => + current ? { ...current, slugTemplate: event.target.value } : current, + ) } />

@@ -182,7 +197,9 @@ export function AdminSettingsPage() { value={form.displayNameTemplate} disabled={!form.enabled} onChange={(event) => - setForm((current) => ({ ...current, displayNameTemplate: event.target.value })) + setForm((current) => + current ? { ...current, displayNameTemplate: event.target.value } : current, + ) } />

From a9e7f43e5a82645e2946a789f8f82e824640d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Sun, 16 Aug 2026 20:31:34 +0800 Subject: [PATCH 6/7] feat(namespace): let operators choose which namespaces new accounts join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment that stands up its own organisation-wide namespace — to use instead of the built-in global one — finds it invisible to everybody. The namespace listing only returns namespaces the caller belongs to, and the only thing that ever added members automatically was hard-wired to the slug "global". Make that list a setting. namespace.default-membership holds the slugs every newly activated account is enrolled in, defaulting to ["global"], which is what every deployment did before. Publishing only requires membership of any role, so being enrolled is enough to publish there; no extra grant needed. GlobalNamespaceMembershipService becomes DefaultNamespaceMembershipService, since it no longer means one specific namespace. Where the strictness sits: - Saving validates every slug resolves to an ACTIVE namespace, so a typo fails at the moment an administrator makes it. - Enrolling tolerates a slug that no longer resolves: it logs and skips. A namespace that was deleted or renamed must not cost somebody their login. Adding a namespace to the list after people have signed up leaves them out, the same trap the personal-namespace work hit, so this ships with the same preview-then-apply backfill. Verified against a real PostgreSQL, end to end: create a namespace, reject an unknown slug, save with whitespace and duplicates and see them normalised, preview, apply, re-preview showing nothing left, and a fresh registration landing in the global namespace, the new shared one, and its own personal one at once. --- docs/06-api-design.md | 3 + ...6-08-13-personal-namespace-provisioning.md | 21 ++ .../admin/AdminSystemSettingController.java | 42 +++- .../iflytek/skillhub/dto/BackfillRequest.java | 10 + .../dto/DefaultNamespaceBackfillResponse.java | 18 ++ .../dto/DefaultNamespaceSettingsResponse.java | 8 + ...DefaultNamespaceSettingsUpdateRequest.java | 13 ++ .../dto/PersonalNamespaceBackfillRequest.java | 8 - .../DefaultNamespaceSettingsAppService.java | 118 ++++++++++ .../PersonalNamespaceSettingsAppService.java | 4 +- .../src/main/resources/application.yml | 5 + .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + ...rsonalNamespaceSettingsAppServiceTest.java | 6 +- .../auth/identity/IdentityBindingService.java | 10 +- .../skillhub/auth/local/LocalAuthService.java | 10 +- .../identity/IdentityBindingServiceTest.java | 10 +- .../auth/local/LocalAuthServiceTest.java | 8 +- .../DefaultNamespaceBackfillEntry.java | 15 ++ .../DefaultNamespaceBackfillReport.java | 18 ++ .../DefaultNamespaceMembershipService.java | 178 +++++++++++++++ .../namespace/DefaultNamespaceProperties.java | 33 +++ .../namespace/DefaultNamespaceSettings.java | 20 ++ .../GlobalNamespaceMembershipService.java | 33 --- ...DefaultNamespaceMembershipServiceTest.java | 208 ++++++++++++++++++ .../GlobalNamespaceMembershipServiceTest.java | 71 ------ web/src/api/client.ts | 25 +++ web/src/api/generated/schema.d.ts | 139 +++++++++++- web/src/api/types.ts | 19 ++ .../features/admin/use-default-namespaces.ts | 36 +++ web/src/i18n/locales/en.json | 13 +- web/src/i18n/locales/zh.json | 13 +- web/src/pages/admin/settings.test.tsx | 25 ++- web/src/pages/admin/settings.tsx | 162 +++++++++++++- 34 files changed, 1161 insertions(+), 145 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java delete mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java delete mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java create mode 100644 web/src/features/admin/use-default-namespaces.ts diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 579b62696..ca81383ba 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -337,6 +337,9 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | GET | `/api/v1/admin/settings/personal-namespace` | 读取「新账号自动建命名空间」策略 | | PUT | `/api/v1/admin/settings/personal-namespace` | 更新该策略(写审计日志) | | POST | `/api/v1/admin/settings/personal-namespace/backfill` | 为已有账号补建;`dryRun=true` 只返回计划,不写库 | +| GET | `/api/v1/admin/settings/default-namespaces` | 读取「新账号默认加入的命名空间」列表 | +| PUT | `/api/v1/admin/settings/default-namespaces` | 更新该列表(slug 必须存在且为 ACTIVE;写审计日志)| +| POST | `/api/v1/admin/settings/default-namespaces/backfill` | 把已有账号补加入这些命名空间;`dryRun=true` 只返回计划 | 详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。 diff --git a/docs/2026-08-13-personal-namespace-provisioning.md b/docs/2026-08-13-personal-namespace-provisioning.md index c09487387..f22d276ed 100644 --- a/docs/2026-08-13-personal-namespace-provisioning.md +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -128,6 +128,27 @@ slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。 账号激活本身是低频事件,多两行日志的代价可以忽略。 +## 二·五、全员默认加入的命名空间 + +「自动建一个自己的命名空间」解决的是个人空间;另一个相邻问题是**组织级公共空间**。 + +部署方新建一个命名空间来代替内置的 `global` 时会发现它对所有人不可见—— +`listNamespaces` 只返回调用者是成员的命名空间,而「新账号自动入伙」这件事 +原本写死在 `GlobalNamespaceMembershipService` 里,只认 slug `global`。 + +所以把它一般化为 `DefaultNamespaceMembershipService`: + +- 设置项 `namespace.default-membership` 存一个 slug 列表,默认 `["global"]`, + 即改造前的行为 +- 保存时校验每个 slug 存在且为 ACTIVE,让拼错在保存那一刻就暴露, + 而不是变成某个人首次登录时的一条警告 +- 运行期遇到已被删除或改名的 slug 只记 WARN 并跳过—— + 一个不存在的命名空间不该让人登不上来 +- 同样配了预览 + 执行的补建,把存量账号一次性加进去 + +发布只要求「是该命名空间的成员」(任意角色),所以加入即可发布, +不需要额外授予角色。 + ## 三、配置 | 位置 | 项 | 默认 | 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 index 4b4735de2..1118cc451 100644 --- 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 @@ -4,11 +4,15 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.BackfillRequest; +import com.iflytek.skillhub.dto.DefaultNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsUpdateRequest; import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.DefaultNamespaceSettingsAppService; import com.iflytek.skillhub.service.PersonalNamespaceSettingsAppService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -29,11 +33,14 @@ public class AdminSystemSettingController extends BaseApiController { private final PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService; + private final DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService; public AdminSystemSettingController(PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService, + DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService, ApiResponseFactory responseFactory) { super(responseFactory); this.personalNamespaceSettingsAppService = personalNamespaceSettingsAppService; + this.defaultNamespaceSettingsAppService = defaultNamespaceSettingsAppService; } @GetMapping("/personal-namespace") @@ -59,10 +66,41 @@ public ApiResponse updatePersonalNamespaceSet @PostMapping("/personal-namespace/backfill") @PreAuthorize("hasRole('SUPER_ADMIN')") public ApiResponse backfillPersonalNamespaces( - @Valid @RequestBody PersonalNamespaceBackfillRequest request, + @Valid @RequestBody BackfillRequest request, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { return ok("response.success", personalNamespaceSettingsAppService.backfill( request, principal.userId(), AuditRequestContext.from(httpRequest))); } + + @GetMapping("/default-namespaces") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse getDefaultNamespaces() { + return ok("response.success.read", defaultNamespaceSettingsAppService.get()); + } + + @PutMapping("/default-namespaces") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse updateDefaultNamespaces( + @Valid @RequestBody DefaultNamespaceSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", defaultNamespaceSettingsAppService.update( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + /** + * Enrols existing accounts in the configured default namespaces, for when one is added after + * people have already signed up. Send {@code dryRun} to see the plan first. + */ + @PostMapping("/default-namespaces/backfill") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse backfillDefaultNamespaces( + @Valid @RequestBody BackfillRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success", defaultNamespaceSettingsAppService.backfill( + Boolean.TRUE.equals(request.dryRun()), principal.userId(), + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java new file mode 100644 index 000000000..353dd9281 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * Shared body for the admin backfill endpoints. + * + * @param dryRun when true, report what would happen without writing anything + */ +public record BackfillRequest(@NotNull Boolean dryRun) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java new file mode 100644 index 000000000..fe1856365 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were enrolled, or would be + */ +public record DefaultNamespaceBackfillResponse( + boolean dryRun, + int scannedAccounts, + int alreadyEnrolled, + int systemAccountsSkipped, + boolean truncated, + List entries) { + + public record Entry(String userId, String displayName, List slugs) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java new file mode 100644 index 000000000..f276cc65c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param slugs namespaces every newly activated account is enrolled in + */ +public record DefaultNamespaceSettingsResponse(List slugs) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java new file mode 100644 index 000000000..a7aca30c9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * @param slugs may be empty, which means new accounts are enrolled nowhere + */ +public record DefaultNamespaceSettingsUpdateRequest( + @NotNull @Size(max = 20) List<@Size(max = 64) String> slugs +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java deleted file mode 100644 index 04df3456c..000000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.iflytek.skillhub.dto; - -import jakarta.validation.constraints.NotNull; - -/** - * @param dryRun when true, report the accounts that would get a namespace without creating any - */ -public record PersonalNamespaceBackfillRequest(@NotNull Boolean dryRun) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java new file mode 100644 index 000000000..a35ed25aa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java @@ -0,0 +1,118 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceBackfillReport; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceSettings; +import com.iflytek.skillhub.dto.DefaultNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Exposes the "namespaces every new account joins" policy to the admin console. + */ +@Service +public class DefaultNamespaceSettingsAppService { + + private static final String AUDIT_TARGET_TYPE = "SYSTEM_SETTING"; + private static final String AUDIT_ACTION_UPDATE = "SYSTEM_SETTING_DEFAULT_NAMESPACES_UPDATE"; + private static final String AUDIT_ACTION_BACKFILL = "SYSTEM_SETTING_DEFAULT_NAMESPACES_BACKFILL"; + + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + private final ObjectMapper objectMapper; + + public DefaultNamespaceSettingsAppService( + DefaultNamespaceMembershipService defaultNamespaceMembershipService, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor, + ObjectMapper objectMapper) { + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public DefaultNamespaceSettingsResponse get() { + return new DefaultNamespaceSettingsResponse( + defaultNamespaceMembershipService.currentSettings().slugs()); + } + + @Transactional + public DefaultNamespaceSettingsResponse update(DefaultNamespaceSettingsUpdateRequest request, + String actorUserId, + AuditRequestContext auditContext) { + DefaultNamespaceSettings previous = defaultNamespaceMembershipService.currentSettings(); + DefaultNamespaceSettings updated = defaultNamespaceMembershipService.updateSettings( + new DefaultNamespaceSettings(request.slugs()), actorUserId); + + Map detail = new LinkedHashMap<>(); + detail.put("before", previous.slugs()); + detail.put("after", updated.slugs()); + record(actorUserId, auditContext, AUDIT_ACTION_UPDATE, detail); + return new DefaultNamespaceSettingsResponse(updated.slugs()); + } + + /** + * A dry run writes nothing and is not audited; an applied run records who it enrolled. + */ + public DefaultNamespaceBackfillResponse backfill(boolean dryRun, + String actorUserId, + AuditRequestContext auditContext) { + DefaultNamespaceBackfillReport report = defaultNamespaceMembershipService.backfill(dryRun); + + if (!dryRun) { + Map detail = new LinkedHashMap<>(); + detail.put("scannedAccounts", report.scannedAccounts()); + detail.put("alreadyEnrolled", report.alreadyEnrolled()); + detail.put("truncated", report.truncated()); + detail.put("enrolled", report.entries().stream() + .map(entry -> Map.of("userId", entry.userId(), "slugs", entry.slugs())) + .toList()); + record(actorUserId, auditContext, AUDIT_ACTION_BACKFILL, detail); + } + + return new DefaultNamespaceBackfillResponse( + report.dryRun(), + report.scannedAccounts(), + report.alreadyEnrolled(), + report.systemAccountsSkipped(), + report.truncated(), + report.entries().stream() + .map(entry -> new DefaultNamespaceBackfillResponse.Entry( + entry.userId(), entry.displayName(), entry.slugs())) + .toList()); + } + + private void record(String actorUserId, + AuditRequestContext auditContext, + String action, + Map detail) { + auditLogService.record( + actorUserId, + action, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + + private String toJson(Map detail) { + try { + return objectMapper.writeValueAsString(detail); + } catch (Exception e) { + return null; + } + } +} 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 index 7f3868e96..e47a03ac1 100644 --- 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 @@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; -import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.BackfillRequest; import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; @@ -73,7 +73,7 @@ public PersonalNamespaceSettingsResponse update(PersonalNamespaceSettingsUpdateR * Runs the backfill over existing accounts. A dry run writes nothing and is not audited; an * applied run records what it created. */ - public PersonalNamespaceBackfillResponse backfill(PersonalNamespaceBackfillRequest request, + public PersonalNamespaceBackfillResponse backfill(BackfillRequest request, String actorUserId, AuditRequestContext auditContext) { boolean dryRun = Boolean.TRUE.equals(request.dryRun()); diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index e7abdd12e..3744d1795 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -125,6 +125,11 @@ skillhub: # the admin console instead; their defaults live in PersonalNamespaceProvisioningProperties. personal-provisioning: enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:false} + # Namespaces every newly activated account is enrolled in. The built-in global namespace is + # the historical behaviour; an administrator can change the list in the admin console, and the + # stored choice then wins over this file. + default-membership: + slugs: ${SKILLHUB_NAMESPACE_DEFAULT_MEMBERSHIP_SLUGS:global} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8791e6be3..6dcf494a3 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -76,6 +76,8 @@ error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required error.namespace.owner.required=Namespace owner role required +error.namespace.defaultMembership.unknownSlug=Namespace ''{0}'' does not exist +error.namespace.defaultMembership.inactiveSlug=Namespace ''{0}'' is not active error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces error.namespace.delete.hasDependencies=Namespace cannot be deleted while it still contains skills or governance records error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0e1b3fc33..91297887a 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -76,6 +76,8 @@ error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 error.namespace.owner.required=需要命名空间所有者权限 +error.namespace.defaultMembership.unknownSlug=命名空间 ''{0}'' 不存在 +error.namespace.defaultMembership.inactiveSlug=命名空间 ''{0}'' 不是启用状态 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除 error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色 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 index eedc14957..fa8a2faae 100644 --- 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 @@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; -import com.iflytek.skillhub.dto.PersonalNamespaceBackfillRequest; +import com.iflytek.skillhub.dto.BackfillRequest; import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; @@ -120,7 +120,7 @@ void backfillDryRunIsNotAudited() { PersonalNamespaceBackfillEntry.Outcome.PLANNED)))); PersonalNamespaceBackfillResponse response = service.backfill( - new PersonalNamespaceBackfillRequest(true), "usr_admin", null); + new BackfillRequest(true), "usr_admin", null); assertThat(response.dryRun()).isTrue(); assertThat(response.entries()).singleElement() @@ -137,7 +137,7 @@ void backfillRecordsWhatItCreated() { new PersonalNamespaceBackfillEntry("usr_2", "admin", null, PersonalNamespaceBackfillEntry.Outcome.NO_SLUG)))); - service.backfill(new PersonalNamespaceBackfillRequest(false), "usr_admin", + service.backfill(new BackfillRequest(false), "usr_admin", new AuditRequestContext("10.0.0.1", "curl/8")); ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); 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 8e9224311..2c4d80bf8 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 @@ -7,7 +7,7 @@ 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.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -28,18 +28,18 @@ public class IdentityBindingService { private final IdentityBindingRepository bindingRepo; private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; - private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; private final ApplicationEventPublisher eventPublisher; public IdentityBindingService(IdentityBindingRepository bindingRepo, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - GlobalNamespaceMembershipService globalNamespaceMembershipService, + DefaultNamespaceMembershipService defaultNamespaceMembershipService, ApplicationEventPublisher eventPublisher) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; - this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; this.eventPublisher = eventPublisher; } @@ -69,7 +69,7 @@ public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat user.setStatus(initialStatus); user = userRepo.save(user); if (initialStatus == UserStatus.ACTIVE) { - globalNamespaceMembershipService.ensureMember(user.getId()); + defaultNamespaceMembershipService.ensureMember(user.getId()); eventPublisher.publishEvent( new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email())); } 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 5d0dad226..ad0bc5c5f 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 @@ -5,7 +5,7 @@ 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.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -42,7 +42,7 @@ public class LocalAuthService { private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; - private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; @@ -51,7 +51,7 @@ public class LocalAuthService { public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, - GlobalNamespaceMembershipService globalNamespaceMembershipService, + DefaultNamespaceMembershipService defaultNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, Clock clock, @@ -59,7 +59,7 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; - this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; @@ -104,7 +104,7 @@ public PlatformPrincipal register(String username, String password, String email normalizedUsername, passwordEncoder.encode(password) )); - globalNamespaceMembershipService.ensureMember(user.getId()); + defaultNamespaceMembershipService.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 7c9281c32..b84991616 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 @@ -17,7 +17,7 @@ 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.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -46,7 +46,7 @@ class IdentityBindingServiceTest { private UserRoleBindingRepository roleBindingRepo; @Mock - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private DefaultNamespaceMembershipService defaultNamespaceMembershipService; @Mock private ApplicationEventPublisher eventPublisher; @@ -56,7 +56,7 @@ class IdentityBindingServiceTest { @BeforeEach void setUp() { service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, - globalNamespaceMembershipService, eventPublisher); + defaultNamespaceMembershipService, eventPublisher); } @Test @@ -77,7 +77,7 @@ void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userRepo).save(userCaptor.capture()); - verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); verify(bindingRepo).save(any(IdentityBinding.class)); assertThat(principal.displayName()).isEqualTo("alice"); assertThat(principal.oauthProvider()).isEqualTo("github"); @@ -137,7 +137,7 @@ void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) .isInstanceOf(AccountPendingException.class); - verify(globalNamespaceMembershipService, never()).ensureMember(any()); + verify(defaultNamespaceMembershipService, never()).ensureMember(any()); } @Test 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 f11b91162..96a1dd32c 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 @@ -14,7 +14,7 @@ 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.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -48,7 +48,7 @@ class LocalAuthServiceTest { private UserRoleBindingRepository userRoleBindingRepository; @Mock - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private DefaultNamespaceMembershipService defaultNamespaceMembershipService; @Mock private PasswordEncoder passwordEncoder; @@ -64,7 +64,7 @@ void setUp() { credentialRepository, userAccountRepository, userRoleBindingRepository, - globalNamespaceMembershipService, + defaultNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, CLOCK, @@ -89,7 +89,7 @@ void register_createsUserAndCredential() { assertThat(principal.email()).isEqualTo("alice@example.com"); assertThat(principal.platformRoles()).containsExactly("USER"); verify(credentialRepository).save(any(LocalCredential.class)); - verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java new file mode 100644 index 000000000..b2b88339a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * One account a default-namespace backfill enrolled, or would enrol. + * + * @param slugs the default namespaces the account is not yet a member of + */ +public record DefaultNamespaceBackfillEntry(String userId, String displayName, List slugs) { + + public DefaultNamespaceBackfillEntry { + slugs = slugs == null ? List.of() : List.copyOf(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java new file mode 100644 index 000000000..ce2311417 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * Outcome of enrolling existing accounts in the configured default namespaces. + * + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were enrolled, or would be + */ +public record DefaultNamespaceBackfillReport( + boolean dryRun, + int scannedAccounts, + int alreadyEnrolled, + int systemAccountsSkipped, + boolean truncated, + List entries) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java new file mode 100644 index 000000000..608a80823 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java @@ -0,0 +1,178 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Enrolls newly active users in the namespaces the operator has designated as defaults. + * + *

This used to be hard-wired to the built-in {@code global} namespace. Deployments that stand up + * their own organisation-wide namespace found it invisible to everyone, because the namespace + * listing only returns namespaces the caller belongs to and nothing ever added them. + */ +@Service +public class DefaultNamespaceMembershipService { + + public static final String SETTING_KEY = "namespace.default-membership"; + + private static final int MAX_BACKFILL_ACCOUNTS = 5000; + private static final int BACKFILL_PAGE_SIZE = 200; + + private static final Logger log = LoggerFactory.getLogger(DefaultNamespaceMembershipService.class); + + private final SystemSettingService systemSettingService; + private final DefaultNamespaceProperties defaults; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + + public DefaultNamespaceMembershipService(SystemSettingService systemSettingService, + DefaultNamespaceProperties defaults, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { + this.systemSettingService = systemSettingService; + this.defaults = defaults; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + public DefaultNamespaceSettings currentSettings() { + return systemSettingService.get(SETTING_KEY, DefaultNamespaceSettings.class, defaults.toSettings()); + } + + /** + * Stores the operator's choice after checking every slug resolves to a live namespace, so a + * typo surfaces here rather than as a warning on somebody's first login. + */ + @Transactional + public DefaultNamespaceSettings updateSettings(DefaultNamespaceSettings settings, String actorUserId) { + Set normalized = new LinkedHashSet<>(); + for (String slug : settings.slugs()) { + String trimmed = slug == null ? "" : slug.trim(); + if (trimmed.isEmpty()) { + continue; + } + Namespace namespace = namespaceRepository.findBySlug(trimmed) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.defaultMembership.unknownSlug", trimmed)); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException( + "error.namespace.defaultMembership.inactiveSlug", trimmed); + } + normalized.add(trimmed); + } + return systemSettingService.put( + SETTING_KEY, new DefaultNamespaceSettings(List.copyOf(normalized)), actorUserId); + } + + /** + * Adds {@code userId} to every configured default namespace it is not already in. + * + *

A slug that no longer resolves is logged and skipped: a namespace that was renamed or + * deleted must not cost somebody their registration. + */ + @Transactional + public void ensureMember(String userId) { + for (String slug : currentSettings().slugs()) { + Optional namespace = namespaceRepository.findBySlug(slug); + if (namespace.isEmpty()) { + log.warn("Default namespace '{}' does not exist; skipping enrolment for user {}", slug, userId); + continue; + } + join(namespace.get(), userId); + } + } + + /** + * Enrolls existing accounts in the configured defaults, for when an operator adds a namespace + * after people have already signed up. + * + *

Not {@code @Transactional}: each account is enrolled on its own, so one failure does not + * discard the rest of the run. + */ + public DefaultNamespaceBackfillReport backfill(boolean dryRun) { + List targets = new ArrayList<>(); + for (String slug : currentSettings().slugs()) { + namespaceRepository.findBySlug(slug).ifPresentOrElse( + targets::add, + () -> log.warn("Default namespace '{}' does not exist; excluded from backfill", slug)); + } + + List entries = new ArrayList<>(); + int scanned = 0; + int alreadyMember = 0; + int systemAccounts = 0; + boolean truncated = false; + + for (int page = 0; !truncated && !targets.isEmpty(); page++) { + Page batch = userAccountRepository.findByStatus(UserStatus.ACTIVE, + PageRequest.of(page, BACKFILL_PAGE_SIZE, Sort.by("id"))); + if (batch.isEmpty()) { + break; + } + for (UserAccount user : batch) { + if (scanned >= MAX_BACKFILL_ACCOUNTS) { + truncated = true; + break; + } + scanned++; + if (user.isSystemAccount()) { + systemAccounts++; + continue; + } + List missing = targets.stream() + .filter(namespace -> namespaceMemberRepository + .findByNamespaceIdAndUserId(namespace.getId(), user.getId()).isEmpty()) + .map(Namespace::getSlug) + .toList(); + if (missing.isEmpty()) { + alreadyMember++; + continue; + } + if (!dryRun) { + targets.stream() + .filter(namespace -> missing.contains(namespace.getSlug())) + .forEach(namespace -> join(namespace, user.getId())); + } + entries.add(new DefaultNamespaceBackfillEntry(user.getId(), user.getDisplayName(), missing)); + } + if (!batch.hasNext()) { + break; + } + } + + log.info("Default namespace backfill ({}): scanned {}, already enrolled {}, acted on {}{}", + dryRun ? "dry run" : "applied", scanned, alreadyMember, entries.size(), + truncated ? ", stopped at the per-run cap" : ""); + return new DefaultNamespaceBackfillReport( + dryRun, scanned, alreadyMember, systemAccounts, truncated, List.copyOf(entries)); + } + + /** + * Idempotent. Called from {@link #ensureMember} inside the caller's transaction, and from the + * backfill outside one, where each save commits on its own. + */ + private void join(Namespace namespace, String userId) { + namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) + .orElseGet(() -> namespaceMemberRepository.save( + new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java new file mode 100644 index 000000000..4cead4655 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Deployment default for {@link DefaultNamespaceSettings}, used until an administrator saves a + * choice in the admin console. + * + *

Defaults to the built-in global namespace, which is what every deployment did before this was + * configurable. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.namespace.default-membership") +public class DefaultNamespaceProperties { + + private List slugs = new ArrayList<>(List.of("global")); + + public List getSlugs() { + return slugs; + } + + public void setSlugs(List slugs) { + this.slugs = slugs; + } + + public DefaultNamespaceSettings toSettings() { + return new DefaultNamespaceSettings(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java new file mode 100644 index 000000000..f4ac928d2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * The namespaces every newly activated account is enrolled in, as member. + * + *

A deployment that outgrows the built-in {@code global} namespace — say it wants an + * organisation-wide space of its own — needs to say so somewhere, because a namespace nobody is a + * member of is invisible: the namespace listing only returns namespaces the caller belongs to. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record DefaultNamespaceSettings(List slugs) { + + public DefaultNamespaceSettings { + slugs = slugs == null ? List.of() : List.copyOf(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java deleted file mode 100644 index 1ef7d2470..000000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.iflytek.skillhub.domain.namespace; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Ensures newly active users belong to the built-in global namespace. - */ -@Service -public class GlobalNamespaceMembershipService { - - private static final String GLOBAL_NAMESPACE_SLUG = "global"; - - private final NamespaceRepository namespaceRepository; - private final NamespaceMemberRepository namespaceMemberRepository; - - public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { - this.namespaceRepository = namespaceRepository; - this.namespaceMemberRepository = namespaceMemberRepository; - } - - @Transactional - public void ensureMember(String userId) { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) - .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); - - namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) - .orElseGet(() -> namespaceMemberRepository.save( - new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) - )); - } -} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java new file mode 100644 index 000000000..728810289 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java @@ -0,0 +1,208 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +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.springframework.data.domain.PageImpl; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultNamespaceMembershipServiceTest { + + @Mock + private SystemSettingService systemSettingService; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + private DefaultNamespaceMembershipService service; + + @BeforeEach + void setUp() { + service = new DefaultNamespaceMembershipService( + systemSettingService, + new DefaultNamespaceProperties(), + namespaceRepository, + namespaceMemberRepository, + userAccountRepository); + } + + private Namespace namespace(long id, String slug) { + Namespace namespace = new Namespace(slug, slug, "usr_owner"); + try { + Field field = Namespace.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(namespace, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + return namespace; + } + + private void configured(String... slugs) { + when(systemSettingService.get(eq(DefaultNamespaceMembershipService.SETTING_KEY), + eq(DefaultNamespaceSettings.class), any())) + .thenReturn(new DefaultNamespaceSettings(List.of(slugs))); + } + + @Test + void defaultsToTheBuiltInGlobalNamespace() { + assertEquals(List.of("global"), new DefaultNamespaceProperties().toSettings().slugs()); + } + + @Test + void ensureMemberJoinsEveryConfiguredNamespace() { + configured("global", "musee"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq("usr_1"))) + .thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, org.mockito.Mockito.times(2)).save(captor.capture()); + assertEquals(List.of(1L, 2L), captor.getAllValues().stream().map(NamespaceMember::getNamespaceId).toList()); + assertTrue(captor.getAllValues().stream().allMatch(m -> m.getRole() == NamespaceRole.MEMBER)); + } + + @Test + void ensureMemberSkipsASlugThatNoLongerResolves() { + configured("global", "deleted-one"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceRepository.findBySlug("deleted-one")).thenReturn(Optional.empty()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, org.mockito.Mockito.times(1)).save(any(NamespaceMember.class)); + } + + @Test + void ensureMemberIsIdempotent() { + configured("global"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "usr_1", NamespaceRole.MEMBER))); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void updateSettingsRejectsASlugThatDoesNotExist() { + when(namespaceRepository.findBySlug("typo")).thenReturn(Optional.empty()); + + assertThrows(DomainBadRequestException.class, () -> + service.updateSettings(new DefaultNamespaceSettings(List.of("typo")), "usr_admin")); + verify(systemSettingService, never()).put(any(), any(), any()); + } + + @Test + void updateSettingsRejectsANamespaceThatIsNotActive() { + Namespace archived = namespace(3L, "old"); + archived.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceRepository.findBySlug("old")).thenReturn(Optional.of(archived)); + + assertThrows(DomainBadRequestException.class, () -> + service.updateSettings(new DefaultNamespaceSettings(List.of("old")), "usr_admin")); + } + + @Test + void updateSettingsTrimsBlanksAndDropsDuplicates() { + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(systemSettingService.put(any(), any(), any())).thenAnswer(i -> i.getArgument(1)); + + service.updateSettings(new DefaultNamespaceSettings(List.of(" global ", "", "global")), "usr_admin"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(DefaultNamespaceSettings.class); + verify(systemSettingService).put(eq(DefaultNamespaceMembershipService.SETTING_KEY), + captor.capture(), eq("usr_admin")); + assertEquals(List.of("global"), captor.getValue().slugs()); + } + + @Test + void backfillDryRunListsAccountsMissingMembershipWithoutWriting() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")).thenReturn(Optional.empty()); + + DefaultNamespaceBackfillReport report = service.backfill(true); + + assertEquals(1, report.entries().size()); + assertEquals(List.of("musee"), report.entries().getFirst().slugs()); + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void backfillEnrollsAccountsThatAreMissing() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")).thenReturn(Optional.empty()); + + service.backfill(false); + + verify(namespaceMemberRepository).save(any(NamespaceMember.class)); + } + + @Test + void backfillCountsAccountsThatAreAlreadyEnrolled() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")) + .thenReturn(Optional.of(new NamespaceMember(2L, "usr_1", NamespaceRole.MEMBER))); + + DefaultNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.alreadyEnrolled()); + assertTrue(report.entries().isEmpty()); + } + + @Test + void backfillLeavesSystemAccountsAlone() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of( + UserAccount.systemAccount("builtin-skill-publisher", "Built-in", null, null)))); + + DefaultNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.systemAccountsSkipped()); + assertTrue(report.entries().isEmpty()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java deleted file mode 100644 index c4e211bb1..000000000 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.iflytek.skillhub.domain.namespace; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import java.lang.reflect.Field; -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 static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -class GlobalNamespaceMembershipServiceTest { - - @Mock - private NamespaceRepository namespaceRepository; - - @Mock - private NamespaceMemberRepository namespaceMemberRepository; - - private GlobalNamespaceMembershipService service; - - @BeforeEach - void setUp() { - service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); - } - - @Test - void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { - Namespace global = new Namespace("global", "Global", "system"); - setNamespaceId(global, 1L); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); - - service.ensureMember("usr_1"); - - ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); - verify(namespaceMemberRepository).save(memberCaptor.capture()); - assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); - assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1"); - assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER); - } - - @Test - void ensureMember_keepsExistingGlobalMembership() throws Exception { - Namespace global = new Namespace("global", "Global", "system"); - setNamespaceId(global, 1L); - NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); - - service.ensureMember("usr_1"); - - verify(namespaceMemberRepository, never()).save(any()); - } - - private void setNamespaceId(Namespace namespace, Long id) throws Exception { - Field field = Namespace.class.getDeclaredField("id"); - field.setAccessible(true); - field.set(namespace, id); - } -} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 906947e3f..db3ea600f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -50,6 +50,8 @@ import type { PersonalNamespaceSettings, PersonalNamespaceSettingsInput, PersonalNamespaceBackfillResult, + DefaultNamespaceSettings, + DefaultNamespaceBackfillResult, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1475,6 +1477,29 @@ export const adminApi = { }, ) }, + + async getDefaultNamespaces(): Promise { + return fetchJson('/api/v1/admin/settings/default-namespaces') + }, + + async updateDefaultNamespaces(slugs: string[]): Promise { + return fetchJson('/api/v1/admin/settings/default-namespaces', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ slugs }), + }) + }, + + async backfillDefaultNamespaces(dryRun: boolean): Promise { + return fetchJson( + '/api/v1/admin/settings/default-namespaces/backfill', + { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ dryRun }), + }, + ) + }, } export const notificationApi = { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 1bfb84d39..573bf962b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -388,6 +388,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/default-namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDefaultNamespaces"]; + put: operations["updateDefaultNamespaces"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/namespaces/{slug}/members/{userId}/role": { parameters: { query?: never; @@ -1604,6 +1620,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/default-namespaces/backfill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillDefaultNamespaces"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/search/rebuild": { parameters: { query?: never; @@ -3762,6 +3794,21 @@ export interface components { displayNameTemplate?: string; supportedPlaceholders?: string[]; }; + DefaultNamespaceSettingsUpdateRequest: { + slugs: string[]; + }; + ApiResponseDefaultNamespaceSettingsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DefaultNamespaceSettingsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DefaultNamespaceSettingsResponse: { + slugs?: string[]; + }; AdminLabelUpdateRequest: { /** @enum {string} */ type: "RECOMMENDED" | "PRIVILEGED"; @@ -4185,7 +4232,7 @@ export interface components { comment?: string; disposition?: string; }; - PersonalNamespaceBackfillRequest: { + BackfillRequest: { dryRun: boolean; }; ApiResponsePersonalNamespaceBackfillResponse: { @@ -4214,6 +4261,26 @@ export interface components { truncated?: boolean; entries?: components["schemas"]["Entry"][]; }; + ApiResponseDefaultNamespaceBackfillResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DefaultNamespaceBackfillResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DefaultNamespaceBackfillResponse: { + dryRun?: boolean; + /** Format: int32 */ + scannedAccounts?: number; + /** Format: int32 */ + alreadyEnrolled?: number; + /** Format: int32 */ + systemAccountsSkipped?: number; + truncated?: boolean; + entries?: components["schemas"]["Entry"][]; + }; ProfileReviewRejectRequest: { comment: string; }; @@ -6517,6 +6584,50 @@ export interface operations { }; }; }; + getDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceSettingsResponse"]; + }; + }; + }; + }; + updateDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DefaultNamespaceSettingsUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceSettingsResponse"]; + }; + }; + }; + }; updateMemberRole_2: { parameters: { query?: never; @@ -8680,7 +8791,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PersonalNamespaceBackfillRequest"]; + "application/json": components["schemas"]["BackfillRequest"]; }; }; responses: { @@ -8695,6 +8806,30 @@ export interface operations { }; }; }; + backfillDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BackfillRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceBackfillResponse"]; + }; + }; + }; + }; rebuildAll: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9733263a2..590ad720f 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -612,3 +612,22 @@ export interface PersonalNamespaceBackfillResult { truncated: boolean entries: PersonalNamespaceBackfillEntry[] } + +export interface DefaultNamespaceSettings { + slugs: string[] +} + +export interface DefaultNamespaceBackfillEntry { + userId: string + displayName: string | null + slugs: string[] +} + +export interface DefaultNamespaceBackfillResult { + dryRun: boolean + scannedAccounts: number + alreadyEnrolled: number + systemAccountsSkipped: number + truncated: boolean + entries: DefaultNamespaceBackfillEntry[] +} diff --git a/web/src/features/admin/use-default-namespaces.ts b/web/src/features/admin/use-default-namespaces.ts new file mode 100644 index 000000000..9345bd44a --- /dev/null +++ b/web/src/features/admin/use-default-namespaces.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' +import type { DefaultNamespaceBackfillResult, DefaultNamespaceSettings } from '@/api/types' + +const QUERY_KEY = ['admin', 'settings', 'default-namespaces'] + +export function useDefaultNamespaces() { + return useQuery({ + queryKey: QUERY_KEY, + queryFn: () => adminApi.getDefaultNamespaces(), + }) +} + +export function useUpdateDefaultNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (slugs: string[]) => adminApi.updateDefaultNamespaces(slugs), + onSuccess: (settings) => { + queryClient.setQueryData(QUERY_KEY, settings) + }, + }) +} + +export function useBackfillDefaultNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (dryRun: boolean) => adminApi.backfillDefaultNamespaces(dryRun), + onSuccess: (result) => { + if (!result.dryRun) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] }) + } + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8f5d9cf92..df4982056 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1687,6 +1687,17 @@ }, "backfillDoneTitle": "Backfill finished", "backfillDoneDescription": "Created {{count}} namespaces.", - "backfillErrorTitle": "Backfill failed" + "backfillErrorTitle": "Backfill failed", + "defaultsTitle": "Namespaces every account joins", + "defaultsDescription": "A namespace nobody belongs to is invisible: the namespace list only shows namespaces you are a member of. List the ones every newly activated account should be enrolled in.", + "defaultsLabel": "Namespace slugs", + "defaultsHint": "Comma separated. Each must be an existing, active namespace.", + "defaultsSaveDescription": "Accounts activated from now on join these namespaces.", + "defaultsBackfillHint": "Preview first — the enrol button stays disabled until you do.", + "defaultsBackfillApplyAction": "Enrol {{count}} accounts", + "defaultsBackfillSummary": "Scanned {{scanned}} accounts · {{already}} already enrolled · {{acted}} to enrol", + "defaultsBackfillNothingToDo": "Every account is already enrolled.", + "defaultsBackfillDoneDescription": "Enrolled {{count}} accounts.", + "defaultsColumnSlugs": "Will join" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 74bb82adb..ff86398f9 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1687,6 +1687,17 @@ }, "backfillDoneTitle": "补建完成", "backfillDoneDescription": "已创建 {{count}} 个命名空间。", - "backfillErrorTitle": "补建失败" + "backfillErrorTitle": "补建失败", + "defaultsTitle": "全员默认加入的命名空间", + "defaultsDescription": "没有成员的命名空间是看不见的——命名空间列表只显示你是成员的那些。在这里列出每个新激活的账号都应该加入的命名空间。", + "defaultsLabel": "命名空间标识", + "defaultsHint": "用逗号分隔,每个都必须是已存在且启用的命名空间。", + "defaultsSaveDescription": "此后激活的账号会自动加入这些命名空间。", + "defaultsBackfillHint": "请先预览——未预览前加入按钮不可用。", + "defaultsBackfillApplyAction": "加入 {{count}} 个账号", + "defaultsBackfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已加入 · {{acted}} 个待加入", + "defaultsBackfillNothingToDo": "所有账号都已经加入了。", + "defaultsBackfillDoneDescription": "已为 {{count}} 个账号加入。", + "defaultsColumnSlugs": "将加入" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx index b20639a55..1f959c570 100644 --- a/web/src/pages/admin/settings.test.tsx +++ b/web/src/pages/admin/settings.test.tsx @@ -4,6 +4,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const usePersonalNamespaceSettingsMock = vi.fn() +const useDefaultNamespacesMock = vi.fn() vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') @@ -29,6 +30,12 @@ vi.mock('@/features/admin/use-personal-namespace-settings', () => ({ useBackfillPersonalNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), })) +vi.mock('@/features/admin/use-default-namespaces', () => ({ + useDefaultNamespaces: () => useDefaultNamespacesMock(), + useUpdateDefaultNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), + useBackfillDefaultNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + import { AdminSettingsPage, previewSlug, renderTemplate } from './settings' describe('previewSlug', () => { @@ -64,6 +71,10 @@ describe('AdminSettingsPage', () => { }, isLoading: false, }) + useDefaultNamespacesMock.mockReturnValue({ + data: { slugs: ['global', 'musee'] }, + isLoading: false, + }) }) afterEach(() => { @@ -110,13 +121,23 @@ describe('AdminSettingsPage', () => { render() expect(await screen.findByText('adminSettings.backfillTitle')).toBeDefined() - expect(screen.getByRole('button', { name: 'adminSettings.backfillPreviewAction' })).toBeDefined() + // One preview button per backfill: default namespaces, and personal namespaces. + expect(screen.getAllByRole('button', { name: 'adminSettings.backfillPreviewAction' })).toHaveLength(2) }) it('keeps the apply button disabled until a preview has been run', async () => { render() - const apply = await screen.findByRole('button', { name: /backfillApplyAction/ }) + const apply = await screen.findByRole('button', { name: /adminSettings.backfillApplyAction/ }) expect((apply as HTMLButtonElement).disabled).toBe(true) }) + + it('shows the configured default namespaces the server returned', async () => { + render() + + const input = (await screen.findByLabelText('adminSettings.defaultsLabel')) as HTMLInputElement + await waitFor(() => { + expect(input.value).toBe('global, musee') + }) + }) }) diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx index 3036b3b27..64bc81813 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -14,12 +14,21 @@ import { TableHeader, TableRow, } from '@/shared/ui/table' -import type { PersonalNamespaceBackfillResult, PersonalNamespaceSettingsInput } from '@/api/types' +import type { + DefaultNamespaceBackfillResult, + PersonalNamespaceBackfillResult, + PersonalNamespaceSettingsInput, +} from '@/api/types' import { useBackfillPersonalNamespaces, usePersonalNamespaceSettings, useUpdatePersonalNamespaceSettings, } from '@/features/admin/use-personal-namespace-settings' +import { + useBackfillDefaultNamespaces, + useDefaultNamespaces, + useUpdateDefaultNamespaces, +} from '@/features/admin/use-default-namespaces' /** * Sample account used for the live template preview. @@ -55,6 +64,20 @@ export function AdminSettingsPage() { const backfillMutation = useBackfillPersonalNamespaces() const [backfill, setBackfill] = useState(null) + const { data: defaults, isLoading: defaultsLoading } = useDefaultNamespaces() + const updateDefaultsMutation = useUpdateDefaultNamespaces() + const defaultsBackfillMutation = useBackfillDefaultNamespaces() + // Null until loaded, for the same reason as `form` below. + const [defaultSlugs, setDefaultSlugs] = useState(null) + const [defaultsBackfill, setDefaultsBackfill] = useState(null) + + useEffect(() => { + if (!defaults) { + return + } + setDefaultSlugs((current) => current ?? defaults.slugs.join(', ')) + }, [defaults]) + // Null until the server answers. The form must not mount before then: Radix's // Select keeps a hidden native setDefaultSlugs(event.target.value)} + /> +

{t('adminSettings.defaultsHint')}

+ +
+ +
+ +
+ + +
+

{t('adminSettings.defaultsBackfillHint')}

+ + {defaultsBackfill ? ( +
+

+ {t('adminSettings.defaultsBackfillSummary', { + scanned: defaultsBackfill.scannedAccounts, + already: defaultsBackfill.alreadyEnrolled, + acted: defaultsBackfill.entries.length, + })} +

+ {defaultsBackfill.truncated ? ( +

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

+ ) : null} + {defaultsBackfill.entries.length === 0 ? ( +

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

+ ) : ( +
+ + + + {t('adminSettings.backfillColumnUser')} + {t('adminSettings.defaultsColumnSlugs')} + + + + {defaultsBackfill.entries.map((entry) => ( + + {entry.displayName || entry.userId} + {entry.slugs.join(', ')} + + ))} + +
+
+ )} +
+ ) : null} + + )} + +

{t('adminSettings.backfillTitle')}

From 7247defd5d28e1a25a651a19987dd88a413820b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Mon, 17 Aug 2026 00:43:57 +0800 Subject: [PATCH 7/7] feat(web): pick default namespaces from a list instead of typing slugs The setting took a comma-separated string, which puts the operator in the position of remembering exact slugs and getting the punctuation right. The server rejects a bad slug, but only after a round trip, and only for the first mistake in the list. Offer the active namespaces as checkboxes instead. Nothing to spell. One case the list alone would get wrong: a slug that is configured but has since been deleted, archived or renamed is not among the choices, so rendering only the choices would quietly drop it on the next save. Those are appended as their own rows, ticked and flagged, so dropping one is a decision rather than a side effect. The choice list is capped, and a directory larger than the cap says so rather than presenting a partial list as complete. --- web/src/i18n/locales/en.json | 9 ++- web/src/i18n/locales/zh.json | 9 ++- web/src/pages/admin/settings.test.tsx | 40 +++++++++++- web/src/pages/admin/settings.tsx | 87 ++++++++++++++++++++++----- 4 files changed, 120 insertions(+), 25 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index df4982056..c5cab6a00 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1690,14 +1690,17 @@ "backfillErrorTitle": "Backfill failed", "defaultsTitle": "Namespaces every account joins", "defaultsDescription": "A namespace nobody belongs to is invisible: the namespace list only shows namespaces you are a member of. List the ones every newly activated account should be enrolled in.", - "defaultsLabel": "Namespace slugs", - "defaultsHint": "Comma separated. Each must be an existing, active namespace.", + "defaultsLabel": "Namespaces", + "defaultsHint": "Tick the namespaces every newly activated account should be enrolled in.", "defaultsSaveDescription": "Accounts activated from now on join these namespaces.", "defaultsBackfillHint": "Preview first — the enrol button stays disabled until you do.", "defaultsBackfillApplyAction": "Enrol {{count}} accounts", "defaultsBackfillSummary": "Scanned {{scanned}} accounts · {{already}} already enrolled · {{acted}} to enrol", "defaultsBackfillNothingToDo": "Every account is already enrolled.", "defaultsBackfillDoneDescription": "Enrolled {{count}} accounts.", - "defaultsColumnSlugs": "Will join" + "defaultsColumnSlugs": "Will join", + "defaultsNoNamespaces": "No active namespaces to choose from yet.", + "defaultsMissingNamespace": "no longer exists — untick to drop it", + "defaultsTruncated": "Showing the first namespaces only; there are more than this list holds." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ff86398f9..c3bc9499b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1690,14 +1690,17 @@ "backfillErrorTitle": "补建失败", "defaultsTitle": "全员默认加入的命名空间", "defaultsDescription": "没有成员的命名空间是看不见的——命名空间列表只显示你是成员的那些。在这里列出每个新激活的账号都应该加入的命名空间。", - "defaultsLabel": "命名空间标识", - "defaultsHint": "用逗号分隔,每个都必须是已存在且启用的命名空间。", + "defaultsLabel": "命名空间", + "defaultsHint": "勾选每个新激活的账号都应该加入的命名空间。", "defaultsSaveDescription": "此后激活的账号会自动加入这些命名空间。", "defaultsBackfillHint": "请先预览——未预览前加入按钮不可用。", "defaultsBackfillApplyAction": "加入 {{count}} 个账号", "defaultsBackfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已加入 · {{acted}} 个待加入", "defaultsBackfillNothingToDo": "所有账号都已经加入了。", "defaultsBackfillDoneDescription": "已为 {{count}} 个账号加入。", - "defaultsColumnSlugs": "将加入" + "defaultsColumnSlugs": "将加入", + "defaultsNoNamespaces": "目前还没有可选的启用状态命名空间。", + "defaultsMissingNamespace": "已不存在——取消勾选以移除", + "defaultsTruncated": "命名空间数量超出列表上限,这里只显示了前一部分。" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx index 1f959c570..c1ad23fe0 100644 --- a/web/src/pages/admin/settings.test.tsx +++ b/web/src/pages/admin/settings.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const usePersonalNamespaceSettingsMock = vi.fn() const useDefaultNamespacesMock = vi.fn() +const useAdminNamespacesMock = vi.fn() vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') @@ -36,6 +37,10 @@ vi.mock('@/features/admin/use-default-namespaces', () => ({ useBackfillDefaultNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), })) +vi.mock('@/features/admin/use-admin-namespaces', () => ({ + useAdminNamespaces: () => useAdminNamespacesMock(), +})) + import { AdminSettingsPage, previewSlug, renderTemplate } from './settings' describe('previewSlug', () => { @@ -75,6 +80,13 @@ describe('AdminSettingsPage', () => { data: { slugs: ['global', 'musee'] }, isLoading: false, }) + useAdminNamespacesMock.mockReturnValue({ + data: { + items: [{ slug: 'global' }, { slug: 'musee' }, { slug: 'team-a' }], + total: 3, + }, + isLoading: false, + }) }) afterEach(() => { @@ -132,12 +144,34 @@ describe('AdminSettingsPage', () => { expect((apply as HTMLButtonElement).disabled).toBe(true) }) - it('shows the configured default namespaces the server returned', async () => { + it('ticks exactly the default namespaces the server returned', async () => { render() - const input = (await screen.findByLabelText('adminSettings.defaultsLabel')) as HTMLInputElement + await screen.findByText('adminSettings.defaultsTitle') + const ticked = (slug: string) => + (screen.getByText(slug).closest('label')?.querySelector('input') as HTMLInputElement).checked + await waitFor(() => { - expect(input.value).toBe('global, musee') + expect(ticked('global')).toBe(true) }) + expect(ticked('musee')).toBe(true) + expect(ticked('team-a')).toBe(false) + }) + + /** + * A configured namespace that has since been deleted is not in the choice list. It must still + * appear, ticked and flagged, so saving cannot drop it without the operator noticing. + */ + it('still offers a configured namespace that no longer exists', async () => { + useDefaultNamespacesMock.mockReturnValue({ + data: { slugs: ['global', 'vanished'] }, + isLoading: false, + }) + + render() + + const row = (await screen.findByText('vanished')).closest('label') + expect((row?.querySelector('input') as HTMLInputElement).checked).toBe(true) + expect(row?.textContent).toContain('adminSettings.defaultsMissingNamespace') }) }) diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx index 64bc81813..e8cec33f3 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -29,6 +29,13 @@ import { useDefaultNamespaces, useUpdateDefaultNamespaces, } from '@/features/admin/use-default-namespaces' +import { useAdminNamespaces } from '@/features/admin/use-admin-namespaces' + +/** + * Upper bound on the namespaces offered as choices. Beyond this the list is + * reported as partial rather than silently cut. + */ +const NAMESPACE_CHOICE_LIMIT = 200 /** * Sample account used for the live template preview. @@ -65,19 +72,43 @@ export function AdminSettingsPage() { const [backfill, setBackfill] = useState(null) const { data: defaults, isLoading: defaultsLoading } = useDefaultNamespaces() + const { data: namespacePage, isLoading: namespacesLoading } = useAdminNamespaces({ + status: 'ACTIVE', + page: 0, + size: NAMESPACE_CHOICE_LIMIT, + }) const updateDefaultsMutation = useUpdateDefaultNamespaces() const defaultsBackfillMutation = useBackfillDefaultNamespaces() // Null until loaded, for the same reason as `form` below. - const [defaultSlugs, setDefaultSlugs] = useState(null) + const [defaultSlugs, setDefaultSlugs] = useState(null) const [defaultsBackfill, setDefaultsBackfill] = useState(null) useEffect(() => { if (!defaults) { return } - setDefaultSlugs((current) => current ?? defaults.slugs.join(', ')) + setDefaultSlugs((current) => current ?? defaults.slugs) }, [defaults]) + const namespaceChoices = namespacePage?.items.map((item) => item.slug) ?? [] + // A slug can be configured and yet missing here — the namespace was deleted, archived or + // renamed. Surface it as its own choice rather than dropping it silently on the next save. + const staleSlugs = (defaultSlugs ?? []).filter((slug) => !namespaceChoices.includes(slug)) + const namespaceOptions = [...namespaceChoices, ...staleSlugs] + const namespacesTruncated = (namespacePage?.total ?? 0) > namespaceChoices.length + + const toggleDefaultSlug = (slug: string, checked: boolean) => { + setDefaultSlugs((current) => { + if (current === null) { + return current + } + if (checked) { + return current.includes(slug) ? current : [...current, slug] + } + return current.filter((value) => value !== slug) + }) + } + // Null until the server answers. The form must not mount before then: Radix's // Select keeps a hidden native setDefaultSlugs(event.target.value)} - /> + {t('adminSettings.defaultsLabel')} + {namespaceOptions.length === 0 ? ( +

{t('adminSettings.defaultsNoNamespaces')}

+ ) : ( +
+ {namespaceOptions.map((slug) => { + const missing = staleSlugs.includes(slug) + return ( + + ) + })} +
+ )} + {namespacesTruncated ? ( +

{t('adminSettings.defaultsTruncated')}

+ ) : null}

{t('adminSettings.defaultsHint')}