42e0c650a4
- AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过
644 lines
24 KiB
TypeScript
644 lines
24 KiB
TypeScript
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
|