feat(admin): Add user management and upgrade to module permission system

Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
This commit is contained in:
2026-01-16 13:42:10 +08:00
parent 98d862dbd4
commit 66255368b7
560 changed files with 70424 additions and 52353 deletions

View File

@@ -0,0 +1,900 @@
/**
* 用户管理服务
* @description 提供用户 CRUD、租户隔离、模块权限管理等功能
*/
import { PrismaClient, UserRole, Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../../common/logging/index.js';
import {
ListUsersQuery,
CreateUserRequest,
UpdateUserRequest,
AssignTenantRequest,
UpdateUserModulesRequest,
UserListItem,
UserDetail,
TenantMembership,
PaginatedResponse,
UserQueryScope,
ImportUserRow,
ImportResult,
ImportError,
} from '../types/user.types.js';
const prisma = new PrismaClient();
// 默认密码
const DEFAULT_PASSWORD = '123456';
/**
* 根据用户ID获取用户的 departmentId
*/
export async function getUserDepartmentId(userId: string): Promise<string | undefined> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { department_id: true },
});
return user?.department_id || undefined;
}
/**
* 根据用户角色获取查询范围
*/
export async function getUserQueryScope(
userRole: UserRole | string,
tenantId?: string,
userId?: string
): Promise<UserQueryScope> {
switch (userRole) {
case 'SUPER_ADMIN':
case 'PROMPT_ENGINEER':
return {}; // 无限制
case 'HOSPITAL_ADMIN':
case 'PHARMA_ADMIN':
return { tenantId }; // 只能查看本租户
case 'DEPARTMENT_ADMIN': {
// 科室主任需要查询其 departmentId
const departmentId = userId ? await getUserDepartmentId(userId) : undefined;
return { tenantId, departmentId };
}
default:
throw new Error('无权限访问用户管理');
}
}
/**
* 获取用户列表(支持分页、搜索、筛选)
*/
export async function listUsers(
query: ListUsersQuery,
scope: UserQueryScope
): Promise<PaginatedResponse<UserListItem>> {
// 确保 page 和 pageSize 是数字类型HTTP 查询参数默认是字符串)
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const { search, role, tenantId, status, departmentId } = query;
const skip = (page - 1) * pageSize;
// 构建查询条件
const where: Prisma.UserWhereInput = {};
// 数据隔离:根据 scope 限制查询范围
if (scope.tenantId) {
where.tenant_id = scope.tenantId;
}
if (scope.departmentId) {
where.department_id = scope.departmentId;
}
// 搜索条件
if (search) {
where.OR = [
{ phone: { contains: search } },
{ name: { contains: search } },
{ email: { contains: search } },
];
}
// 筛选条件
if (role) {
where.role = role;
}
if (tenantId && !scope.tenantId) {
// 只有无限制 scope 才能按租户筛选
where.tenant_id = tenantId;
}
if (status) {
where.status = status;
}
if (departmentId && !scope.departmentId) {
where.department_id = departmentId;
}
// 查询总数
const total = await prisma.user.count({ where });
// 查询列表
const users = await prisma.user.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
tenants: {
select: {
id: true,
code: true,
name: true,
type: true,
},
},
departments: {
select: {
id: true,
name: true,
},
},
tenant_members: {
select: {
id: true,
},
},
},
});
// 转换为列表项
const data: UserListItem[] = users.map((user) => ({
id: user.id,
phone: user.phone,
name: user.name,
email: user.email,
role: user.role,
status: user.status,
isDefaultPassword: user.is_default_password,
defaultTenant: {
id: user.tenants.id,
code: user.tenants.code,
name: user.tenants.name,
type: user.tenants.type,
},
department: user.departments
? {
id: user.departments.id,
name: user.departments.name,
}
: null,
tenantCount: user.tenant_members.length,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
}));
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* 获取用户详情
*/
export async function getUserById(userId: string, scope: UserQueryScope): Promise<UserDetail | null> {
const where: Prisma.UserWhereInput = { id: userId };
// 数据隔离
if (scope.tenantId) {
where.tenant_id = scope.tenantId;
}
const user = await prisma.user.findFirst({
where,
include: {
tenants: {
select: {
id: true,
code: true,
name: true,
type: true,
},
},
departments: {
select: {
id: true,
name: true,
},
},
tenant_members: {
include: {
tenants: {
select: {
id: true,
code: true,
name: true,
type: true,
},
},
},
},
user_modules: {
include: {
tenant: {
select: {
id: true,
code: true,
},
},
},
},
},
});
if (!user) {
return null;
}
// 获取每个租户的模块权限
const tenantMemberships: TenantMembership[] = await Promise.all(
user.tenant_members.map(async (tm) => {
// 获取租户订阅的模块
const tenantModules = await prisma.tenant_modules.findMany({
where: { tenant_id: tm.tenants.id, is_enabled: true },
});
// 获取用户在该租户的模块权限
const userModulesInTenant = user.user_modules.filter(
(um) => um.tenant_id === tm.tenants.id
);
// 计算最终模块权限
const allowedModules = tenantModules.map((tm) => {
const userModule = userModulesInTenant.find((um) => um.module_code === tm.module_code);
return {
code: tm.module_code,
name: getModuleName(tm.module_code),
isEnabled: userModule ? userModule.is_enabled : true, // 默认继承租户权限
};
});
return {
tenantId: tm.tenants.id,
tenantCode: tm.tenants.code,
tenantName: tm.tenants.name,
tenantType: tm.tenants.type,
role: tm.role,
joinedAt: tm.joined_at,
allowedModules,
};
})
);
// 获取用户权限(基于角色)
const rolePermissions = await prisma.role_permissions.findMany({
where: { role: user.role },
include: { permissions: true },
});
const permissions = rolePermissions.map((rp) => rp.permissions.code);
return {
id: user.id,
phone: user.phone,
name: user.name,
email: user.email,
role: user.role,
status: user.status,
isDefaultPassword: user.is_default_password,
defaultTenant: {
id: user.tenants.id,
code: user.tenants.code,
name: user.tenants.name,
type: user.tenants.type,
},
department: user.departments
? {
id: user.departments.id,
name: user.departments.name,
}
: null,
tenantCount: user.tenant_members.length,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
tenantMemberships,
permissions,
};
}
/**
* 创建用户
*/
export async function createUser(data: CreateUserRequest, creatorId: string): Promise<UserDetail> {
// 检查手机号是否已存在
const existingUser = await prisma.user.findUnique({
where: { phone: data.phone },
});
if (existingUser) {
throw new Error('手机号已存在');
}
// 检查邮箱是否已存在
if (data.email) {
const existingEmail = await prisma.user.findUnique({
where: { email: data.email },
});
if (existingEmail) {
throw new Error('邮箱已存在');
}
}
// 检查租户是否存在
const tenant = await prisma.tenants.findUnique({
where: { id: data.tenantId },
});
if (!tenant) {
throw new Error('租户不存在');
}
// 检查科室是否存在(如果提供)
if (data.departmentId) {
const department = await prisma.departments.findFirst({
where: { id: data.departmentId, tenant_id: data.tenantId },
});
if (!department) {
throw new Error('科室不存在或不属于该租户');
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(DEFAULT_PASSWORD, 10);
// 创建用户和租户成员关系
const userId = uuidv4();
const tenantMemberId = uuidv4();
const user = await prisma.$transaction(async (tx) => {
// 创建用户
const newUser = await tx.user.create({
data: {
id: userId,
phone: data.phone,
name: data.name,
email: data.email,
password: hashedPassword,
role: data.role,
tenant_id: data.tenantId,
department_id: data.departmentId,
is_default_password: true,
status: 'active',
},
});
// 创建租户成员关系
await tx.tenant_members.create({
data: {
id: tenantMemberId,
tenant_id: data.tenantId,
user_id: userId,
role: data.tenantRole || data.role,
},
});
// 如果指定了模块权限,创建用户模块记录
if (data.allowedModules && data.allowedModules.length > 0) {
await tx.user_modules.createMany({
data: data.allowedModules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
module_code: moduleCode,
is_enabled: true,
})),
});
}
return newUser;
});
logger.info('[UserService] User created', {
userId: user.id,
phone: user.phone,
createdBy: creatorId,
});
// 返回用户详情
return (await getUserById(user.id, {}))!;
}
/**
* 更新用户
*/
export async function updateUser(
userId: string,
data: UpdateUserRequest,
scope: UserQueryScope,
updaterId: string
): Promise<UserDetail> {
// 检查用户是否存在且在权限范围内
const existingUser = await prisma.user.findFirst({
where: {
id: userId,
...(scope.tenantId ? { tenant_id: scope.tenantId } : {}),
},
});
if (!existingUser) {
throw new Error('用户不存在或无权限操作');
}
// 检查邮箱唯一性
if (data.email && data.email !== existingUser.email) {
const existingEmail = await prisma.user.findUnique({
where: { email: data.email },
});
if (existingEmail) {
throw new Error('邮箱已存在');
}
}
// 更新用户
await prisma.user.update({
where: { id: userId },
data: {
name: data.name,
email: data.email,
role: data.role,
department_id: data.departmentId,
status: data.status,
},
});
logger.info('[UserService] User updated', {
userId,
updatedFields: Object.keys(data),
updatedBy: updaterId,
});
return (await getUserById(userId, scope))!;
}
/**
* 更新用户状态(启用/禁用)
*/
export async function updateUserStatus(
userId: string,
status: 'active' | 'disabled',
scope: UserQueryScope,
updaterId: string
): Promise<void> {
const existingUser = await prisma.user.findFirst({
where: {
id: userId,
...(scope.tenantId ? { tenant_id: scope.tenantId } : {}),
},
});
if (!existingUser) {
throw new Error('用户不存在或无权限操作');
}
await prisma.user.update({
where: { id: userId },
data: { status },
});
logger.info('[UserService] User status updated', {
userId,
status,
updatedBy: updaterId,
});
}
/**
* 重置用户密码
*/
export async function resetUserPassword(
userId: string,
scope: UserQueryScope,
resetterId: string
): Promise<void> {
const existingUser = await prisma.user.findFirst({
where: {
id: userId,
...(scope.tenantId ? { tenant_id: scope.tenantId } : {}),
},
});
if (!existingUser) {
throw new Error('用户不存在或无权限操作');
}
const hashedPassword = await bcrypt.hash(DEFAULT_PASSWORD, 10);
await prisma.user.update({
where: { id: userId },
data: {
password: hashedPassword,
is_default_password: true,
password_changed_at: null,
},
});
logger.info('[UserService] User password reset', {
userId,
resetBy: resetterId,
});
}
/**
* 分配租户给用户
*/
export async function assignTenantToUser(
userId: string,
data: AssignTenantRequest,
assignerId: string
): Promise<void> {
// 检查用户是否存在
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('用户不存在');
}
// 检查租户是否存在
const tenant = await prisma.tenants.findUnique({ where: { id: data.tenantId } });
if (!tenant) {
throw new Error('租户不存在');
}
// 检查是否已是该租户成员
const existingMember = await prisma.tenant_members.findUnique({
where: {
tenant_id_user_id: {
tenant_id: data.tenantId,
user_id: userId,
},
},
});
if (existingMember) {
throw new Error('用户已是该租户成员');
}
// 创建租户成员关系
await prisma.$transaction(async (tx) => {
await tx.tenant_members.create({
data: {
id: uuidv4(),
tenant_id: data.tenantId,
user_id: userId,
role: data.role,
},
});
// 如果指定了模块权限,创建用户模块记录
if (data.allowedModules && data.allowedModules.length > 0) {
await tx.user_modules.createMany({
data: data.allowedModules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
module_code: moduleCode,
is_enabled: true,
})),
});
}
});
logger.info('[UserService] Tenant assigned to user', {
userId,
tenantId: data.tenantId,
assignedBy: assignerId,
});
}
/**
* 从租户移除用户
*/
export async function removeTenantFromUser(
userId: string,
tenantId: string,
removerId: string
): Promise<void> {
// 检查用户是否存在
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('用户不存在');
}
// 不能移除默认租户
if (user.tenant_id === tenantId) {
throw new Error('不能移除用户的默认租户');
}
// 检查租户成员关系是否存在
const membership = await prisma.tenant_members.findUnique({
where: {
tenant_id_user_id: {
tenant_id: tenantId,
user_id: userId,
},
},
});
if (!membership) {
throw new Error('用户不是该租户的成员');
}
// 删除租户成员关系和模块权限
await prisma.$transaction(async (tx) => {
await tx.tenant_members.delete({
where: { id: membership.id },
});
await tx.user_modules.deleteMany({
where: {
user_id: userId,
tenant_id: tenantId,
},
});
});
logger.info('[UserService] Tenant removed from user', {
userId,
tenantId,
removedBy: removerId,
});
}
/**
* 更新用户在指定租户的模块权限
*/
export async function updateUserModules(
userId: string,
data: UpdateUserModulesRequest,
updaterId: string
): Promise<void> {
// 检查用户是否是该租户成员
const membership = await prisma.tenant_members.findUnique({
where: {
tenant_id_user_id: {
tenant_id: data.tenantId,
user_id: userId,
},
},
});
if (!membership) {
throw new Error('用户不是该租户的成员');
}
// 获取租户订阅的模块
const tenantModules = await prisma.tenant_modules.findMany({
where: { tenant_id: data.tenantId, is_enabled: true },
});
const tenantModuleCodes = tenantModules.map((tm) => tm.module_code);
// 验证请求的模块是否在租户订阅范围内
const invalidModules = data.modules.filter((m) => !tenantModuleCodes.includes(m));
if (invalidModules.length > 0) {
throw new Error(`以下模块不在租户订阅范围内: ${invalidModules.join(', ')}`);
}
// 更新用户模块权限
await prisma.$transaction(async (tx) => {
// 删除旧的模块权限
await tx.user_modules.deleteMany({
where: {
user_id: userId,
tenant_id: data.tenantId,
},
});
// 创建新的模块权限
if (data.modules.length > 0) {
await tx.user_modules.createMany({
data: data.modules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
module_code: moduleCode,
is_enabled: true,
})),
});
}
});
logger.info('[UserService] User modules updated', {
userId,
tenantId: data.tenantId,
modules: data.modules,
updatedBy: updaterId,
});
}
/**
* 批量导入用户
*/
export async function importUsers(
rows: ImportUserRow[],
defaultTenantId: string,
importerId: string
): Promise<ImportResult> {
const result: ImportResult = {
success: 0,
failed: 0,
errors: [],
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2; // Excel行号跳过表头
try {
// 验证手机号
if (!row.phone || !/^1[3-9]\d{9}$/.test(row.phone)) {
throw new Error('手机号格式不正确');
}
// 验证姓名
if (!row.name || row.name.trim().length === 0) {
throw new Error('姓名不能为空');
}
// 解析角色
const role = parseRole(row.role);
// 解析租户
let tenantId = defaultTenantId;
if (row.tenantCode) {
const tenant = await prisma.tenants.findUnique({
where: { code: row.tenantCode },
});
if (!tenant) {
throw new Error(`租户代码 ${row.tenantCode} 不存在`);
}
tenantId = tenant.id;
}
// 解析科室
let departmentId: string | undefined;
if (row.departmentName) {
const department = await prisma.departments.findFirst({
where: {
name: row.departmentName,
tenant_id: tenantId,
},
});
if (!department) {
throw new Error(`科室 ${row.departmentName} 不存在`);
}
departmentId = department.id;
}
// 解析模块
const modules = row.modules
? row.modules.split(',').map((m) => m.trim().toUpperCase())
: undefined;
// 创建用户
await createUser(
{
phone: row.phone,
name: row.name.trim(),
email: row.email,
role,
tenantId,
departmentId,
allowedModules: modules,
},
importerId
);
result.success++;
} catch (error: any) {
result.failed++;
result.errors.push({
row: rowNumber,
phone: row.phone || '',
error: error.message,
});
}
}
logger.info('[UserService] Batch import completed', {
success: result.success,
failed: result.failed,
importedBy: importerId,
});
return result;
}
/**
* 获取所有租户列表(用于下拉选择)
*/
export async function getAllTenants() {
return prisma.tenants.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
code: true,
name: true,
type: true,
},
orderBy: { name: 'asc' },
});
}
/**
* 获取租户的科室列表(用于下拉选择)
*/
export async function getDepartmentsByTenant(tenantId: string) {
return prisma.departments.findMany({
where: { tenant_id: tenantId },
select: {
id: true,
name: true,
parent_id: true,
},
orderBy: { name: 'asc' },
});
}
/**
* 获取租户的模块列表(用于模块配置)
*/
export async function getModulesByTenant(tenantId: string) {
const tenantModules = await prisma.tenant_modules.findMany({
where: { tenant_id: tenantId, is_enabled: true },
});
const allModules = await prisma.modules.findMany({
where: { is_active: true },
orderBy: { sort_order: 'asc' },
});
return allModules.map((m) => ({
code: m.code,
name: m.name,
isSubscribed: tenantModules.some((tm) => tm.module_code === m.code),
}));
}
// ============ 辅助函数 ============
function getModuleName(code: string): string {
const moduleNames: Record<string, string> = {
AIA: 'AI智能问答',
PKB: '个人知识库',
ASL: 'AI智能文献',
DC: '数据清洗整理',
IIT: 'IIT Manager',
RVW: '稿件审查',
SSA: '智能统计分析',
ST: '统计分析工具',
};
return moduleNames[code] || code;
}
function parseRole(roleStr?: string): UserRole {
if (!roleStr) return 'USER';
const roleMap: Record<string, UserRole> = {
: 'SUPER_ADMIN',
SUPER_ADMIN: 'SUPER_ADMIN',
PROMPT工程师: 'PROMPT_ENGINEER',
PROMPT_ENGINEER: 'PROMPT_ENGINEER',
: 'HOSPITAL_ADMIN',
HOSPITAL_ADMIN: 'HOSPITAL_ADMIN',
: 'PHARMA_ADMIN',
PHARMA_ADMIN: 'PHARMA_ADMIN',
: 'DEPARTMENT_ADMIN',
DEPARTMENT_ADMIN: 'DEPARTMENT_ADMIN',
: 'USER',
USER: 'USER',
};
return roleMap[roleStr.trim()] || 'USER';
}