204 lines
6.3 KiB
TypeScript
204 lines
6.3 KiB
TypeScript
import prisma from '../lib/prisma'
|
|
import { sha256 } from '../lib/crypto'
|
|
|
|
/**
|
|
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
|
|
*/
|
|
|
|
export type EvidenceCategory =
|
|
| 'CONTRACT_SIGN'
|
|
| 'ONBOARD'
|
|
| 'PAYSLIP_CONFIRM'
|
|
| 'DISCIPLINARY'
|
|
| 'ATTENDANCE'
|
|
| 'TERMINATION'
|
|
|
|
/**
|
|
* 创建证据链记录
|
|
*/
|
|
export async function createEvidence(params: {
|
|
orgId: string
|
|
category: EvidenceCategory
|
|
refId?: string
|
|
employeeId?: string
|
|
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 || ''))
|
|
|
|
return prisma.evidenceChain.create({
|
|
data: {
|
|
orgId: params.orgId,
|
|
category: params.category,
|
|
refId: params.refId || null,
|
|
employeeId: params.employeeId || null,
|
|
events: params.events,
|
|
hash,
|
|
createdBy: params.createdBy,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 追加证据事件到已有证据链
|
|
*/
|
|
export async function appendEvidence(orgId: string, evidenceId: string, event: { action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }) {
|
|
const existing = await prisma.evidenceChain.findFirst({ where: { id: evidenceId, orgId } })
|
|
if (!existing) {
|
|
throw { code: 'NOT_FOUND', message: '证据链不存在' }
|
|
}
|
|
|
|
const events = [...(existing.events as any[]), event]
|
|
const eventsJson = JSON.stringify(events)
|
|
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
|
|
|
|
return prisma.evidenceChain.update({
|
|
where: { id: evidenceId },
|
|
data: { events, hash },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 获取某操作的完整证据链
|
|
*/
|
|
export async function getEvidenceByRef(orgId: string, category: string, refId: string) {
|
|
return prisma.evidenceChain.findMany({
|
|
where: { orgId, category, refId },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 获取某员工所有证据链
|
|
*/
|
|
export async function getEvidenceByEmployee(orgId: string, employeeId: string) {
|
|
return prisma.evidenceChain.findMany({
|
|
where: { orgId, employeeId },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 获取证据链详情
|
|
*/
|
|
export async function getEvidenceDetail(orgId: string, id: string) {
|
|
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
|
|
if (!evidence) {
|
|
throw { code: 'NOT_FOUND', message: '证据链不存在' }
|
|
}
|
|
return evidence
|
|
}
|
|
|
|
/**
|
|
* 验证证据链完整性(重新计算哈希对比)
|
|
* 注意:PostgreSQL 的 json 类型会按 key 字母序重排属性,导致 JSON.stringify 结果与原始不一致。
|
|
* 因此验证时需要尝试两种方式:原始序列化和排序后序列化。
|
|
*/
|
|
export async function verifyEvidence(orgId: string, id: string): Promise<{ valid: boolean; expectedHash: string; actualHash: string }> {
|
|
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
|
|
if (!evidence) {
|
|
throw { code: 'NOT_FOUND', message: '证据链不存在' }
|
|
}
|
|
|
|
const events = evidence.events as any[]
|
|
const eventsJson = JSON.stringify(events)
|
|
const expectedHash = sha256(eventsJson + 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,
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
expectedHash,
|
|
actualHash: evidence.hash,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取组织级证据链列表(支持按类别筛选)
|
|
*/
|
|
export async function getEvidenceList(orgId: string, category?: string, page: number = 1, pageSize: number = 20) {
|
|
const where: any = { orgId }
|
|
if (category && category !== 'ALL') {
|
|
where.category = category
|
|
}
|
|
|
|
const [total, records] = await Promise.all([
|
|
prisma.evidenceChain.count({ where }),
|
|
prisma.evidenceChain.findMany({
|
|
where,
|
|
include: { employee: { select: { id: true, name: true, department: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
])
|
|
|
|
return {
|
|
total,
|
|
page,
|
|
pageSize,
|
|
records: records.map((r: any) => {
|
|
const events = Array.isArray(r.events) ? r.events : []
|
|
const firstEvent = events.length > 0 ? events[0] : null
|
|
const lastEvent = events.length > 0 ? events[events.length - 1] : null
|
|
return {
|
|
id: r.id,
|
|
category: r.category,
|
|
refId: r.refId,
|
|
employeeId: r.employeeId,
|
|
employeeName: r.employee?.name || null,
|
|
employeeDept: r.employee?.department || null,
|
|
eventCount: events.length,
|
|
firstAction: firstEvent?.action || null,
|
|
lastAction: lastEvent?.action || null,
|
|
firstTimestamp: firstEvent?.timestamp || null,
|
|
lastTimestamp: lastEvent?.timestamp || null,
|
|
hash: r.hash,
|
|
hashShort: r.hash ? r.hash.substring(0, 16) : null,
|
|
createdAt: r.createdAt.toISOString(),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 验证全部证据链完整性
|
|
*/
|
|
export async function verifyAllEvidence(orgId: string) {
|
|
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
|
let valid = 0
|
|
let invalid = 0
|
|
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 || ''))
|
|
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++
|
|
else invalid++
|
|
}
|
|
return { total: records.length, valid, invalid }
|
|
}
|