mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Revert "feat(namespace): backfill personal namespaces for existing accounts"
This reverts commit 2d50437e4f.
This commit is contained in:
parent
fbf6887e9d
commit
dc31bb97f4
19 changed files with 9 additions and 719 deletions
|
|
@ -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)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 日志。
|
||||
最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。
|
||||
账号激活本身是低频事件,多两行日志的代价可以忽略。
|
||||
|
||||
## 三、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
69
web/src/api/generated/schema.d.ts
vendored
69
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "请稍后重试。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue