feat: 电子签署完整流程 + 证据链体系
## 后端 - esign.routes.ts 重写: - 发起签署时校验组织电子签开关(POLICY/PAYSLIP/ONBOARDING) - 自动从模板渲染文件内容(CONTRACT→劳动合同模板,RESIGNATION→离职协议模板) - 创建签署记录时自动创建证据链(发起签署事件) - 新增签署详情接口、证据链查看接口 - 取消签署追加证据链事件 - 签署状态查询自动处理过期记录 - portal.routes.ts 员工端 esign 重写: - 新增验证码发送接口(/esign/:id/send-code) - 签署操作增加验证码校验(5次错误限制) - 签署完成追加证据链(IP/UA/时间戳/验证码/签署人) - 签署完成回写合同 signMethod + attachmentName(签署证据) - 列表和详情接口自动处理过期记录 ## 前端 - ESign.tsx 管理端重写: - 发起签署增加场景选择(5种场景) - 场景选择后自动填充默认文件标题 - 新增签署详情页(文件内容预览 + 证据链时间线) - 签署流程说明 - MyEsign.tsx 员工端重写: - 签署操作增加验证码确认流程 - 60秒倒计时限制 - 文件内容预览 - 签署完成状态展示 - api-services.ts: - esignApi 增加 detail/evidence 接口 - portalApi 增加 esignSendCode 接口 - signEsign 增加 verifyCode 参数 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,43 @@
|
||||
/**
|
||||
* 电子签署路由 — 管理端
|
||||
*
|
||||
* 完整签署流程:
|
||||
* 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链)
|
||||
* 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署
|
||||
* 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件
|
||||
* 4. 易签宝回调(对接后启用):更新状态 + 回写PDF
|
||||
*
|
||||
* 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||
import { renderTemplate, getTemplateById } from '../services/template.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
/** 场景与组织电子签开关的映射 */
|
||||
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
|
||||
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
|
||||
RESIGNATION: null, // 离职协议不需要额外开关
|
||||
POLICY: 'esignPolicyEnabled',
|
||||
PAYSLIP: 'esignPayslipEnabled',
|
||||
ONBOARDING: 'esignOnboardingEnabled',
|
||||
}
|
||||
|
||||
/** 场景与模板ID的映射(自动渲染文件内容) */
|
||||
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
|
||||
CONTRACT: 'tpl_fixed_term_contract',
|
||||
RESIGNATION: 'tpl_termination_agreement',
|
||||
POLICY: null,
|
||||
PAYSLIP: null,
|
||||
ONBOARDING: null,
|
||||
}
|
||||
|
||||
const createSignSchema = z.object({
|
||||
contractId: z.string().optional(),
|
||||
employeeId: z.string().min(1),
|
||||
@@ -13,9 +45,13 @@ const createSignSchema = z.object({
|
||||
documentContent: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
scene: z.string().optional(),
|
||||
templateId: z.string().optional(), // 可指定模板,不传则按 scene 自动匹配
|
||||
templateVars: z.record(z.string()).optional(), // 模板变量
|
||||
})
|
||||
|
||||
// 签署记录列表
|
||||
/**
|
||||
* 签署记录列表
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = req.query.status as string | undefined
|
||||
@@ -35,53 +71,126 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 发起签署
|
||||
// TODO: 对接易签宝 API — 此处为框架预留
|
||||
// 1. 调用易签宝创建签署流程
|
||||
// 2. 获取签署链接
|
||||
// 3. 保存 flowId 和 signUrl
|
||||
/**
|
||||
* 发起签署
|
||||
* - 校验组织电子签开关
|
||||
* - 自动从模板渲染文件内容
|
||||
* - 创建 ESignRecord
|
||||
* - 创建证据链(发起签署事件)
|
||||
*/
|
||||
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createSignSchema.parse(req.body)
|
||||
const scene = data.scene || 'CONTRACT'
|
||||
|
||||
// 预留:调用易签宝 API 创建签署流程
|
||||
// const esignResult = await esignApi.createFlow({
|
||||
// title: data.documentTitle,
|
||||
// signerPhone: employee.phone,
|
||||
// signerName: employee.name,
|
||||
// content: data.documentContent,
|
||||
// })
|
||||
// const flowId = esignResult.flowId
|
||||
// const signUrl = esignResult.signUrl
|
||||
// 1. 校验组织电子签开关
|
||||
const orgFlag = SCENE_ORG_FLAG_MAP[scene]
|
||||
if (orgFlag) {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.user!.orgId },
|
||||
select: { [orgFlag]: true, name: true } as any,
|
||||
})
|
||||
if (org && !(org as any)[orgFlag]) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'ESIGN_DISABLED', message: `组织未开启${scene === 'POLICY' ? '规章制度' : scene === 'PAYSLIP' ? '工资条' : scene === 'ONBOARDING' ? '入职文件' : '该场景'}电子签功能,请在系统设置中开启` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取员工信息
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: data.employeeId, orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, department: true, idCardNumber: true, position: true, monthlySalary: true, hireDate: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
// 3. 自动渲染文件内容(优先使用指定模板,否则按 scene 匹配)
|
||||
let documentContent = data.documentContent || ''
|
||||
if (!documentContent) {
|
||||
const templateId = data.templateId || SCENE_TEMPLATE_MAP[scene]
|
||||
if (templateId) {
|
||||
const template = getTemplateById(templateId)
|
||||
if (template) {
|
||||
// 自动填充模板变量
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { name: true } })
|
||||
const vars: Record<string, string> = {
|
||||
companyName: org?.name || '',
|
||||
employeeName: employee.name || '',
|
||||
idCard: employee.idCardNumber || '',
|
||||
position: employee.position || '',
|
||||
monthlySalary: String(employee.monthlySalary || ''),
|
||||
...data.templateVars,
|
||||
}
|
||||
documentContent = renderTemplate(templateId, vars) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建签署记录
|
||||
const record = await prisma.eSignRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
contractId: data.contractId || null,
|
||||
employeeId: data.employeeId,
|
||||
scene: data.scene || 'CONTRACT',
|
||||
scene,
|
||||
documentTitle: data.documentTitle,
|
||||
documentContent: data.documentContent || null,
|
||||
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
|
||||
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
|
||||
documentContent: documentContent || null,
|
||||
status: 'PENDING',
|
||||
initiatedBy: req.user!.id,
|
||||
createdBy: req.user!.id,
|
||||
remark: data.remark || null,
|
||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
|
||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
|
||||
// 5. 创建证据链 — 发起签署事件
|
||||
await createEvidence({
|
||||
orgId: req.user!.orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: record.id,
|
||||
employeeId: data.employeeId,
|
||||
events: [{
|
||||
action: `发起电子签署:${data.documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `场景:${scene},发起人:${req.user!.id}`,
|
||||
}],
|
||||
createdBy: req.user!.id,
|
||||
}).catch(() => {})
|
||||
|
||||
await auditLog(req, 'ESIGN_CREATE', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, scene, documentTitle: data.documentTitle })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record,
|
||||
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
|
||||
message: '签署记录已创建,员工可在员工端查看并签署',
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 查询签署状态
|
||||
// TODO: 对接易签宝 API — 查询流程状态并同步
|
||||
/**
|
||||
* 签署记录详情
|
||||
*/
|
||||
router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true } },
|
||||
},
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 查询签署状态
|
||||
*/
|
||||
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
@@ -89,22 +198,41 @@ router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
|
||||
// 预留:调用易签宝 API 查询流程状态
|
||||
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
|
||||
// if (esignStatus !== record.status) {
|
||||
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
|
||||
// }
|
||||
// 检查是否已过期但状态仍为 PENDING
|
||||
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
})
|
||||
return res.json({ success: true, data: updated })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 易签宝回调接口(无需认证)
|
||||
/**
|
||||
* 获取签署记录的证据链
|
||||
*/
|
||||
router.get('/:id/evidence', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const evidence = await prisma.evidenceChain.findMany({
|
||||
where: { orgId: req.user!.orgId, refId: req.params.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: evidence })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 易签宝回调接口(无需认证)
|
||||
* 对接易签宝后启用:验证签名 + 更新状态 + 回写PDF + 追加证据链
|
||||
*/
|
||||
router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
||||
|
||||
// TODO: 验证易签宝回调签名
|
||||
// TODO: 对接易签宝后验证回调签名
|
||||
// if (!verifyEsignCallback(req.headers, req.body)) {
|
||||
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
|
||||
// }
|
||||
@@ -122,16 +250,26 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
},
|
||||
})
|
||||
|
||||
// 如果关联了合同,更新合同的签署方式和电子合同URL
|
||||
if (record.contractId && status === 'COMPLETED') {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: {
|
||||
signMethod: 'ELECTRONIC',
|
||||
electronicContractUrl: signedPdfUrl || null,
|
||||
},
|
||||
})
|
||||
// 签署完成 → 回写合同 + 追加证据链
|
||||
if (status === 'COMPLETED') {
|
||||
if (record.contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: {
|
||||
signMethod: 'ELECTRONIC',
|
||||
electronicContractUrl: signedPdfUrl || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await appendEvidence(record.orgId, '', {
|
||||
action: '易签宝回调:签署完成',
|
||||
timestamp: new Date().toISOString(),
|
||||
location: `flowId: ${flowId},PDF: ${signedPdfUrl || '无'}`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await auditLog({} as any, 'ESIGN_CALLBACK', 'ESIGN_RECORD', record.id, { flowId, status })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +277,43 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 取消签署
|
||||
/**
|
||||
* 取消签署
|
||||
* - 更新状态为 CANCELLED
|
||||
* - 追加证据链
|
||||
*/
|
||||
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.update({
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
if (record.status === 'COMPLETED') {
|
||||
return res.status(400).json({ success: false, error: { message: '已完成的签署不可取消' } })
|
||||
}
|
||||
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
|
||||
// 追加证据链
|
||||
const evidence = await prisma.evidenceChain.findFirst({
|
||||
where: { orgId: req.user!.orgId, refId: record.id },
|
||||
})
|
||||
if (evidence) {
|
||||
await appendEvidence(req.user!.orgId, evidence.id, {
|
||||
action: '取消签署',
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `操作人:${req.user!.id}`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await auditLog(req, 'ESIGN_CANCEL', 'ESIGN_RECORD', record.id, {})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user