Files
AIclinicalresearch/backend/src/modules/admin/services/userService.ts
HaHafeng 66255368b7 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
2026-01-16 13:42:10 +08:00

901 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 用户管理服务
* @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';
}