fix: 全面优化安全、性能和代码质量

P0: 环境变量校验/事务保护/RBAC权限
P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化
P2: 导出限制/自定义错误类/body大小限制/代码去重
P3: 自定义确认弹窗替换window.confirm
This commit is contained in:
freedakgmail
2026-07-24 22:28:58 +08:00
parent 9ec21fedea
commit 5c12f28ac7
23 changed files with 634 additions and 366 deletions
+5
View File
@@ -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) {
+19 -11
View File
@@ -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
// 判断是否应签无固定期限:
+12 -10
View File
@@ -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'
+4 -3
View File
@@ -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
+31 -10
View File
@@ -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) {
+7 -6
View File
@@ -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)) {