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