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
+401
View File
@@ -0,0 +1,401 @@
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