From ee0f0763dbf0932b618181cb39ebaef8fb0779b2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:55:29 +0800 Subject: [PATCH] refactor(namespace): keep personal provisioning configuration-only --- docs/06-api-design.md | 9 - ...6-08-13-personal-namespace-provisioning.md | 57 +----- .../admin/AdminSystemSettingController.java | 51 ------ .../PersonalNamespaceSettingsResponse.java | 14 -- ...ersonalNamespaceSettingsUpdateRequest.java | 11 -- .../PersonalNamespaceSettingsAppService.java | 108 ----------- .../src/main/resources/application.yml | 8 +- .../db/migration/V44__system_setting.sql | 15 -- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + ...rsonalNamespaceSettingsAppServiceTest.java | 120 ------------- ...rsonalNamespaceProvisioningProperties.java | 9 +- .../PersonalNamespaceProvisioningService.java | 15 +- .../domain/setting/SystemSetting.java | 70 -------- .../setting/SystemSettingRepository.java | 8 - .../domain/setting/SystemSettingService.java | 78 -------- .../skillhub/domain/setting/package-info.java | 10 -- ...sonalNamespaceProvisioningServiceTest.java | 15 +- .../setting/SystemSettingServiceTest.java | 110 ------------ .../infra/jpa/SystemSettingJpaRepository.java | 13 -- web/src/api/client.ts | 20 --- web/src/api/generated/schema.d.ts | 86 +-------- web/src/api/types.ts | 15 +- web/src/app/router.tsx | 17 +- .../admin/use-personal-namespace-settings.ts | 24 --- web/src/i18n/locales/en.json | 33 +--- web/src/i18n/locales/zh.json | 33 +--- web/src/pages/admin/settings.test.tsx | 80 --------- web/src/pages/admin/settings.tsx | 169 ------------------ web/src/shared/components/user-menu.tsx | 5 - 30 files changed, 42 insertions(+), 1165 deletions(-) delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java delete mode 100644 server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java delete mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java delete mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java delete mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java delete mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java delete mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java delete mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java delete mode 100644 web/src/features/admin/use-personal-namespace-settings.ts delete mode 100644 web/src/pages/admin/settings.test.tsx delete mode 100644 web/src/pages/admin/settings.tsx diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6a111f07..56cb5541 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -330,15 +330,6 @@ 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 index d43ca819..b61b74ff 100644 --- a/docs/2026-08-13-personal-namespace-provisioning.md +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -5,40 +5,7 @@ 自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能, 而不必先向管理员申请命名空间、也不必把半成品塞进 `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)`, -滚动升级时旧节点读到新节点写入的文档不会报错。 - -## 二、自动创建个人命名空间 +## 一、自动创建个人命名空间 ### 「私有」在当前模型里的含义 @@ -96,8 +63,6 @@ namespace_member.user_id REFERENCES user_account(id) slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 字母数字以外的字符变连字符、去掉首尾与重复连字符。 **注意下划线不合法**——`${username}_space` 会得到 `alice-space`。 -控制台有实时预览,就是为了让这条规则在保存前可见。 - 冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用, 依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。 `admin` 这类保留字因此自然落到 `admin-2`。 @@ -105,28 +70,16 @@ slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。 解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。 -## 三、配置 +## 二、配置 | 位置 | 项 | 默认 | |------|-----|------| | `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` | -| 控制台 | 启用开关、slug 模板、显示名模板 | `${username}` | +| 配置文件/环境变量 | 启用开关 | `false` | **默认关闭**:升级不应该让现有部署突然开始建命名空间。 模板刻意**不放在 `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`。 +模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`, +如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。 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 deleted file mode 100644 index d6ec50e2..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index d09dbd8c..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 331d6a4c..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -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/service/PersonalNamespaceSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java deleted file mode 100644 index b9a32784..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java +++ /dev/null @@ -1,108 +0,0 @@ -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 e7abdd12..f851441e 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -117,12 +117,8 @@ skillhub: 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. + # Whether a newly activated account gets a namespace of its own. + # Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties. personal-provisioning: enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:false} public: 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 deleted file mode 100644 index 11f01954..00000000 --- a/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 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-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8791e6be..82b255e5 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -54,6 +54,7 @@ error.forbidden=Forbidden error.apiToken.scope.missing=API token is missing required scope: {0} error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0} error.request.timeout=Request timed out +error.request.include.unsupported=Unsupported include option: {0} error.rateLimit.exceeded=Rate limit exceeded error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. error.internal=An unexpected error occurred @@ -184,3 +185,4 @@ promotion.status.invalid=Unsupported promotion status: {0} promotion.sort.field.invalid=Unsupported promotion sort field: {0} promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting +error.skill.subscription.noPermission=You do not have permission to subscribe to this skill. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0e1b3fc3..0f2e1808 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作 error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0} error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0} error.request.timeout=请求超时 +error.request.include.unsupported=不支持的 include 参数:{0} error.rateLimit.exceeded=请求过于频繁,请稍后再试 error.storage.unavailable=对象存储暂时不可用,请稍后再试 error.internal=服务器内部错误 @@ -184,3 +185,4 @@ promotion.status.invalid=不支持的提升审核状态:{0} promotion.sort.field.invalid=不支持的提升审核排序字段:{0} promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 +error.skill.subscription.noPermission=您没有订阅此技能的权限。 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 deleted file mode 100644 index a0bf3c3f..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java +++ /dev/null @@ -1,120 +0,0 @@ -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-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 index 268138b2..6d7c39ff 100644 --- 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 @@ -6,9 +6,7 @@ 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. + *

Deployments can disable provisioning with the environment-backed {@code enabled} property. */ @Component @ConfigurationProperties(prefix = "skillhub.namespace.personal-provisioning") @@ -20,9 +18,8 @@ public class PersonalNamespaceProvisioningProperties { 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. + * Templates remain code defaults because Spring treats {@code ${...}} in YAML as property + * references. */ private String slugTemplate = "personal-${random}"; 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 e6b55af2..8efd576a 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,6 +1,5 @@ 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; @@ -27,8 +26,6 @@ import java.util.Optional; @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. */ @@ -36,20 +33,17 @@ public class PersonalNamespaceProvisioningService { 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; private final com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; - public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService, - PersonalNamespaceProvisioningProperties defaults, + public PersonalNamespaceProvisioningService(PersonalNamespaceProvisioningProperties defaults, NamespaceService namespaceService, NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository) { - this.systemSettingService = systemSettingService; this.defaults = defaults; this.namespaceService = namespaceService; this.namespaceRepository = namespaceRepository; @@ -61,12 +55,7 @@ public class PersonalNamespaceProvisioningService { * 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); + return defaults.toSettings(); } /** 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 deleted file mode 100644 index 2abc026b..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index ee6522cf..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6badc519..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 6f4038ce..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 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/namespace/PersonalNamespaceProvisioningServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java index 73788a1d..fc61b617 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,6 +1,5 @@ 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; @@ -25,9 +24,6 @@ class PersonalNamespaceProvisioningServiceTest { private static final PersonalNamespaceOwner ALICE = new PersonalNamespaceOwner("usr_alice", "alice", "alice@example.com"); - @Mock - private SystemSettingService systemSettingService; - @Mock private NamespaceService namespaceService; @@ -41,12 +37,13 @@ class PersonalNamespaceProvisioningServiceTest { private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; private PersonalNamespaceProvisioningService service; + private PersonalNamespaceProvisioningProperties properties; @BeforeEach void setUp() { + properties = new PersonalNamespaceProvisioningProperties(); service = new PersonalNamespaceProvisioningService( - systemSettingService, - new PersonalNamespaceProvisioningProperties(), + properties, namespaceService, namespaceRepository, namespaceMemberRepository, @@ -54,9 +51,9 @@ class PersonalNamespaceProvisioningServiceTest { } 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)); + properties.setEnabled(enabled); + properties.setSlugTemplate(slugTemplate); + properties.setDisplayNameTemplate(displayNameTemplate); } private void ownsNothing() { 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 deleted file mode 100644 index 581fd3fd..00000000 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 82d4754a..00000000 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -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); -} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 972d013f..5e4c7756 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -47,8 +47,6 @@ import type { LabelDefinition, LabelItem, BatchMemberResponse, - PersonalNamespaceSettings, - PersonalNamespaceSettingsInput, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1445,24 +1443,6 @@ 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 482e6c1e..423ee239 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -372,22 +372,6 @@ 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; @@ -3726,26 +3710,6 @@ 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"; @@ -4389,6 +4353,7 @@ export interface components { ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; resolutionMode?: string; complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"]; + labels?: components["schemas"]["SkillLabelDto"][]; }; ApiResponseBoolean: { /** Format: int32 */ @@ -5016,6 +4981,7 @@ export interface components { /** Format: int64 */ updatedAt?: number; latestVersion?: components["schemas"]["LatestVersion"]; + labels?: components["schemas"]["SkillLabelDto"][]; }; ApiResponseListSecurityAuditResponse: { /** Format: int32 */ @@ -6428,50 +6394,6 @@ 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; @@ -8082,6 +8004,8 @@ export interface operations { page?: number; limit?: number; sort?: string; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; }; header?: never; path?: never; @@ -9119,6 +9043,8 @@ export interface operations { q?: string; namespace?: string; label?: string[]; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; sort?: string; page?: number; size?: number; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c2d0c2e1..e5d62e12 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -208,6 +208,8 @@ export interface SkillSummary { ratingCount: number namespace: string updatedAt: string + ownerId?: string + ownerDisplayName?: string canSubmitPromotion: boolean headlineVersion?: SkillLifecycleVersion publishedVersion?: SkillLifecycleVersion @@ -583,16 +585,3 @@ 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 3f8c6196..71d91116 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -151,11 +151,6 @@ const AdminNamespacesPage = createRoleProtectedRouteComponent( 'AdminNamespacesPage', ['SUPER_ADMIN'], ) -const AdminSettingsPage = createRoleProtectedRouteComponent( - () => import('@/pages/admin/settings'), - 'AdminSettingsPage', - ['SUPER_ADMIN'], -) function DefaultNotFound() { return ( @@ -188,8 +183,8 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', - validateSearch: (search: Record): { returnTo: string; reason?: string } => ({ - returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + validateSearch: (search: Record): { returnTo?: string; reason?: string } => ({ + returnTo: typeof search.returnTo === 'string' && search.returnTo ? search.returnTo : undefined, reason: typeof search.reason === 'string' ? search.reason : undefined, }), component: LoginPage, @@ -462,13 +457,6 @@ const adminNamespacesRoute = createRoute({ component: AdminNamespacesPage, }) -const adminSettingsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'admin/settings', - beforeLoad: requireAuth, - component: AdminSettingsPage, -}) - const routeTree = rootRoute.addChildren([ landingRoute, skillsRoute, @@ -506,7 +494,6 @@ 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 deleted file mode 100644 index 5864c831..00000000 --- a/web/src/features/admin/use-personal-namespace-settings.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 0127214e..f22f7cf9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "Drop to upload...", "dragHint": "Drag a ZIP file here, or click to select", - "formatHint": "Only .zip format supported" + "formatHint": "Only .zip format supported", + "folderHint": "Or select a folder to package and upload" }, "layout": { "footerDescription": "Skill registry, providing efficient skill management and distribution for developers." @@ -1338,8 +1339,7 @@ "notifications": "Notification Settings", "accounts": "Account Merge", "logout": "Logout", - "namespacesAdmin": "Namespace management", - "platformSettings": "Platform settings" + "namespacesAdmin": "Namespace management" } }, "footer": { @@ -1390,7 +1390,8 @@ "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", - "selectRequired": "Please select namespace and file" + "selectRequired": "Please select namespace and file", + "folderPackagingFailed": "Could not package the selected folder. Make sure it contains files." }, "toast": { "success": "Success", @@ -1645,29 +1646,5 @@ "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 4d75f11a..f858ce9a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "放开以上传文件...", "dragHint": "拖拽 ZIP 文件到此处,或点击选择", - "formatHint": "仅支持 .zip 格式" + "formatHint": "仅支持 .zip 格式", + "folderHint": "或选择文件夹,自动打包上传" }, "layout": { "footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。" @@ -1338,8 +1339,7 @@ "notifications": "通知设置", "accounts": "账号合并", "logout": "退出登录", - "namespacesAdmin": "命名空间管理", - "platformSettings": "平台设置" + "namespacesAdmin": "命名空间管理" } }, "footer": { @@ -1390,7 +1390,8 @@ "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", - "selectRequired": "请选择命名空间和文件" + "selectRequired": "请选择命名空间和文件", + "folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。" }, "toast": { "success": "成功", @@ -1645,29 +1646,5 @@ "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 deleted file mode 100644 index 33bd4853..00000000 --- a/web/src/pages/admin/settings.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 5e0e35c2..00000000 --- a/web/src/pages/admin/settings.tsx +++ /dev/null @@ -1,169 +0,0 @@ -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 2139142f..bd3f4cba 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -195,11 +195,6 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.namespacesAdmin')} ) : null} - {isSuperAdmin ? ( - - {t('user.menu.platformSettings')} - - ) : null} {isAuditor ? ( {t('user.menu.auditLog')}