feat: 创建租户时自动生成管理员账号、角色和权限

创建租户改为事务化一站式操作:自动复制 gdlib 权限模板 + 补充基础管理权限,
创建 tenant_admin 角色和管理员用户,支持自定义账号密码。
前端表单增加管理员输入区块,成功弹窗展示凭据并支持一键复制。
同步实现 menuIds 菜单分配(消除原 TODO)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-04-02 18:48:34 +08:00
parent 8154628d3d
commit 3c4100c231
8 changed files with 416 additions and 37 deletions

View File

@ -33,8 +33,8 @@ public class SysTenantController {
@PostMapping
@RequirePermission("tenant:create")
@Operation(summary = "创建租户")
public Result<SysTenant> create(@Valid @RequestBody CreateTenantDto dto) {
@Operation(summary = "创建租户(自动创建管理员账号、角色、权限)")
public Result<Map<String, Object>> create(@Valid @RequestBody CreateTenantDto dto) {
Long currentTenantId = SecurityUtil.getCurrentTenantId();
return Result.success(tenantService.createTenant(dto, currentTenantId));
}

View File

@ -29,4 +29,10 @@ public class CreateTenantDto {
@Schema(description = "分配菜单 ID 列表")
private List<Long> menuIds;
@Schema(description = "管理员用户名(默认 admin")
private String adminUsername;
@Schema(description = "管理员密码(默认 admin@{tenantCode}")
private String adminPassword;
}

View File

@ -10,7 +10,7 @@ import java.util.Map;
public interface ISysTenantService extends IService<SysTenant> {
SysTenant createTenant(CreateTenantDto dto, Long currentTenantId);
Map<String, Object> createTenant(CreateTenantDto dto, Long currentTenantId);
PageResult<Map<String, Object>> findAll(Long page, Long pageSize, String keyword, String tenantType);

View File

@ -9,14 +9,14 @@ import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.sys.dto.CreateTenantDto;
import com.competition.modules.sys.dto.UpdateTenantDto;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysTenantMapper;
import com.competition.modules.sys.mapper.SysUserMapper;
import com.competition.modules.sys.entity.*;
import com.competition.modules.sys.mapper.*;
import com.competition.modules.sys.service.ISysTenantService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@ -30,10 +30,32 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
private static final Set<String> INTERNAL_TENANT_CODES = Set.of("super", "public", "school", "teacher", "student", "judge");
/** 广东省图租户编码,作为新租户权限模板 */
private static final String TEMPLATE_TENANT_CODE = "gdlib";
/** 基础管理权限(模板租户可能缺少,需补充) */
private static final List<String[]> BASE_ADMIN_PERMISSIONS = List.of(
// {name, code, resource, action, description}
new String[]{"更新用户", "user:update", "user", "update", "允许更新用户信息"},
new String[]{"删除用户", "user:delete", "user", "delete", "允许删除用户"},
new String[]{"创建角色", "role:create", "role", "create", "允许创建新角色"},
new String[]{"更新角色", "role:update", "role", "update", "允许更新角色信息"},
new String[]{"删除角色", "role:delete", "role", "delete", "允许删除角色"},
new String[]{"分配角色", "role:assign", "role", "assign", "允许给用户分配角色"},
new String[]{"查看权限", "permission:read", "permission", "read", "允许查看权限列表"}
);
private final SysUserMapper userMapper;
private final SysRoleMapper roleMapper;
private final SysPermissionMapper permissionMapper;
private final SysRolePermissionMapper rolePermissionMapper;
private final SysUserRoleMapper userRoleMapper;
private final SysTenantMenuMapper tenantMenuMapper;
private final PasswordEncoder passwordEncoder;
@Override
public SysTenant createTenant(CreateTenantDto dto, Long currentTenantId) {
@Transactional
public Map<String, Object> createTenant(CreateTenantDto dto, Long currentTenantId) {
log.info("开始创建租户,编码:{}", dto.getCode());
checkSuperTenant(currentTenantId);
@ -42,6 +64,7 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
throw BusinessException.of(ErrorCode.CONFLICT, "租户编码已存在");
}
// 1. 创建租户
SysTenant tenant = new SysTenant();
tenant.setName(dto.getName());
tenant.setCode(dto.getCode());
@ -50,11 +73,126 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
tenant.setTenantType(dto.getTenantType() != null ? dto.getTenantType() : "other");
tenant.setIsSuper(0);
save(tenant);
Long tenantId = tenant.getId();
// TODO: Phase 2 处理 menuIds 关联
// 2. 复制模板租户权限 + 补充基础管理权限
List<Long> permissionIds = copyTemplatePermissions(tenantId);
log.info("租户创建成功ID{}", tenant.getId());
return tenant;
// 3. 创建管理员角色
SysRole adminRole = new SysRole();
adminRole.setTenantId(tenantId);
adminRole.setName("机构管理员");
adminRole.setCode("tenant_admin");
adminRole.setDescription(tenant.getName() + "管理员角色,拥有该租户所有权限");
roleMapper.insert(adminRole);
// 4. 绑定角色-权限
for (Long permId : permissionIds) {
SysRolePermission rp = new SysRolePermission();
rp.setRoleId(adminRole.getId());
rp.setPermissionId(permId);
rolePermissionMapper.insert(rp);
}
// 5. 创建管理员用户
String adminUsername = dto.getAdminUsername() != null && !dto.getAdminUsername().isBlank()
? dto.getAdminUsername() : "admin";
String adminPassword = dto.getAdminPassword() != null && !dto.getAdminPassword().isBlank()
? dto.getAdminPassword() : "admin@" + dto.getCode();
SysUser adminUser = new SysUser();
adminUser.setTenantId(tenantId);
adminUser.setUsername(adminUsername);
adminUser.setPassword(passwordEncoder.encode(adminPassword));
adminUser.setNickname(tenant.getName() + "管理员");
adminUser.setStatus("enabled");
adminUser.setUserSource("admin_created");
adminUser.setUserType("adult");
userMapper.insert(adminUser);
// 6. 绑定用户-角色
SysUserRole ur = new SysUserRole();
ur.setUserId(adminUser.getId());
ur.setRoleId(adminRole.getId());
userRoleMapper.insert(ur);
// 7. 分配菜单
if (dto.getMenuIds() != null && !dto.getMenuIds().isEmpty()) {
for (Long menuId : dto.getMenuIds()) {
SysTenantMenu tm = new SysTenantMenu();
tm.setTenantId(tenantId);
tm.setMenuId(menuId);
tenantMenuMapper.insert(tm);
}
}
log.info("租户创建成功ID{},管理员:{}", tenantId, adminUsername);
// 返回租户信息 + 管理员凭据
Map<String, Object> result = new HashMap<>();
result.put("tenant", tenant);
result.put("admin", Map.of(
"username", adminUsername,
"password", adminPassword,
"userId", adminUser.getId(),
"nickname", adminUser.getNickname()
));
return result;
}
/**
* 从模板租户复制权限并补充基础管理权限
*/
private List<Long> copyTemplatePermissions(Long newTenantId) {
List<Long> newPermissionIds = new ArrayList<>();
// 查找模板租户
SysTenant templateTenant = getOne(
new LambdaQueryWrapper<SysTenant>().eq(SysTenant::getCode, TEMPLATE_TENANT_CODE), false);
if (templateTenant == null) {
log.warn("模板租户 {} 不存在,仅创建基础管理权限", TEMPLATE_TENANT_CODE);
} else {
// 复制模板租户的所有权限
List<SysPermission> templatePerms = permissionMapper.selectList(
new LambdaQueryWrapper<SysPermission>()
.eq(SysPermission::getTenantId, templateTenant.getId())
.eq(SysPermission::getValidState, 1));
for (SysPermission tp : templatePerms) {
SysPermission np = new SysPermission();
np.setTenantId(newTenantId);
np.setName(tp.getName());
np.setCode(tp.getCode());
np.setResource(tp.getResource());
np.setAction(tp.getAction());
np.setDescription(tp.getDescription());
permissionMapper.insert(np);
newPermissionIds.add(np.getId());
}
}
// 收集已有的权限编码避免重复
Set<String> existingCodes = new HashSet<>();
for (Long pid : newPermissionIds) {
SysPermission p = permissionMapper.selectById(pid);
if (p != null) existingCodes.add(p.getCode());
}
// 补充基础管理权限
for (String[] perm : BASE_ADMIN_PERMISSIONS) {
if (!existingCodes.contains(perm[1])) {
SysPermission np = new SysPermission();
np.setTenantId(newTenantId);
np.setName(perm[0]);
np.setCode(perm[1]);
np.setResource(perm[2]);
np.setAction(perm[3]);
np.setDescription(perm[4]);
permissionMapper.insert(np);
newPermissionIds.add(np.getId());
}
}
return newPermissionIds;
}
@Override
@ -141,7 +279,17 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
if (dto.getTenantType() != null) tenant.setTenantType(dto.getTenantType());
updateById(tenant);
// TODO: Phase 2 处理 menuIds 关联
// 更新菜单关联先删后增
if (dto.getMenuIds() != null) {
tenantMenuMapper.delete(
new LambdaQueryWrapper<SysTenantMenu>().eq(SysTenantMenu::getTenantId, id));
for (Long menuId : dto.getMenuIds()) {
SysTenantMenu tm = new SysTenantMenu();
tm.setTenantId(id);
tm.setMenuId(menuId);
tenantMenuMapper.insert(tm);
}
}
return getById(id);
}

View File

@ -12,6 +12,7 @@
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
| [机构管理优化](./super-admin/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
| [租户创建自动生成管理员](./super-admin/tenant-auto-create-admin.md) | 机构管理 | 已完成 | 2026-04-02 |
## 租户端(机构管理端)

View File

@ -0,0 +1,160 @@
# 租户创建自动生成管理员账号 — 设计方案
> 所属端:超管端
> 状态:已完成
> 创建日期2026-04-02
> 最后更新2026-04-02
---
## 1. 背景与问题
超管端创建新租户(机构)后,需要**手动**到用户管理页面另行创建该租户的管理员账号、角色、权限,流程割裂且容易遗漏,导致新建的租户无法登录使用。
核心问题:
- 创建租户与创建管理员是分离的两步操作,缺乏原子性
- 新租户没有默认角色和权限,管理员账号无法正常使用
- 菜单分配menuIds也未实现后端标记为 TODO
## 2. 现状分析
### 2.1 创建租户接口
- `POST /api/tenants``SysTenantServiceImpl.createTenant()`
- 仅创建 `t_sys_tenant` 记录,不创建用户、角色、权限、菜单关联
- menuIds 参数存在但未处理TODO 注释)
### 2.2 初始化脚本的做法
`backend/scripts/init-dev-tenants.ts`Node 版,已移除)中,创建租户时会一并完成:
1. 创建租户 → 2. 创建权限 → 3. 创建角色 → 4. 角色绑定权限 → 5. 创建 admin 用户 → 6. 用户绑定角色 → 7. 分配菜单
`init.sql` 中每个租户也都有对应的 admin 用户和角色。
### 2.3 涉及的数据表
| 表名 | 说明 |
|------|------|
| `t_sys_tenant` | 租户 |
| `t_sys_user` | 用户 |
| `t_sys_role` | 角色 |
| `t_sys_permission` | 权限(按租户隔离) |
| `t_sys_user_role` | 用户-角色关联 |
| `t_sys_role_permission` | 角色-权限关联 |
| `t_sys_tenant_menu` | 租户-菜单关联 |
## 3. 设计方案
### 3.1 整体思路
创建租户时在同一事务中自动完成:创建租户 → 复制权限模板 → 创建管理员角色 → 创建管理员用户 → 绑定关联关系 → 分配菜单。接口返回租户信息 + 管理员凭据。
### 3.2 权限模板策略
- **模板来源**运行时从广东省图租户code=`gdlib`tenant_id=9复制权限该租户拥有 26 项业务权限,覆盖活动、报名、评审、作品、公告等核心模块
- **补充基础管理权限**:模板中缺少的 7 项基础权限自动补充:
- `user:update`、`user:delete` — 用户管理
- `role:create`、`role:update`、`role:delete`、`role:assign` — 角色管理
- `permission:read` — 权限查看
- 新租户最终获得 **33 项权限**26 模板 + 7 补充)
### 3.3 前端页面设计
**创建表单改动**
- 基本信息 Tab 增加「管理员账号」区块(用分割线分隔)
- 新增两个输入框:管理员账号(默认 admin、管理员密码默认 admin@{编码}
- 仅新建时显示,编辑时隐藏
**成功弹窗改动**
- 原先:提示"创建成功"+ 跳转用户管理页按钮
- 现在:展示管理员账号、初始密码、登录地址
- 新增「复制账号信息」按钮,一键复制全部信息到剪贴板
### 3.4 后端改动
**`CreateTenantDto`** — 新增字段:
- `adminUsername`String可选默认 "admin"
- `adminPassword`String可选默认 "admin@{tenantCode}"
**`ISysTenantService.createTenant()`** — 返回值改为 `Map<String, Object>`
**`SysTenantServiceImpl.createTenant()`** — 核心改动,事务内完成 7 步:
1. 创建租户记录
2. `copyTemplatePermissions()` — 复制 gdlib 权限 + 补充基础权限
3. 创建 `tenant_admin` 角色
4. 批量绑定角色-权限
5. 创建管理员用户(密码 BCrypt 加密)
6. 绑定用户-角色
7. 处理 menuIds 菜单分配
新增私有方法 `copyTemplatePermissions(Long newTenantId)`
- 查询 gdlib 租户的全部权限,逐条复制(替换 tenantId
- 对比已有权限编码,补充缺失的基础管理权限
**`SysTenantServiceImpl.updateTenant()`** — 同步实现 menuIds 更新(先删后增)
**`SysTenantController.create()`** — 返回类型从 `Result<SysTenant>` 改为 `Result<Map<String, Object>>`
### 3.5 接口响应格式
```json
{
"code": 200,
"data": {
"tenant": {
"id": 13,
"name": "测试图书馆",
"code": "test-lib",
"tenantType": "library",
...
},
"admin": {
"username": "libadmin",
"password": "Lib@2026",
"userId": 32,
"nickname": "测试图书馆管理员"
}
}
}
```
### 3.6 前端改动
**`src/api/tenants.ts`**
- `CreateTenantForm` 新增 `adminUsername`、`adminPassword`
- 新增 `CreateTenantResult` 接口
- `createTenant()` 返回类型改为 `CreateTenantResult`
**`src/views/system/tenants/Index.vue`**
- 表单 reactive 新增 `adminUsername`、`adminPassword`
- 模板增加管理员输入区块
- 成功弹窗展示管理员信息卡片 + 复制按钮
- 移除 `useRouter`、`useAuthStore` 无用依赖
## 4. 改动范围
### 后端Java
| 操作 | 文件 |
|------|------|
| 修改 | `modules/sys/dto/CreateTenantDto.java` |
| 修改 | `modules/sys/service/ISysTenantService.java` |
| 修改 | `modules/sys/service/impl/SysTenantServiceImpl.java` |
| 修改 | `modules/sys/controller/SysTenantController.java` |
### 前端
| 操作 | 文件 |
|------|------|
| 修改 | `src/api/tenants.ts` |
| 修改 | `src/views/system/tenants/Index.vue` |
## 5. 实施记录
### 2026-04-02 — 初版实现
- 完成后端事务化创建租户 + 管理员 + 角色 + 权限 + 菜单分配
- 权限模板使用 gdlib 租户26项+ 7项基础管理权限补充
- 前端表单增加管理员账号/密码输入,成功弹窗展示凭据信息
- 测试验证:创建租户 → 管理员登录成功 → 获得 97 项权限(含 gdlib 完整业务权限 + 补充权限)
- 同步实现了 `updateTenant` 的 menuIds 更新(消除 TODO

View File

@ -31,6 +31,18 @@ export interface CreateTenantForm {
domain?: string;
description?: string;
menuIds?: number[];
adminUsername?: string;
adminPassword?: string;
}
export interface CreateTenantResult {
tenant: Tenant;
admin: {
username: string;
password: string;
userId: number;
nickname: string;
};
}
export interface UpdateTenantForm {
@ -61,9 +73,9 @@ export async function getTenantDetail(id: number): Promise<Tenant> {
return response;
}
// 创建租户
export async function createTenant(data: CreateTenantForm): Promise<Tenant> {
const response = await request.post<any, Tenant>("/tenants", data);
// 创建租户(自动创建管理员账号)
export async function createTenant(data: CreateTenantForm): Promise<CreateTenantResult> {
const response = await request.post<any, CreateTenantResult>("/tenants", data);
return response;
}

View File

@ -131,6 +131,21 @@
<a-textarea v-model:value="form.description" placeholder="机构描述(可选)" :rows="3"
:maxlength="500" />
</a-form-item>
<template v-if="!editingId">
<a-divider style="margin: 16px 0 8px;">管理员账号</a-divider>
<a-form-item label="管理员账号" name="adminUsername">
<a-input v-model:value="form.adminUsername" placeholder="默认 admin" :maxlength="50" />
<template #extra>
<span class="form-hint">留空则默认为 admin</span>
</template>
</a-form-item>
<a-form-item label="管理员密码" name="adminPassword">
<a-input-password v-model:value="form.adminPassword" placeholder="默认 admin@{机构编码}" :maxlength="50" />
<template #extra>
<span class="form-hint">留空则默认为 admin@{{ form.code || '{机构编码}' }}</span>
</template>
</a-form-item>
</template>
<a-form-item v-if="editingId" label="状态" name="validState">
<a-radio-group v-model:value="form.validState">
<a-radio :value="1">正常</a-radio>
@ -174,12 +189,26 @@
</a-modal>
<!-- 新建成功引导弹窗 -->
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="460px">
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="接下来你可以">
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="500px">
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="管理员账号已自动创建请妥善保管以下信息">
<template #extra>
<div style="display: flex; flex-direction: column; gap: 12px; align-items: center">
<a-button type="primary" @click="goCreateAdmin">为该机构创建管理员账号</a-button>
<a-button @click="guideVisible = false">稍后再说</a-button>
<div class="admin-info-card" v-if="createdAdmin">
<div class="admin-info-row">
<span class="admin-info-label">管理员账号</span>
<span class="admin-info-value">{{ createdAdmin.username }}</span>
</div>
<div class="admin-info-row">
<span class="admin-info-label">初始密码</span>
<span class="admin-info-value admin-info-password">{{ createdAdmin.password }}</span>
</div>
<div class="admin-info-row">
<span class="admin-info-label">登录地址</span>
<span class="admin-info-value" style="font-family: monospace; font-size: 12px;">{{ loginUrlForCreated }}</span>
</div>
</div>
<div style="display: flex; gap: 12px; justify-content: center; margin-top: 16px;">
<a-button type="primary" @click="copyAdminInfo">复制账号信息</a-button>
<a-button @click="guideVisible = false">我已记录</a-button>
</div>
</template>
</a-result>
@ -189,7 +218,6 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
import { PlusOutlined, UserOutlined, SafetyOutlined, CopyOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
@ -198,12 +226,9 @@ import {
type Tenant,
type CreateTenantForm,
type UpdateTenantForm,
type CreateTenantResult,
} from '@/api/tenants'
import { menusApi, type Menu } from '@/api/menus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const dataSource = ref<Tenant[]>([])
@ -222,10 +247,10 @@ const activeTab = ref('basic')
const searchKeyword = ref('')
const searchType = ref('')
// #6
//
const guideVisible = ref(false)
const lastCreatedName = ref('')
const lastCreatedId = ref<number | null>(null)
const lastCreatedCode = ref('')
//
const allMenus = ref<Menu[]>([])
@ -239,8 +264,13 @@ const form = reactive<CreateTenantForm & { validState?: number; menuIds?: number
tenantType: 'other',
validState: 1,
menuIds: [],
adminUsername: '',
adminPassword: '',
})
//
const createdAdmin = ref<CreateTenantResult['admin'] | null>(null)
const rules = {
name: [
{ required: true, message: '请输入机构名称', trigger: 'blur' },
@ -493,7 +523,7 @@ const handleAdd = () => {
preservedExcludedMenuIds.value = []
nextTick(() => {
formRef.value?.resetFields()
Object.assign(form, { name: '', code: '', domain: '', description: '', tenantType: 'other', validState: 1, menuIds: [] })
Object.assign(form, { name: '', code: '', domain: '', description: '', tenantType: 'other', validState: 1, menuIds: [], adminUsername: '', adminPassword: '' })
})
}
@ -563,17 +593,20 @@ const handleSubmit = async () => {
message.success('保存成功')
modalVisible.value = false
} else {
const created = await tenantsApi.create({
const result = await tenantsApi.create({
name: form.name, code: form.code,
domain: form.domain || undefined,
description: form.description || undefined,
tenantType: form.tenantType,
menuIds,
adminUsername: form.adminUsername || undefined,
adminPassword: form.adminPassword || undefined,
} as CreateTenantForm)
modalVisible.value = false
// #6
// +
lastCreatedName.value = form.name
lastCreatedId.value = created.id
lastCreatedCode.value = form.code
createdAdmin.value = result.admin
guideVisible.value = true
}
@ -586,12 +619,20 @@ const handleSubmit = async () => {
}
}
// #6
const goCreateAdmin = () => {
guideVisible.value = false
//
const tenantCode = authStore.tenantCode || 'super'
router.push(`/${tenantCode}/system/users`)
//
const loginUrlForCreated = computed(() => {
return `${window.location.origin}/${lastCreatedCode.value}/login`
})
//
const copyAdminInfo = () => {
if (!createdAdmin.value) return
const info = `机构:${lastCreatedName.value}\n账号${createdAdmin.value.username}\n密码${createdAdmin.value.password}\n登录地址${loginUrlForCreated.value}`
navigator.clipboard.writeText(info).then(() => {
message.success('账号信息已复制到剪贴板')
}).catch(() => {
message.info(info)
})
}
const handleCancel = () => {
@ -641,6 +682,17 @@ $primary: #6366f1;
.form-hint { font-size: 12px; color: #9ca3af; }
.admin-info-card {
background: #f6f8fc; border-radius: 10px; padding: 16px 24px; text-align: left; margin: 0 auto; max-width: 380px;
.admin-info-row {
display: flex; justify-content: space-between; align-items: center; padding: 8px 0;
&:not(:last-child) { border-bottom: 1px solid #eef0f5; }
}
.admin-info-label { font-size: 13px; color: #6b7280; }
.admin-info-value { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.admin-info-password { color: #dc2626; font-family: monospace; letter-spacing: 1px; }
}
.menu-config {
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
}