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:
selfrelease
2026-08-15 16:19:37 +08:00
parent d216e4a57a
commit b6fc93cc61
5 changed files with 748 additions and 133 deletions
+175 -8
View File
@@ -9,7 +9,7 @@ import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service'
import { createEvidence, appendEvidence } from '../services/evidence.service'
const router = Router()
@@ -1029,6 +1029,12 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
// ========== 员工端:电子签署 ==========
// 查看自己的签署记录列表
// ========== 员工端:电子签署(验证码确认 + 证据链) ==========
/**
* 我的电子签署列表
* - 自动将过期的 PENDING 记录标记为 EXPIRED
*/
router.get('/esign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
@@ -1036,11 +1042,30 @@ router.get('/esign', portalAuth, async (req: any, res, next) => {
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
// 自动处理过期:PENDING 且已超过 expiredAt 的记录标记为 EXPIRED
const now = new Date()
const expiredIds = records
.filter(r => r.status === 'PENDING' && r.expiredAt && r.expiredAt < now)
.map(r => r.id)
if (expiredIds.length > 0) {
await prisma.eSignRecord.updateMany({
where: { id: { in: expiredIds } },
data: { status: 'EXPIRED' },
})
expiredIds.forEach(id => {
const r = records.find(rec => rec.id === id)
if (r) r.status = 'EXPIRED'
})
}
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 查看签署详情
/**
* 查看签署详情(含文件内容)
*/
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
@@ -1048,37 +1073,179 @@ router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 检查过期
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.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
/**
* 发送签署验证码
* - 通过手机号发送6位验证码
* - 验证码5分钟有效,最多5次错误
*/
router.post('/esign/:id/send-code', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
include: { employee: { select: { phone: true, name: true } } },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
if (!record.employee.phone) {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '未登记手机号,无法发送验证码' } })
}
// 限流:同一记录60秒内只能发一次
const rateLimitKey = `esign-rate-${record.id}`
const allowed = await checkRateLimit(rateLimitKey, 60 * 1000)
if (!allowed) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
await setCode(`esign-${record.id}`, code)
// 追加证据链 — 验证码发送事件
const evidence = await prisma.evidenceChain.findFirst({
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
})
if (evidence) {
await appendEvidence(orgId, evidence.id, {
action: '发送签署验证码',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `手机号:${record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}`,
}).catch(() => {})
}
res.json({
success: true,
data: {
code, // 开发阶段直接返回,生产环境通过短信发送
message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)',
phone: record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
},
})
} catch (err) { next(err) }
})
/**
* 员工签署操作(验证码确认 + 证据链 + 回写合同)
* - 校验验证码
* - 更新签署状态为 COMPLETED
* - 追加证据链(签署完成事件,含IP/UA/验证码/时间戳)
* - 回写合同 signMethod 和 electronicContractUrl
*/
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { verifyCode } = req.body || {}
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
include: { employee: { select: { name: true, phone: true } } },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
// 当前框架阶段:直接标记为已签署
// 检查过期
if (record.expiredAt && record.expiredAt < new Date()) {
await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' } })
return res.status(400).json({ success: false, error: { code: 'EXPIRED', message: '签署链接已过期' } })
}
// 校验验证码
if (!verifyCode) {
return res.status(400).json({ success: false, error: { code: 'NO_CODE', message: '请输入验证码' } })
}
const stored = await getCode(`esign-${record.id}`)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
await deleteCode(`esign-${record.id}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== verifyCode) {
await updateCode(`esign-${record.id}`, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
await deleteCode(`esign-${record.id}`)
// 签署完成
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
ip: req.ip,
userAgent,
timestamp: new Date().toISOString(),
verifyCode: true,
employeeName: record.employee.name,
phone: record.employee.phone,
})
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
// 保存签署证据到 callbackData(作为签署凭证)
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
},
})
// 如果关联了合同,更新合同签署信息
// 回写合同
if (record.contractId) {
await prisma.laborContract.update({
where: { id: record.contractId },
data: { signMethod: 'ELECTRONIC' },
data: {
signMethod: 'ELECTRONIC',
// 签署证据写入 attachmentName
attachmentName: `e-sign:${new Date().toISOString()}|evidence:${signEvidence}`,
},
})
}
// 追加证据链 — 签署完成事件
const evidence = await prisma.evidenceChain.findFirst({
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
})
if (evidence) {
await appendEvidence(orgId, evidence.id, {
action: `员工签署完成:${record.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent,
smsCode: verifyCode,
location: `签署人:${record.employee.name},验证码已验证`,
}).catch(() => {})
} else {
// 证据链不存在时创建新的
await createEvidence({
orgId,
category: 'CONTRACT_SIGN',
refId: record.id,
employeeId,
events: [{
action: `员工签署完成:${record.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent,
smsCode: verifyCode,
location: `签署人:${record.employee.name},验证码已验证`,
}],
createdBy: employeeId,
}).catch(() => {})
}
res.json({ success: true, data: updated, message: '签署成功' })
} catch (err) { next(err) }
})