Files
TurboHR/backend/src/routes/portal.routes.ts
T
freedakgmail 42e0c650a4 feat: 实现20260730优化方案全部功能
- AI文件审查:.docx上传提取文本,支持多种文档类型
- 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程
- 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word
- 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面
- 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤
- 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作
- Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型
- 前后端编译验证全部通过
2026-07-30 10:21:22 +08:00

644 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service'
const router = Router()
// 员工端认证中间件
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 allowed = await checkRateLimit(data.phone)
if (!allowed) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
await setCode(data.phone, code)
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 = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
// 错误次数限制:5次后锁定
if (stored.failCount >= 5) {
await deleteCode(data.phone)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.code) {
await updateCode(data.phone, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
await deleteCode(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, publishStatus: 'PUBLISHED' },
})
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, publishStatus: 'PUBLISHED' },
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',
},
})
await createEvidence({
orgId: req.employee.orgId,
category: 'PAYSLIP_CONFIRM',
refId: payslip.id,
employeeId: req.employee.id,
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.employee.id,
}).catch(() => {})
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)
await setCode(`contract-${data.token}`, code)
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 = await getCode(`contract-${data.token}`)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
await deleteCode(`contract-${data.token}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.verifyCode) {
await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
await deleteCode(`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}` },
})
await createEvidence({
orgId: link.contract.orgId,
category: 'CONTRACT_SIGN',
refId: link.contractId,
employeeId: link.contract.employeeId,
events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }],
createdBy: link.contract.employeeId,
}).catch(() => {})
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)
}
})
// ========== 员工端:规章制度公示 ==========
/** 获取已公示制度列表(含当前员工阅读状态) */
router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policies = await prisma.policyDocument.findMany({
where: { orgId, status: 'PUBLISHED' },
orderBy: { publishedAt: 'desc' },
select: {
id: true,
title: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
const result = policies.map(p => ({
id: p.id,
title: p.title,
type: p.type,
publishedAt: p.publishedAt?.toISOString() || null,
hasRead: p.readRecords.length > 0,
readAt: p.readRecords[0]?.readAt?.toISOString() || null,
}))
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/** 获取制度详情(已公示) */
router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: {
id: true,
title: true,
content: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
res.json({
success: true,
data: {
id: policy.id,
title: policy.title,
content: policy.content,
type: policy.type,
publishedAt: policy.publishedAt?.toISOString() || null,
hasRead: policy.readRecords.length > 0,
readAt: policy.readRecords[0]?.readAt?.toISOString() || null,
},
})
} catch (err) {
next(err)
}
})
/** 提交阅读确认(签收) */
router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: { id: true },
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
// 幂等:已存在则返回已有记录
const existing = await prisma.policyReadRecord.findUnique({
where: { policyId_employeeId: { policyId: req.params.id, employeeId } },
})
if (existing) {
return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } })
}
const record = await prisma.policyReadRecord.create({
data: {
policyId: req.params.id,
orgId,
employeeId,
ip: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'] || null,
},
})
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
} catch (err) {
next(err)
}
})
// ========== 一次性自动登录 ==========
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
/**
* 管理端生成员工一次性自动登录 token(10 分钟有效)
* POST /portal/auto-login-token body: { employeeId }
*/
router.post('/auto-login-token', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId } = req.body
if (!employeeId) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
}
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
select: { id: true, name: true, phone: true, orgId: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
}
const token = jwt.sign(
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
AUTO_LOGIN_SECRET,
{ expiresIn: '10m' }
)
res.json({ success: true, data: { token, employeeName: employee.name, phone: employee.phone } })
} catch (err) {
next(err)
}
})
/**
* 员工端自动登录(消费一次性 token)
* GET /portal/auto-login?token=xxx
*/
router.get('/auto-login', async (req, res, next) => {
try {
const { token } = req.query
if (!token || typeof token !== 'string') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 token' } })
}
let payload: any
try {
payload = jwt.verify(token, AUTO_LOGIN_SECRET)
} catch {
return res.status(401).json({ success: false, error: { code: 'TOKEN_EXPIRED', message: '链接已过期,请重新扫码' } })
}
if (payload.role !== 'EMPLOYEE_AUTO') {
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '无效的登录链接' } })
}
const employee = await prisma.employee.findFirst({
where: { id: payload.id, orgId: payload.orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true, orgId: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
}
const accessToken = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
res.json({ success: true, data: { token: accessToken, employee: { id: employee.id, name: employee.name, department: employee.department } } })
} catch (err) {
next(err)
}
})
// 员工端查看自己的月度考勤
router.get('/attendance', portalAuth, async (req: any, res, next) => {
try {
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
// 检查该月份是否已发布
const publish = await (prisma as any).attendancePublish.findFirst({
where: { orgId: req.employee.orgId, month, status: 'PUBLISHED' },
})
if (!publish) {
return res.json({ success: true, data: { published: false, records: [] } })
}
// 查询该月考勤记录
const startDate = new Date(`${month}-01`)
const endDate = new Date(startDate)
endDate.setMonth(endDate.getMonth() + 1)
const records = await prisma.attendanceRecord.findMany({
where: {
employeeId: req.employee.id,
orgId: req.employee.orgId,
date: { gte: startDate, lt: endDate },
},
orderBy: { date: 'asc' },
})
res.json({ success: true, data: { published: true, records, title: publish.title } })
} catch (err) {
next(err)
}
})
export default router