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:
freedakgmail
2026-08-11 21:24:43 +08:00
parent b682178549
commit 86e5526a83
27 changed files with 837 additions and 428 deletions
+2 -1
View File
@@ -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' }))
+7 -2
View File
@@ -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: '令牌无效或已过期' } })
+1 -1
View File
@@ -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">&nbsp;</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}`)
+2 -1
View File
@@ -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 || '导入失败'}`)
+6 -3
View File
@@ -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 || '重算失败'}`)
+76
View File
@@ -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) => {
+67
View File
@@ -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
+54 -3
View File
@@ -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">&nbsp;</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}`)
+60 -44
View File
@@ -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 }
}