feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload || payload.role !== 'EMPLOYEE') {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
|
||||
}
|
||||
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
|
||||
}
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalLoginSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码(页面内显示)
|
||||
router.post('/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalSendCodeSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
// 频率限制:60秒内不可重复发送
|
||||
const existing = codeStore.get(data.phone)
|
||||
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
|
||||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证码登录
|
||||
router.post('/verify-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalVerifyCodeSchema.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: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
// 错误次数限制:5次后锁定
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(data.phone)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条
|
||||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条历史(最近6个月)
|
||||
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { month: 'desc' },
|
||||
take: 6,
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条确认已阅
|
||||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||||
}
|
||||
await prisma.payslip.update({
|
||||
where: { id: req.params.id },
|
||||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
// 通知 HR
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.employee.orgId,
|
||||
title: '工资条确认通知',
|
||||
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip})`,
|
||||
type: 'PAYSLIP_CONFIRM',
|
||||
channel: 'IN_APP',
|
||||
},
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 我的合同
|
||||
router.get('/contract', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: contract })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职填报提交
|
||||
router.post('/onboarding', async (req, res, next) => {
|
||||
try {
|
||||
const data = onboardingSchema.parse(req.body)
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
employeeName: data.name,
|
||||
phone: data.phone,
|
||||
formData: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
idCard: data.idCard,
|
||||
emergencyContact: data.emergencyContact,
|
||||
emergencyPhone: data.emergencyPhone,
|
||||
address: data.address,
|
||||
bankCard: data.bankCard,
|
||||
bankName: data.bankName,
|
||||
},
|
||||
status: 'APPROVED',
|
||||
usedAt: new Date(),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署验证码发送
|
||||
router.post('/contract-confirm/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractSendCodeSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const phone = link.contract.employee.phone
|
||||
if (!phone) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署确认
|
||||
router.post('/contract-confirm', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractConfirmSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
// 验证码校验
|
||||
const stored = codeStore.get(`contract-${data.token}`)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.verifyCode) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const signEvidence = JSON.stringify({
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
await prisma.laborContract.update({
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取入职填报信息(通过 token)
|
||||
router.get('/onboarding/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
include: { org: { select: { name: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({ success: true, data: { orgName: link.org.name } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 撤回入职链接(HR 端调用,需要认证)
|
||||
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status !== 'PENDING') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: { message: '入职链接已撤回' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取合同确认信息(通过 token)
|
||||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: {
|
||||
contract: {
|
||||
include: {
|
||||
employee: { select: { name: true, org: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orgName: link.contract.employee.org.name,
|
||||
employeeName: link.contract.employee.name,
|
||||
contract: link.contract,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重发合同确认链接(HR 端调用,需要认证)
|
||||
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status === 'CONFIRMED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
|
||||
}
|
||||
// 生成新 token 并延长过期时间
|
||||
const crypto = await import('crypto')
|
||||
const newToken = crypto.randomUUID()
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
token: newToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'UNCONFIRMED',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职文件上传
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||||
|
||||
const onboardingUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (allowed.includes(ext)) cb(null, true)
|
||||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||
}
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
fs.unlinkSync(req.file.path)
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const fileType = (req.body.fileType as string) || 'OTHER'
|
||||
const fileUrl = `/uploads/onboarding/${req.file.filename}`
|
||||
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user