fix: 全面优化安全、性能和代码质量
P0: 环境变量校验/事务保护/RBAC权限 P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化 P2: 导出限制/自定义错误类/body大小限制/代码去重 P3: 自定义确认弹窗替换window.confirm
This commit is contained in:
@@ -81,6 +81,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
|
||||
router.post('/reset-password', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = resetPasswordSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const result = await resetPassword(data.phone, data.newPassword)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
|
||||
@@ -111,22 +111,30 @@ router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest,
|
||||
}
|
||||
|
||||
// 合规检查:按员工分组,检查历史固定期合同次数
|
||||
const employeeIds = contracts.map((c) => c.employeeId)
|
||||
const allFixedContracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
employeeId: { in: employeeIds },
|
||||
orgId: req.user!.orgId,
|
||||
contractType: 'FIXED',
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
})
|
||||
const fixedContractsByEmp = new Map<string, typeof allFixedContracts>()
|
||||
for (const c of allFixedContracts) {
|
||||
if (!fixedContractsByEmp.has(c.employeeId)) {
|
||||
fixedContractsByEmp.set(c.employeeId, [])
|
||||
}
|
||||
fixedContractsByEmp.get(c.employeeId)!.push(c)
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const contract of contracts) {
|
||||
const employee = contract.employee
|
||||
|
||||
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
|
||||
const allFixedContracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
employeeId: contract.employeeId,
|
||||
orgId: req.user!.orgId,
|
||||
contractType: 'FIXED',
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
})
|
||||
const empFixedContracts = fixedContractsByEmp.get(contract.employeeId) || []
|
||||
|
||||
// 当前合同是第几次固定期(从1开始计数)
|
||||
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
|
||||
const currentIndex = empFixedContracts.findIndex((c) => c.id === contract.id)
|
||||
const renewalCount = currentIndex + 1
|
||||
|
||||
// 判断是否应签无固定期限:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Response } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import ExcelJS from 'exceljs'
|
||||
@@ -21,22 +22,23 @@ function maskBankAccount(account: string | null): string | null {
|
||||
}
|
||||
|
||||
// 导出全部数据(支持模块选择、格式选择、脱敏)
|
||||
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const format = (req.query.format as string) || 'json'
|
||||
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
|
||||
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
|
||||
|
||||
const fetchMap: Record<string, () => Promise<any>> = {
|
||||
employees: () => prisma.employee.findMany({ where: { orgId } }),
|
||||
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
|
||||
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
|
||||
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
|
||||
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
|
||||
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
|
||||
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
|
||||
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
|
||||
const exportBatchSize = 500
|
||||
const fetchMap: Record<string, () => Promise<any[]>> = {
|
||||
employees: () => prisma.employee.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
contracts: () => prisma.laborContract.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
terminations: () => prisma.terminationRecord.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
payslips: () => prisma.payslip.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
riskItems: () => prisma.riskItem.findMany({ where: { orgId }, take: exportBatchSize }),
|
||||
}
|
||||
|
||||
const useGzip = req.query.gzip !== 'false'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
@@ -85,7 +86,7 @@ function num(v: any): number {
|
||||
|
||||
// ========== 导入预览(不写入数据库) ==========
|
||||
|
||||
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
|
||||
@@ -209,7 +210,7 @@ router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Re
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
const orgId = req.user!.orgId
|
||||
@@ -420,7 +421,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
|
||||
|
||||
// ========== 月度导入 ==========
|
||||
|
||||
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
@@ -40,13 +40,31 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
]
|
||||
}
|
||||
|
||||
const [total, employees] = await Promise.all([
|
||||
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
||||
if (status === 'RESIGNED') {
|
||||
whereBase.status = 'RESIGNED'
|
||||
} else if (status === 'PRE_HIRE') {
|
||||
whereBase.status = 'ACTIVE'
|
||||
whereBase.hireDate = { gt: today }
|
||||
} else if (status === 'ACTIVE') {
|
||||
whereBase.status = 'ACTIVE'
|
||||
whereBase.hireDate = { lte: today }
|
||||
}
|
||||
|
||||
// unsigned 合同状态可以在 DB 层过滤
|
||||
if (contractStatus === 'unsigned') {
|
||||
whereBase.contracts = { none: {} }
|
||||
}
|
||||
|
||||
// 当有 contractStatus(非 unsigned)筛选时,需要先查全部再过滤后分页
|
||||
const needPostFilter = !!contractStatus && contractStatus !== 'unsigned'
|
||||
|
||||
const [dbTotal, employees] = await Promise.all([
|
||||
prisma.employee.count({ where: whereBase }),
|
||||
prisma.employee.findMany({
|
||||
where: whereBase,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
...(needPostFilter ? {} : { skip, take: pageSize }),
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
|
||||
@@ -107,22 +125,25 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where)
|
||||
if (status) {
|
||||
result = result.filter((e) => e.status === status)
|
||||
}
|
||||
if (contractStatus) {
|
||||
// 前端过滤:合同状态(非 unsigned 的需要后处理计算)
|
||||
if (contractStatus && contractStatus !== 'unsigned') {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
// 计算过滤后的总数和分页
|
||||
const filteredTotal = needPostFilter ? result.length : dbTotal
|
||||
if (needPostFilter) {
|
||||
result = result.slice(skip, skip + pageSize)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
total: filteredTotal,
|
||||
totalPages: Math.ceil(filteredTotal / pageSize),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
@@ -35,7 +36,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
// 更新企业信息
|
||||
router.put('/org', async (req: AuthRequest, res, next) => {
|
||||
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
|
||||
const updateData: any = {}
|
||||
@@ -67,7 +68,7 @@ router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
// 添加用户
|
||||
router.post('/users', async (req: AuthRequest, res, next) => {
|
||||
router.post('/users', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createUserSchema.parse(req.body)
|
||||
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
|
||||
@@ -92,7 +93,7 @@ router.post('/users', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
// 更新用户
|
||||
router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
router.put('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateUserSchema.parse(req.body)
|
||||
const user = await prisma.user.update({
|
||||
@@ -107,7 +108,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
// 删除用户
|
||||
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
router.delete('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
|
||||
@@ -120,7 +121,7 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
// 禁用/启用用户
|
||||
router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => {
|
||||
router.patch('/users/:id/toggle-disable', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } })
|
||||
@@ -141,7 +142,7 @@ router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) =>
|
||||
})
|
||||
|
||||
// 切换套餐
|
||||
router.put('/plan', async (req: AuthRequest, res, next) => {
|
||||
router.put('/plan', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
|
||||
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
|
||||
|
||||
Reference in New Issue
Block a user