feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+425
View File
@@ -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