Revert "feat(namespace): backfill personal namespaces for existing accounts"

This reverts commit 2d50437e4f.
This commit is contained in:
XiaoSeS 2026-08-28 15:19:56 +08:00
parent fbf6887e9d
commit dc31bb97f4
19 changed files with 9 additions and 719 deletions

View file

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

View file

@ -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 日志。
最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。
账号激活本身是低频事件,多两行日志的代价可以忽略。
## 三、配置
| 位置 | 项 | 默认 |

View file

@ -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<PersonalNamespaceBackfillResponse> backfillPersonalNamespaces(
@Valid @RequestBody PersonalNamespaceBackfillRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success", personalNamespaceSettingsAppService.backfill(
request, principal.userId(), AuditRequestContext.from(httpRequest)));
}
}

View file

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

View file

@ -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<Entry> entries) {
/**
* @param outcome one of {@code PLANNED}, {@code CREATED}, {@code NO_SLUG}
*/
public record Entry(String userId, String displayName, String slug, String outcome) {}
}

View file

@ -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<String> 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<String, Object> 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(),

View file

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

View file

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

View file

@ -1,21 +0,0 @@
package com.iflytek.skillhub.domain.namespace;
import java.util.List;
/**
* Outcome of a backfill run over existing accounts.
*
* <p>{@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<PersonalNamespaceBackfillEntry> entries) {
}

View file

@ -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<Namespace> 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.
*
* <p>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.
*
* <p>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<PersonalNamespaceBackfillEntry> entries = new ArrayList<>();
Set<String> reserved = new HashSet<>();
int scanned = 0;
int alreadyProvisioned = 0;
int systemAccounts = 0;
boolean truncated = false;
for (int page = 0; !truncated; page++) {
Page<UserAccount> 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<String> 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<String> 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;
}
}

View file

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

View file

@ -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<PersonalNamespaceBackfillResult> {
return fetchJson<PersonalNamespaceBackfillResult>(
'/api/v1/admin/settings/personal-namespace/backfill',
{
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ dryRun }),
},
)
},
}
export const notificationApi = {

View file

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

View file

@ -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[]
}

View file

@ -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<PersonalNamespaceBackfillResult, Error, boolean>({
mutationFn: (dryRun: boolean) => adminApi.backfillPersonalNamespaces(dryRun),
onSuccess: (result) => {
if (!result.dryRun) {
queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] })
}
},
})
}

View file

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

View file

@ -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": "请稍后重试。"
}
}

View file

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

View file

@ -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<PersonalNamespaceBackfillResult | null>(null)
const [form, setForm] = useState<PersonalNamespaceSettingsInput>({
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() {
</form>
)}
</Card>
<Card className="p-6">
<div className="mb-4">
<h2 className="text-xl font-semibold font-heading">{t('adminSettings.backfillTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('adminSettings.backfillDescription')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="button"
variant="outline"
disabled={backfillMutation.isPending}
onClick={() => runBackfill(true)}
>
{t('adminSettings.backfillPreviewAction')}
</Button>
<Button
type="button"
disabled={backfillMutation.isPending || !backfill?.dryRun || plannedCount === 0}
onClick={() => runBackfill(false)}
>
{t('adminSettings.backfillApplyAction', { count: plannedCount })}
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">{t('adminSettings.backfillPreviewFirstHint')}</p>
{backfill ? (
<div className="mt-6 space-y-3">
<p className="text-sm text-muted-foreground">
{t('adminSettings.backfillSummary', {
scanned: backfill.scannedAccounts,
already: backfill.alreadyProvisioned,
acted: backfill.entries.length,
})}
</p>
{backfill.truncated ? (
<p className="text-sm font-medium text-foreground">{t('adminSettings.backfillTruncated')}</p>
) : null}
{backfill.entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('adminSettings.backfillNothingToDo')}</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('adminSettings.backfillColumnUser')}</TableHead>
<TableHead>{t('adminSettings.backfillColumnSlug')}</TableHead>
<TableHead>{t('adminSettings.backfillColumnOutcome')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backfill.entries.map((entry) => (
<TableRow key={entry.userId}>
<TableCell>{entry.displayName || entry.userId}</TableCell>
<TableCell className="font-mono text-xs">{entry.slug ?? '—'}</TableCell>
<TableCell>{t(`adminSettings.backfillOutcome.${entry.outcome}`)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
) : null}
</Card>
</div>
)
}