fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
This commit is contained in:
+2
-1
@@ -23,8 +23,9 @@ app.use(compression({
|
||||
}))
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
origin: true,
|
||||
credentials: true,
|
||||
exposedHeaders: ['Content-Disposition'],
|
||||
}),
|
||||
)
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
@@ -8,10 +8,15 @@ export interface AuthRequest extends Request {
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
let token: string | undefined
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7)
|
||||
} else if (typeof req.query.token === 'string') {
|
||||
token = req.query.token
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload) {
|
||||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
|
||||
|
||||
@@ -140,7 +140,7 @@ router.get('/employee-summary', async (req: AuthRequest, res: Response, next: Ne
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } },
|
||||
},
|
||||
})
|
||||
const summary: Record<string, any> = {}
|
||||
|
||||
@@ -146,13 +146,59 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 包装为 HTML 格式以确保 Word 正确打开
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
let content = template.content
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
if (Object.keys(variables).length > 0) {
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
// 如果内容已包含HTML标签,直接返回
|
||||
if (/<[a-z][\s\S]*>/i.test(text)) return text
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -863,7 +863,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
|
||||
// 重新计算税费
|
||||
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
|
||||
@@ -476,9 +476,10 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...inputs, ...calcResult },
|
||||
data: { ...inputs, ...entryData },
|
||||
})
|
||||
|
||||
// 更新批次汇总
|
||||
@@ -591,11 +592,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
const entry = await prisma.batchEntry.create({
|
||||
data: {
|
||||
batchId, orgId, employeeId,
|
||||
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
|
||||
...calcResult, riskWarnings,
|
||||
...entryData, riskWarnings,
|
||||
},
|
||||
})
|
||||
results.push(entry)
|
||||
@@ -733,9 +735,10 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...calcResult },
|
||||
data: { ...entryData },
|
||||
})
|
||||
} catch (e: any) {
|
||||
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
|
||||
|
||||
@@ -891,6 +891,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 下载离职证明(仅已完成的离职记录)
|
||||
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await (prisma as any).terminationRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
|
||||
if (record.status !== 'COMPLETED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findFirst({ where: { id: orgId } })
|
||||
const orgName = org?.name || ''
|
||||
|
||||
// 查找企业自定义的离职证明模板
|
||||
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { orgId, category: 'LEAVING_CERT' },
|
||||
})
|
||||
|
||||
const reasonLabel: Record<string, string> = {
|
||||
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
|
||||
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
|
||||
}
|
||||
const reason = reasonLabel[record.reason] || record.reason || ''
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
employeeName: record.employee?.name || '',
|
||||
idCardNumber: record.employee?.idCardNumber || '',
|
||||
department: record.employee?.department || '',
|
||||
position: record.employee?.position || '',
|
||||
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
|
||||
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
|
||||
reason,
|
||||
companyName: orgName,
|
||||
compensation: String(record.compensation || 0),
|
||||
socialInsEndMonth: record.socialInsEndMonth || '',
|
||||
housingFundEndMonth: record.housingFundEndMonth || '',
|
||||
}
|
||||
|
||||
let content: string
|
||||
if (tpl) {
|
||||
content = tpl.content
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
} else {
|
||||
content = `<h1>解除/终止劳动合同证明书</h1>
|
||||
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||
<p>特此证明。</p>
|
||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||
}
|
||||
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>离职证明</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${content}</body></html>`
|
||||
|
||||
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(htmlContent)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 员工端:休假申请 ==========
|
||||
// 查看自己的休假申请列表
|
||||
router.get('/leaves', portalAuth, async (req: any, res, next) => {
|
||||
|
||||
@@ -1511,4 +1511,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 员工参保信息列表 ==========
|
||||
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
|
||||
// 查询所有在职员工
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
status: 'ACTIVE',
|
||||
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
department: true,
|
||||
position: true,
|
||||
socialInsBase: true,
|
||||
housingFundBase: true,
|
||||
city: true,
|
||||
},
|
||||
orderBy: { department: 'asc' },
|
||||
})
|
||||
|
||||
const empIds = employees.map(e => e.id)
|
||||
|
||||
// 查询当前有效的社保记录(endMonth 为 null)
|
||||
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
// 查询当前有效的公积金记录
|
||||
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
|
||||
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
|
||||
|
||||
const list = employees.map(emp => {
|
||||
const social = socialMap.get(emp.id)
|
||||
const housing = housingMap.get(emp.id)
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
position: emp.position,
|
||||
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
|
||||
socialInsCity: social?.city ?? emp.city ?? '',
|
||||
socialInsStart: social?.startMonth ?? '',
|
||||
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
|
||||
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
|
||||
housingFundCity: housing?.city ?? emp.city ?? '',
|
||||
housingFundStart: housing?.startMonth ?? '',
|
||||
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: list })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -42,19 +42,70 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
/** 下载模板(Word .doc 格式) */
|
||||
/** 下载模板(Word .doc 格式,支持变量替换) */
|
||||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = getTemplateById(req.params.id)
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
let content = template.content
|
||||
if (Object.keys(variables).length > 0) {
|
||||
content = renderTemplate(req.params.id, variables) || content
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
let inTable = false
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
// 标题检测:以"第X条"开头的行作为小标题
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
if (inTable) html += '</table>'
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
|
||||
/**
|
||||
* 递归排序对象/数组中所有 key,确保 JSON.stringify 结果一致
|
||||
* 解决 PostgreSQL jsonb 类型自动重排 key 顺序导致哈希不一致的问题
|
||||
*/
|
||||
function deepSortKeys(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (Array.isArray(obj)) return obj.map(deepSortKeys)
|
||||
if (typeof obj === 'object' && !(obj instanceof Date)) {
|
||||
const sorted: any = {}
|
||||
Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k]))
|
||||
return sorted
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 对事件对象的 key 按字母序排序(递归),确保 JSON.stringify 结果一致
|
||||
*/
|
||||
function sortEventKeys(events: any[]): any[] {
|
||||
return events.map(deepSortKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算证据链哈希(统一使用排序后的 key)
|
||||
*/
|
||||
function computeHash(events: any[], orgId: string, category: string, refId: string): string {
|
||||
const sortedEvents = sortEventKeys(events)
|
||||
const eventsJson = JSON.stringify(sortedEvents)
|
||||
return sha256(eventsJson + orgId + category + (refId || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
|
||||
*/
|
||||
@@ -26,8 +57,7 @@ export async function createEvidence(params: {
|
||||
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
|
||||
createdBy: string
|
||||
}) {
|
||||
const eventsJson = JSON.stringify(params.events)
|
||||
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
|
||||
const hash = computeHash(params.events, params.orgId, params.category, params.refId || '')
|
||||
|
||||
return prisma.evidenceChain.create({
|
||||
data: {
|
||||
@@ -52,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: {
|
||||
}
|
||||
|
||||
const events = [...(existing.events as any[]), event]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
|
||||
const hash = computeHash(events, orgId, existing.category, existing.refId || '')
|
||||
|
||||
return prisma.evidenceChain.update({
|
||||
where: { id: evidenceId },
|
||||
@@ -104,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid
|
||||
}
|
||||
|
||||
const events = evidence.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '')
|
||||
|
||||
// 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
if (expectedHash !== evidence.hash) {
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
return {
|
||||
valid: sortedHash === evidence.hash,
|
||||
expectedHash: sortedHash,
|
||||
actualHash: evidence.hash,
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || ''))
|
||||
if (legacyHash === evidence.hash) {
|
||||
// 旧哈希匹配,自动更新为新排序哈希
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
expectedHash,
|
||||
actualHash: evidence.hash,
|
||||
}
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,35 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu
|
||||
|
||||
/**
|
||||
* 验证全部证据链完整性
|
||||
* 自动修复因 PostgreSQL jsonb key 重排或哈希算法升级导致的不一致
|
||||
*/
|
||||
export async function verifyAllEvidence(orgId: string) {
|
||||
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
let repaired = 0
|
||||
const invalidItems: any[] = []
|
||||
for (const r of records) {
|
||||
const events = r.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, r.category, r.refId || '')
|
||||
if (expectedHash === r.hash) { valid++; continue }
|
||||
// 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
|
||||
if (sortedHash === r.hash) { valid++; continue }
|
||||
invalid++
|
||||
invalidItems.push({
|
||||
id: r.id,
|
||||
category: r.category,
|
||||
refId: r.refId,
|
||||
employeeId: r.employeeId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
|
||||
})
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || ''))
|
||||
if (legacyHash === r.hash) {
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
continue
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
}
|
||||
return { total: records.length, valid, invalid, invalidItems }
|
||||
return { total: records.length, valid, invalid, repaired, invalidItems }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user