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:
@@ -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) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user