Files
TurboHR/backend/src/routes/ai.routes.ts
T
selfrelease 0df8aa77d9 feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
2026-07-24 13:53:11 +08:00

402 lines
15 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 } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
FREE: { chat: 10, review: 3, case: 3 },
PRO: { chat: 100, review: 20, case: 20 },
ENTERPRISE: { chat: 0, review: 0, case: 0 },
}
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
const limit = limits[type]
if (limit === 0) return
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const count = await prisma.auditLog.count({
where: {
orgId,
action: `AI_${type.toUpperCase()}`,
createdAt: { gte: monthStart },
},
})
if (count >= limit) {
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
}
}
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const month = new Date().toISOString().slice(0, 7)
await prisma.auditLog.create({
data: {
orgId,
userId,
action: `AI_${type.toUpperCase()}`,
entity: 'AI',
entityId: null,
detail: { month, type } as any,
ip: '',
},
})
}
async function buildOrgContext(orgId: string): Promise<string> {
const [employees, risks] = await Promise.all([
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
}),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
include: { employee: true },
}),
])
const now = new Date()
const empSummary = employees.map((e) => {
const contract = e.contracts[0]
const daysToExpire = contract?.endDate
? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null
const specialStatus: string[] = []
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
if (e.isWorkInjured) specialStatus.push('工伤')
return `- ${e.name}${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}${contract ? `合同:${contract.contractType}${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
}).join('\n')
const riskSummary = risks.map((r) => `- ${r.title}${r.level}):${r.description || '无详细描述'}`).join('\n')
return `员工列表(${employees.length}人):
${empSummary}
当前风险项(${risks.length}项):
${riskSummary}`
}
router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
const reply = await chat(messages, orgContext)
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
res.json({ success: true, data: { reply } })
} catch (err) {
next(err)
}
})
router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
let usageRecorded = false
try {
for await (const delta of chatStream(messages, orgContext)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
}
res.write('data: [DONE]\n\n')
} finally {
if (!usageRecorded) {
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
usageRecorded = true
}
}
res.end()
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractText } = req.body as { contractText: string }
if (!contractText) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
}
await checkUsageLimit(req.user!.orgId, 'review')
const result = await reviewContract(contractText)
await recordUsage(req.user!.orgId, req.user!.id, 'review')
res.json({ success: true, data: { text: result.text, structured: result.structured } })
} catch (err) {
next(err)
}
})
router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { scenario } = req.body as { scenario: string }
if (!scenario) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
}
await checkUsageLimit(req.user!.orgId, 'case')
const result = await matchCase(scenario)
await recordUsage(req.user!.orgId, req.user!.id, 'case')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// 案例匹配结果转待办(RiskItem)
router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'),
type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'),
})
const data = schema.parse(req.body)
const risk = await prisma.riskItem.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
title: data.title,
description: data.description,
level: data.level,
type: data.type,
status: 'PENDING',
},
})
res.json({ success: true, data: risk })
} catch (err) {
next(err)
}
})
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const department = req.query.department as string
const employeeId = req.query.employeeId as string
const riskType = req.query.riskType as string
let orgContext = await buildOrgContext(req.user!.orgId)
if (employeeId) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
if (emp) {
const contract = emp.contracts[0]
orgContext = `员工详情:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
}
} else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
}
if (riskType && riskType !== 'all') {
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
}
const result = await predictRisks(orgContext)
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// ========== AI 会话历史 ==========
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conversations = await prisma.aIConversation.findMany({
where: { orgId: req.user!.orgId, userId: req.user!.id },
orderBy: { updatedAt: 'desc' },
take: 50,
select: { id: true, title: true, createdAt: true, updatedAt: true },
})
res.json({ success: true, data: conversations })
} catch (err) {
next(err)
}
})
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages: any[] }
const conv = await prisma.aIConversation.create({
data: {
orgId: req.user!.orgId,
userId: req.user!.id,
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
messages: messages || [],
},
})
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages?: any[] }
const conv = await prisma.aIConversation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
data: {
...(title ? { title } : {}),
...(messages ? { messages } : {}),
},
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== AI 审查记录保存到员工档案 ==========
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string(),
type: z.enum(['REVIEW', 'CASE']),
input: z.string(),
result: z.string(),
})
const data = schema.parse(req.body)
const record = await prisma.aIReviewRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
type: data.type,
input: data.input,
result: data.result,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.aIReviewRecord.findMany({
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
orderBy: { createdAt: 'desc' },
take: 20,
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (_req: AuthRequest, res, next) => {
try {
await seedKnowledgeBase()
res.json({ success: true, data: { message: '知识库初始化完成' } })
} catch (err) {
next(err)
}
})
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, content, source, category } = req.body
if (!title || !content) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
}
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { query, topK } = req.body
if (!query) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
}
const results = await searchKnowledge(query, topK || 5)
res.json({ success: true, data: { results } })
} catch (err) {
next(err)
}
})
// 知识库列表
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
const category = req.query.category as string | undefined
const items = category
? await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge WHERE category = ${category} ORDER BY created_at DESC LIMIT 200` as any[]
: await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge ORDER BY created_at DESC LIMIT 200` as any[]
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 删除知识条目
router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router