445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
import { Request, Response } from 'express'
|
||
import bcrypt from 'bcryptjs'
|
||
import type { Prisma } from '@prisma/client'
|
||
import prisma from '../utils/prisma'
|
||
import { successResponse, errorResponse } from '../utils/response'
|
||
import { getPermissionsForRole, isUserRole } from '../utils/permissions'
|
||
|
||
const SALT_ROUNDS = 10
|
||
const USER_STATUSES = ['active', 'inactive'] as const
|
||
|
||
class UserValidationError extends Error {
|
||
constructor(message: string, public readonly status = 422) {
|
||
super(message)
|
||
this.name = 'UserValidationError'
|
||
}
|
||
}
|
||
|
||
const sanitizeUser = (user: any) => {
|
||
const person = user.person
|
||
? {
|
||
id: user.person.id,
|
||
real_name: user.person.real_name,
|
||
nickname: user.person.nickname,
|
||
status: user.person.status,
|
||
source_employee_status: user.person.source_employee_status
|
||
}
|
||
: null
|
||
return {
|
||
id: user.id,
|
||
username: user.username,
|
||
name: user.name,
|
||
email: user.email,
|
||
phone: user.phone,
|
||
role: user.role,
|
||
roles: [user.role],
|
||
permissions: getPermissionsForRole(user.role),
|
||
status: user.status,
|
||
person_id: user.person_id,
|
||
person_bound: Boolean(user.person_id),
|
||
dingtalk_ready: Boolean(
|
||
user.status === 'active' &&
|
||
user.person?.status === 'active' &&
|
||
user.person?.source_userid?.trim()
|
||
),
|
||
person,
|
||
last_login_at: user.last_login_at,
|
||
created_at: user.created_at,
|
||
updated_at: user.updated_at
|
||
}
|
||
}
|
||
|
||
const personSelect = {
|
||
id: true,
|
||
real_name: true,
|
||
nickname: true,
|
||
status: true,
|
||
source_employee_status: true,
|
||
source_userid: true
|
||
} as const
|
||
|
||
const normalizePersonId = (value: unknown) => {
|
||
if (value === undefined) return undefined
|
||
if (value === null || value === '') return null
|
||
const personId = Number(value)
|
||
if (!Number.isInteger(personId) || personId <= 0) {
|
||
throw new UserValidationError('员工绑定无效')
|
||
}
|
||
return personId
|
||
}
|
||
|
||
const normalizeUserStatus = (value: unknown, fallback?: string) => {
|
||
const status = value === undefined || value === null || value === ''
|
||
? fallback
|
||
: String(value).trim()
|
||
if (!status || !USER_STATUSES.includes(status as (typeof USER_STATUSES)[number])) {
|
||
throw new UserValidationError('状态必须是 active 或 inactive')
|
||
}
|
||
return status
|
||
}
|
||
|
||
const getCurrentUserId = (res: Response) => {
|
||
const userId = Number((res as any).locals?.user?.id)
|
||
return Number.isInteger(userId) && userId > 0 ? userId : null
|
||
}
|
||
|
||
const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => {
|
||
return tx.$queryRawUnsafe<Array<{ id: number }>>(
|
||
"SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE"
|
||
)
|
||
}
|
||
|
||
const assertActiveAdministratorRemains = async (
|
||
tx: Prisma.TransactionClient,
|
||
targetUserId: number
|
||
) => {
|
||
const activeAdministrators = await lockActiveAdministrators(tx)
|
||
const targetIsActiveAdministrator = activeAdministrators.some((item) => Number(item.id) === targetUserId)
|
||
if (targetIsActiveAdministrator && activeAdministrators.length <= 1) {
|
||
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
|
||
}
|
||
}
|
||
|
||
const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => {
|
||
const person = await prisma.person.findUnique({ where: { id: personId } })
|
||
if (!person) {
|
||
throw new UserValidationError('绑定的员工档案不存在')
|
||
}
|
||
if (person.status !== 'active') {
|
||
throw new UserValidationError('只能绑定在职且启用的员工档案')
|
||
}
|
||
if (!person.source_userid?.trim()) {
|
||
throw new UserValidationError('该员工档案缺少钉钉 userId,暂时不能绑定')
|
||
}
|
||
|
||
const occupied = await prisma.user.findFirst({
|
||
where: {
|
||
person_id: personId,
|
||
...(excludeUserId ? { id: { not: excludeUserId } } : {})
|
||
},
|
||
select: { id: true }
|
||
})
|
||
if (occupied) {
|
||
throw new UserValidationError('该员工档案已绑定其他用户', 409)
|
||
}
|
||
}
|
||
|
||
const sendUserWriteError = (res: Response, error: any, fallbackMessage: string) => {
|
||
if (error?.code === 'P2002') {
|
||
const targets = Array.isArray(error?.meta?.target)
|
||
? error.meta.target.map(String)
|
||
: [String(error?.meta?.target || '')]
|
||
const message = targets.some((target: string) => target.includes('person_id'))
|
||
? '该员工档案已绑定其他用户'
|
||
: targets.some((target: string) => target.includes('username'))
|
||
? '用户名已存在'
|
||
: '用户资料与现有记录冲突'
|
||
return errorResponse(res, message, 409)
|
||
}
|
||
return errorResponse(res, error.message || fallbackMessage, error.status || 500)
|
||
}
|
||
|
||
export const getUserList = async (req: Request, res: Response) => {
|
||
try {
|
||
const { page = 1, pageSize = 10, keyword, role, status } = req.query
|
||
|
||
const where: any = {}
|
||
|
||
if (keyword) {
|
||
where.OR = [
|
||
{ username: { contains: keyword as string } },
|
||
{ name: { contains: keyword as string } },
|
||
{ email: { contains: keyword as string } }
|
||
]
|
||
}
|
||
|
||
if (role) {
|
||
where.role = role
|
||
}
|
||
|
||
if (status) {
|
||
where.status = status
|
||
}
|
||
|
||
const skip = (Number(page) - 1) * Number(pageSize)
|
||
|
||
const [list, total] = await Promise.all([
|
||
prisma.user.findMany({
|
||
where,
|
||
skip,
|
||
take: Number(pageSize),
|
||
orderBy: { created_at: 'desc' },
|
||
include: { person: { select: personSelect } }
|
||
}),
|
||
prisma.user.count({ where })
|
||
])
|
||
|
||
successResponse(
|
||
res,
|
||
{
|
||
list: list.map(sanitizeUser),
|
||
total,
|
||
page: Number(page),
|
||
pageSize: Number(pageSize)
|
||
},
|
||
'获取成功'
|
||
)
|
||
} catch (error: any) {
|
||
errorResponse(res, error.message || '获取用户列表失败')
|
||
}
|
||
}
|
||
|
||
export const createUser = async (req: Request, res: Response) => {
|
||
try {
|
||
const { username, password, name, email, phone, roles, role, status, person_id } = req.body as Record<
|
||
string,
|
||
any
|
||
>
|
||
|
||
if (!username || !password || !name) {
|
||
return errorResponse(res, '用户名、姓名和密码为必填项', 422)
|
||
}
|
||
|
||
const existing = await prisma.user.findUnique({ where: { username } })
|
||
if (existing) {
|
||
return errorResponse(res, '用户名已存在', 409)
|
||
}
|
||
|
||
const primaryRole =
|
||
(Array.isArray(roles) ? roles[0] : roles) || role || (username === 'admin' ? 'admin' : 'user')
|
||
if (!isUserRole(primaryRole)) {
|
||
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
|
||
}
|
||
const normalizedStatus = normalizeUserStatus(status, 'active')
|
||
|
||
const personId = normalizePersonId(person_id)
|
||
if (typeof personId === 'number') {
|
||
await ensurePersonCanBind(personId)
|
||
}
|
||
|
||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
|
||
|
||
const created = await prisma.user.create({
|
||
data: {
|
||
username,
|
||
password: hashedPassword,
|
||
name,
|
||
email,
|
||
phone,
|
||
role: primaryRole,
|
||
status: normalizedStatus,
|
||
person_id: personId ?? null
|
||
},
|
||
include: { person: { select: personSelect } }
|
||
})
|
||
|
||
successResponse(res, sanitizeUser(created), '创建成功')
|
||
} catch (error: any) {
|
||
sendUserWriteError(res, error, '创建用户失败')
|
||
}
|
||
}
|
||
|
||
export const updateUser = async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params
|
||
const { name, email, phone, roles, role, status, person_id } = req.body as Record<string, any>
|
||
|
||
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||
if (!user) {
|
||
return errorResponse(res, '用户不存在', 404)
|
||
}
|
||
|
||
const updateData: any = {
|
||
name: name ?? user.name,
|
||
email: email ?? user.email,
|
||
phone: phone ?? user.phone
|
||
}
|
||
|
||
const newRole = (Array.isArray(roles) ? roles[0] : roles) || role
|
||
if (newRole) {
|
||
if (!isUserRole(newRole)) {
|
||
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
|
||
}
|
||
updateData.role = newRole
|
||
}
|
||
|
||
if (status !== undefined) {
|
||
updateData.status = normalizeUserStatus(status)
|
||
}
|
||
|
||
const currentUserId = getCurrentUserId(res)
|
||
if (currentUserId === user.id) {
|
||
if (newRole && newRole !== 'admin') {
|
||
return errorResponse(res, '不能降低当前登录管理员自己的角色', 422)
|
||
}
|
||
if (updateData.status && updateData.status !== 'active') {
|
||
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
|
||
}
|
||
}
|
||
|
||
const personId = normalizePersonId(person_id)
|
||
if (typeof personId === 'number') {
|
||
await ensurePersonCanBind(personId, user.id)
|
||
updateData.person_id = personId
|
||
} else if (personId === null) {
|
||
updateData.person_id = null
|
||
}
|
||
|
||
const changesRoleOrStatus = Boolean(updateData.role || updateData.status)
|
||
|
||
const updated = changesRoleOrStatus
|
||
? await prisma.$transaction(async (tx) => {
|
||
const activeAdministrators = await lockActiveAdministrators(tx)
|
||
const current = await tx.user.findUnique({ where: { id: user.id } })
|
||
if (!current) {
|
||
throw new UserValidationError('用户不存在', 404)
|
||
}
|
||
const finalRole = updateData.role || current.role
|
||
const finalStatus = updateData.status || current.status
|
||
const removesActiveAdministrator = activeAdministrators.some(
|
||
(item) => Number(item.id) === current.id
|
||
) && (finalRole !== 'admin' || finalStatus !== 'active')
|
||
if (removesActiveAdministrator && activeAdministrators.length <= 1) {
|
||
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
|
||
}
|
||
return tx.user.update({
|
||
where: { id: current.id },
|
||
data: updateData,
|
||
include: { person: { select: personSelect } }
|
||
})
|
||
})
|
||
: await prisma.user.update({
|
||
where: { id: user.id },
|
||
data: updateData,
|
||
include: { person: { select: personSelect } }
|
||
})
|
||
|
||
successResponse(res, sanitizeUser(updated), '更新成功')
|
||
} catch (error: any) {
|
||
sendUserWriteError(res, error, '更新用户失败')
|
||
}
|
||
}
|
||
|
||
export const resetPassword = async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params
|
||
const { new_password } = req.body as { new_password?: string }
|
||
|
||
if (!new_password) {
|
||
return errorResponse(res, '新密码不能为空', 422)
|
||
}
|
||
|
||
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||
if (!user) {
|
||
return errorResponse(res, '用户不存在', 404)
|
||
}
|
||
|
||
const hashedPassword = await bcrypt.hash(new_password, SALT_ROUNDS)
|
||
|
||
await prisma.user.update({
|
||
where: { id: Number(id) },
|
||
data: { password: hashedPassword }
|
||
})
|
||
|
||
successResponse(res, null, '密码重置成功')
|
||
} catch (error: any) {
|
||
errorResponse(res, error.message || '重置密码失败')
|
||
}
|
||
}
|
||
|
||
export const updateUserStatus = async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params
|
||
const { status } = req.body as { status?: string }
|
||
|
||
const normalizedStatus = normalizeUserStatus(status)
|
||
|
||
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||
if (!user) {
|
||
return errorResponse(res, '用户不存在', 404)
|
||
}
|
||
|
||
if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') {
|
||
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
|
||
}
|
||
|
||
const updated = await prisma.$transaction(async (tx) => {
|
||
if (normalizedStatus !== 'active') {
|
||
await assertActiveAdministratorRemains(tx, user.id)
|
||
} else {
|
||
await lockActiveAdministrators(tx)
|
||
}
|
||
return tx.user.update({
|
||
where: { id: user.id },
|
||
data: { status: normalizedStatus },
|
||
include: { person: { select: personSelect } }
|
||
})
|
||
})
|
||
|
||
successResponse(res, sanitizeUser(updated), '状态更新成功')
|
||
} catch (error: any) {
|
||
errorResponse(res, error.message || '更新用户状态失败', error.status || 500)
|
||
}
|
||
}
|
||
|
||
|
||
export const deleteUser = async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params
|
||
const userId = Number(id)
|
||
|
||
if (getCurrentUserId(res) === userId) {
|
||
return errorResponse(res, '不能删除当前登录账户', 422)
|
||
}
|
||
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
include: {
|
||
_count: {
|
||
select: {
|
||
site_configs: true,
|
||
mount_histories: true,
|
||
disposal_applied: true,
|
||
disposal_approved: true,
|
||
disposal_completed: true,
|
||
disposal_actions: true,
|
||
maintenance_records: true,
|
||
asset_transfers_operated: true,
|
||
dingtalk_deliveries: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
if (!user) {
|
||
return errorResponse(res, '用户不存在', 404)
|
||
}
|
||
|
||
if (
|
||
user._count.site_configs > 0 ||
|
||
user._count.mount_histories > 0 ||
|
||
user._count.disposal_applied > 0 ||
|
||
user._count.disposal_approved > 0 ||
|
||
user._count.disposal_completed > 0 ||
|
||
user._count.disposal_actions > 0 ||
|
||
user._count.maintenance_records > 0
|
||
|| user._count.asset_transfers_operated > 0
|
||
|| user._count.dingtalk_deliveries > 0
|
||
) {
|
||
return errorResponse(res, '该用户已存在业务历史记录,无法删除')
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await assertActiveAdministratorRemains(tx, userId)
|
||
await tx.operationLog.deleteMany({
|
||
where: { user_id: userId }
|
||
})
|
||
|
||
await tx.user.delete({ where: { id: userId } })
|
||
})
|
||
|
||
successResponse(res, null, '删除成功')
|
||
} catch (error: any) {
|
||
errorResponse(res, error.message || '删除用户失败', error.status || 500)
|
||
}
|
||
}
|