diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 579b6269..6a111f07 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -336,7 +336,6 @@ 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 c0948738..d43ca819 100644 --- a/docs/2026-08-13-personal-namespace-provisioning.md +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -105,29 +105,6 @@ 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 4b4735de..d6ec50e2 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,8 +4,6 @@ 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.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; @@ -15,7 +13,6 @@ 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.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -51,18 +48,4 @@ public class AdminSystemSettingController extends BaseApiController { 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 deleted file mode 100644 index 04df3456..00000000 --- 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/dto/PersonalNamespaceBackfillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java deleted file mode 100644 index 4b81391a..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -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 7f3868e9..b9a32784 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,12 +2,8 @@ package com.iflytek.skillhub.service; 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; @@ -26,7 +22,6 @@ 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, @@ -69,57 +64,6 @@ public class PersonalNamespaceSettingsAppService { 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 eedc1495..a0bf3c3f 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,12 +2,8 @@ package com.iflytek.skillhub.service; 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; @@ -24,12 +20,9 @@ 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.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 { @@ -112,50 +105,6 @@ class PersonalNamespaceSettingsAppServiceTest { .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 deleted file mode 100644 index 944c273f..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index a84e2e31..00000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java +++ /dev/null @@ -1,21 +0,0 @@ -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 b5b3bf81..6e61b533 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,23 +1,13 @@ 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. @@ -44,15 +34,6 @@ 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; @@ -60,20 +41,17 @@ 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, - UserAccountRepository userAccountRepository) { + NamespaceMemberRepository namespaceMemberRepository) { this.systemSettingService = systemSettingService; this.defaults = defaults; this.namespaceService = namespaceService; this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; - this.userAccountRepository = userAccountRepository; } /** @@ -96,16 +74,13 @@ public class PersonalNamespaceProvisioningService { 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, Set.of()); + 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()); @@ -118,88 +93,6 @@ public class PersonalNamespaceProvisioningService { 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. @@ -215,17 +108,12 @@ public class PersonalNamespaceProvisioningService { /** * 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, Set reserved) { + 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 (!reserved.contains(candidate) - && SlugValidator.isValid(candidate) - && namespaceRepository.findBySlug(candidate).isEmpty()) { + if (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 559ff271..5aca736d 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,21 +1,16 @@ 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; @@ -42,9 +37,6 @@ class PersonalNamespaceProvisioningServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; - @Mock - private UserAccountRepository userAccountRepository; - private PersonalNamespaceProvisioningService service; @BeforeEach @@ -54,17 +46,7 @@ class PersonalNamespaceProvisioningServiceTest { new PersonalNamespaceProvisioningProperties(), namespaceService, namespaceRepository, - 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))); + namespaceMemberRepository); } private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) { @@ -178,96 +160,6 @@ class PersonalNamespaceProvisioningServiceTest { 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 906947e3..972d013f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -49,7 +49,6 @@ import type { BatchMemberResponse, PersonalNamespaceSettings, PersonalNamespaceSettingsInput, - PersonalNamespaceBackfillResult, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1464,17 +1463,6 @@ 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 1bfb84d3..482e6c1e 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1588,22 +1588,6 @@ 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; @@ -4185,35 +4169,6 @@ 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; }; @@ -8671,30 +8626,6 @@ 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 9733263a..c2d0c2e1 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -596,19 +596,3 @@ 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 eca99402..5864c831 100644 --- a/web/src/features/admin/use-personal-namespace-settings.ts +++ b/web/src/features/admin/use-personal-namespace-settings.ts @@ -1,10 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { adminApi } from '@/api/client' -import type { - PersonalNamespaceBackfillResult, - PersonalNamespaceSettings, - PersonalNamespaceSettingsInput, -} from '@/api/types' +import type { PersonalNamespaceSettings, PersonalNamespaceSettingsInput } from '@/api/types' const QUERY_KEY = ['admin', 'settings', 'personal-namespace'] @@ -26,16 +22,3 @@ 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 8f5d9cf9..0127214e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1668,25 +1668,6 @@ "saveErrorTitle": "Could not save settings", "validationTitle": "Check the form", "validationTemplateRequired": "Templates cannot be empty.", - "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" + "fallbackErrorDescription": "Please try again." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 74bb82ad..4d75f11a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1668,25 +1668,6 @@ "saveErrorTitle": "保存设置失败", "validationTitle": "请检查表单", "validationTemplateRequired": "模板不能为空。", - "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": "补建失败" + "fallbackErrorDescription": "请稍后重试。" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx index 58e74586..33bd4853 100644 --- a/web/src/pages/admin/settings.test.tsx +++ b/web/src/pages/admin/settings.test.tsx @@ -24,7 +24,6 @@ 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' @@ -71,22 +70,6 @@ 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 61cc95ac..5e0e35c2 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -6,17 +6,8 @@ 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' @@ -52,8 +43,6 @@ 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, @@ -75,29 +64,6 @@ 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() @@ -198,73 +164,6 @@ 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} - ) }