Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/06-api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,19 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN:
|------|------|------|
| GET | `/api/v1/admin/audit-logs` | 审计日志查询 |

### 平台设置(需 SUPER_ADMIN)

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/admin/settings/personal-namespace` | 读取「新账号自动建命名空间」策略 |
| PUT | `/api/v1/admin/settings/personal-namespace` | 更新该策略(写审计日志) |
| POST | `/api/v1/admin/settings/personal-namespace/backfill` | 为已有账号补建;`dryRun=true` 只返回计划,不写库 |
| GET | `/api/v1/admin/settings/default-namespaces` | 读取「新账号默认加入的命名空间」列表 |
| PUT | `/api/v1/admin/settings/default-namespaces` | 更新该列表(slug 必须存在且为 ACTIVE;写审计日志)|
| POST | `/api/v1/admin/settings/default-namespaces/backfill` | 把已有账号补加入这些命名空间;`dryRun=true` 只返回计划 |

详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。

## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN)

| 方法 | 路径 | 说明 |
Expand Down
176 changes: 176 additions & 0 deletions docs/2026-08-13-personal-namespace-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 注册时自动创建个人命名空间

## 背景

自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。

在此之前 SkillHub 没有任何「全局设置」机制——只有按用户维度的通知偏好,
凡是部署级开关都只能靠配置文件加环境变量,改一次要重启。
本次改动同时补上这两块:一个通用的设置存储,和第一个使用它的功能。

## 一、通用设置存储(`system_setting`)

```sql
CREATE TABLE system_setting (
setting_key VARCHAR(128) PRIMARY KEY,
setting_value JSONB NOT NULL,
updated_by VARCHAR(128) REFERENCES user_account(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```

一行存一组设置,值是 JSON 文档,因此一组设置增加字段不需要新的迁移。

`SystemSettingService` 的读取接口强制调用方传入默认值:

```java
<T> T get(String settingKey, Class<T> type, T defaults)
```

这带来两个性质:

- **管理员没动过的设置组不存在数据库行**,读取时回落到部署的配置文件默认值。
纯配置化的部署可以完全不碰控制台,行为与本功能上线前一致。
- **存量文档解析失败时同样回落到默认值**,并打一条 WARN 日志。
一行损坏的设置不应该让登录这种关键路径挂掉。

设置组用 `@JsonIgnoreProperties(ignoreUnknown = true)`,
滚动升级时旧节点读到新节点写入的文档不会报错。

## 二、自动创建个人命名空间

### 「私有」在当前模型里的含义

命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型,
技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。
本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。

如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。

### 触发时机

在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`:

| 入口 | 位置 |
|------|------|
| 本地注册 | `LocalAuthService.register` |
| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) |
| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) |

第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号,
真正可用是在管理员审批那一刻。

### 为什么走事件 + AFTER_COMMIT

`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener`
(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束:

```
namespace.created_by REFERENCES user_account(id)
namespace_member.user_id REFERENCES user_account(id)
```

- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚,
用户会因为「命名空间没建成」而登不上来。
- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行,
外键检查会阻塞在外层事务的行锁上,形成互等。

放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间,
监听器捕获异常并记 WARN。

监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。

### 命名模板

两个模板,占位符语法 `${...}`:

| 占位符 | 取值 |
|--------|------|
| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID |
| `${email_prefix}` | 邮箱 `@` 之前的部分 |
| `${user_id}` | 平台内部用户 ID |

未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。

slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
字母数字以外的字符变连字符、去掉首尾与重复连字符。
**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。
控制台有实时预览,就是为了让这条规则在保存前可见。

冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用,
依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。
`admin` 这类保留字因此自然落到 `admin-2`。

幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。
解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。

### 已有账号的补建

只在「账号第一次变得可用」触发有个后果:**在一个已经跑了一段时间的部署上打开开关,等于对现有的人全部无效**。
这不是理论问题——本功能上线后第一个来问「为什么我没有 namespace」的,就是站点管理员自己。

所以提供 `POST /api/v1/admin/settings/personal-namespace/backfill`:

- 遍历 ACTIVE 账号,跳过系统账号和已拥有非 global 命名空间的账号
- `dryRun=true` 时只返回计划(每个账号将拿到的 slug),不写任何东西;
控制台强制先预览、后执行
- 返回体只列出「会被改动」和「放不下」的账号,其余只给计数——
管理员看到的是待办,不是整个通讯录
- 单次运行有账号数上限,达到上限时返回 `truncated=true` 而不是假装跑完了
- 一次运行内已经许诺出去的 slug 会被预留,避免同一批里把同一个 slug 发给两个人
- **不加 `@Transactional`**:每个命名空间各自一个事务,
某个账号放不下不会把整批已建好的回滚掉

### 可诊断性

三条跳过路径——开关关闭、账号已有命名空间、没有可用 slug——都记 INFO/WARN 日志。
最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。
账号激活本身是低频事件,多两行日志的代价可以忽略。

## 二·五、全员默认加入的命名空间

「自动建一个自己的命名空间」解决的是个人空间;另一个相邻问题是**组织级公共空间**。

部署方新建一个命名空间来代替内置的 `global` 时会发现它对所有人不可见——
`listNamespaces` 只返回调用者是成员的命名空间,而「新账号自动入伙」这件事
原本写死在 `GlobalNamespaceMembershipService` 里,只认 slug `global`。

所以把它一般化为 `DefaultNamespaceMembershipService`:

- 设置项 `namespace.default-membership` 存一个 slug 列表,默认 `["global"]`,
即改造前的行为
- 保存时校验每个 slug 存在且为 ACTIVE,让拼错在保存那一刻就暴露,
而不是变成某个人首次登录时的一条警告
- 运行期遇到已被删除或改名的 slug 只记 WARN 并跳过——
一个不存在的命名空间不该让人登不上来
- 同样配了预览 + 执行的补建,把存量账号一次性加进去

发布只要求「是该命名空间的成员」(任意角色),所以加入即可发布,
不需要额外授予角色。

## 三、配置

| 位置 | 项 | 默认 |
|------|-----|------|
| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` |
| 控制台 | 启用开关、slug 模板、显示名模板 | `${username}` |

**默认关闭**:升级不应该让现有部署突然开始建命名空间。

模板刻意**不放在 `application.yml`**:它们含 `${...}`,
Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。
模板的默认值写在 `PersonalNamespaceProvisioningProperties` 的 Java 字段里,
运行期改动走控制台。

## 四、审计

`PUT /api/v1/admin/settings/personal-namespace` 写一条审计日志,
action 为 `SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE`,target type `SYSTEM_SETTING`,
detail 中包含改动前后的完整设置。

## 五、后续可以复用的地方

`system_setting` 是通用的。最直接的下一个使用者是
[#318](https://github.com/iflytek/skillhub/issues/318)(管理员开关本地注册)——
目前只能靠在网关层挡 `/api/v1/auth/local/register`。
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.iflytek.skillhub.controller.admin;

import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.BackfillRequest;
import com.iflytek.skillhub.dto.DefaultNamespaceBackfillResponse;
import com.iflytek.skillhub.dto.DefaultNamespaceSettingsResponse;
import com.iflytek.skillhub.dto.DefaultNamespaceSettingsUpdateRequest;
import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse;
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse;
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.DefaultNamespaceSettingsAppService;
import com.iflytek.skillhub.service.PersonalNamespaceSettingsAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
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;
import org.springframework.web.bind.annotation.RestController;

/**
* Platform-wide settings an operator can change without redeploying.
*/
@RestController
@RequestMapping("/api/v1/admin/settings")
public class AdminSystemSettingController extends BaseApiController {

private final PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService;
private final DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService;

public AdminSystemSettingController(PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService,
DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.personalNamespaceSettingsAppService = personalNamespaceSettingsAppService;
this.defaultNamespaceSettingsAppService = defaultNamespaceSettingsAppService;
}

@GetMapping("/personal-namespace")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<PersonalNamespaceSettingsResponse> getPersonalNamespaceSettings() {
return ok("response.success.read", personalNamespaceSettingsAppService.get());
}

@PutMapping("/personal-namespace")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<PersonalNamespaceSettingsResponse> updatePersonalNamespaceSettings(
@Valid @RequestBody PersonalNamespaceSettingsUpdateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
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 BackfillRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success", personalNamespaceSettingsAppService.backfill(
request, principal.userId(), AuditRequestContext.from(httpRequest)));
}

@GetMapping("/default-namespaces")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<DefaultNamespaceSettingsResponse> getDefaultNamespaces() {
return ok("response.success.read", defaultNamespaceSettingsAppService.get());
}

@PutMapping("/default-namespaces")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<DefaultNamespaceSettingsResponse> updateDefaultNamespaces(
@Valid @RequestBody DefaultNamespaceSettingsUpdateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", defaultNamespaceSettingsAppService.update(
request, principal.userId(), AuditRequestContext.from(httpRequest)));
}

/**
* Enrols existing accounts in the configured default namespaces, for when one is added after
* people have already signed up. Send {@code dryRun} to see the plan first.
*/
@PostMapping("/default-namespaces/backfill")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<DefaultNamespaceBackfillResponse> backfillDefaultNamespaces(
@Valid @RequestBody BackfillRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success", defaultNamespaceSettingsAppService.backfill(
Boolean.TRUE.equals(request.dryRun()), principal.userId(),
AuditRequestContext.from(httpRequest)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotNull;

/**
* Shared body for the admin backfill endpoints.
*
* @param dryRun when true, report what would happen without writing anything
*/
public record BackfillRequest(@NotNull Boolean dryRun) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.iflytek.skillhub.dto;

import java.util.List;

/**
* @param truncated the run stopped at its per-run account cap; re-run to continue
* @param entries only the accounts that were enrolled, or would be
*/
public record DefaultNamespaceBackfillResponse(
boolean dryRun,
int scannedAccounts,
int alreadyEnrolled,
int systemAccountsSkipped,
boolean truncated,
List<Entry> entries) {

public record Entry(String userId, String displayName, List<String> slugs) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;

import java.util.List;

/**
* @param slugs namespaces every newly activated account is enrolled in
*/
public record DefaultNamespaceSettingsResponse(List<String> slugs) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.util.List;

/**
* @param slugs may be empty, which means new accounts are enrolled nowhere
*/
public record DefaultNamespaceSettingsUpdateRequest(
@NotNull @Size(max = 20) List<@Size(max = 64) String> slugs
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.iflytek.skillhub.dto;

import java.util.List;

/**
* @param truncated the run stopped at its per-run account cap; re-run to continue
* @param entries only the accounts that were changed, or could not be placed
*/
public record PersonalNamespaceBackfillResponse(
boolean dryRun,
int scannedAccounts,
int alreadyProvisioned,
int systemAccountsSkipped,
boolean truncated,
List<Entry> entries) {

/**
* @param outcome one of {@code PLANNED}, {@code CREATED}, {@code NO_SLUG}
*/
public record Entry(String userId, String displayName, String slug, String outcome) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;

import java.util.List;

/**
* @param supportedPlaceholders placeholder names the templates accept, so the console can document
* them without hard-coding the list
*/
public record PersonalNamespaceSettingsResponse(
boolean enabled,
String slugTemplate,
String displayNameTemplate,
List<String> supportedPlaceholders
) {}
Loading