import { Controller, Get, Post, Param, Body, UseGuards, Request, HttpCode, HttpStatus, Logger, Res, StreamableFile, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Response } from 'express'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { ContractSigningService } from '../../application/services/contract-signing.service'; import { PdfGeneratorService } from '../../infrastructure/pdf/pdf-generator.service'; import { MinioStorageService } from '../../infrastructure/storage/minio-storage.service'; import * as crypto from 'crypto'; /** * 获取北京时间的日期字符串 (YYYY-MM-DD) * 用于合同签署日期显示 */ function getBeijingDateString(): string { const now = new Date(); // 北京时间 = UTC + 8小时 const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000); return beijingTime.toISOString().split('T')[0]; } /** * 签名轨迹点 */ interface SignatureTracePoint { x: number; y: number; t: number; // 毫秒时间戳 } /** * 签名笔画 */ interface SignatureTraceStroke { points: SignatureTracePoint[]; startTime: number; endTime: number; } /** * 签名轨迹数据(用于法律凭证) */ interface SignatureTraceData { strokes: SignatureTraceStroke[]; totalDuration: number; // 总签名时长(毫秒) strokeCount: number; // 笔画数量 } /** * 签署合同请求DTO */ interface SignContractDto { signatureBase64: string; // Base64编码的签名图片 signatureHash: string; // SHA256哈希 deviceInfo: { deviceId?: string; deviceModel?: string; osVersion?: string; appVersion?: string; }; location?: { latitude?: number; longitude?: number; }; signatureTrace?: SignatureTraceData; // 签名轨迹数据(可选) } /** * 合同签署配置控制器(公开接口,不需要认证) */ @Controller('planting/contract-signing') export class ContractSigningConfigController { private readonly contractSigningEnabled: boolean; constructor(private readonly configService: ConfigService) { // 默认启用合同签署功能 this.contractSigningEnabled = this.configService.get('CONTRACT_SIGNING_ENABLED', 'true') === 'true'; } /** * 获取合同签署配置(公开接口) * 用于前端判断是否需要在认种前检查实名认证 */ @Get('config') getConfig() { return { success: true, data: { contractSigningEnabled: this.contractSigningEnabled, }, }; } } /** * 合同签署控制器 * * 独立模块,提供合同签署相关的API */ @Controller('planting/contract-signing') @UseGuards(JwtAuthGuard) export class ContractSigningController { private readonly logger = new Logger(ContractSigningController.name); constructor( private readonly contractSigningService: ContractSigningService, private readonly pdfGeneratorService: PdfGeneratorService, private readonly minioStorageService: MinioStorageService, ) {} /** * 获取用户待签署的合同任务列表 */ @Get('pending') async getPendingTasks(@Request() req: { user: { id: string } }) { const userId = BigInt(req.user.id); const tasks = await this.contractSigningService.getPendingTasks(userId); return { success: true, data: tasks, }; } /** * 获取用户所有未签署的合同任务(包括超时的) * 用于App启动时检查 */ @Get('unsigned') async getUnsignedTasks(@Request() req: { user: { id: string } }) { const userId = BigInt(req.user.id); const tasks = await this.contractSigningService.getUnsignedTasks(userId); return { success: true, data: tasks, }; } /** * 检查用户是否需要先完成 KYC * * 如果用户有已付款的认种订单但未完成 KYC,返回需要强制完成 KYC 的信息 */ @Get('kyc-requirement') async checkKycRequirement( @Request() req: { user: { id: string; accountSequence?: string } }, ) { const userId = BigInt(req.user.id); // accountSequence 可能在 token 中,也可能需要从 userId 派生 const accountSequence = req.user.accountSequence || req.user.id; const result = await this.contractSigningService.checkKycRequirement( userId, accountSequence, ); return { success: true, data: result, }; } /** * 获取签署任务详情 */ @Get('tasks/:orderNo') async getTask( @Param('orderNo') orderNo: string, @Request() req: { user: { id: string } }, ) { const userId = BigInt(req.user.id); const task = await this.contractSigningService.getTask(orderNo, userId); if (!task) { return { success: false, message: '签署任务不存在', }; } return { success: true, data: task, }; } /** * 记录用户已滚动到底部 */ @Post('tasks/:orderNo/scroll-complete') @HttpCode(HttpStatus.OK) async markScrollComplete( @Param('orderNo') orderNo: string, @Request() req: { user: { id: string } }, ) { const userId = BigInt(req.user.id); try { await this.contractSigningService.markScrollComplete(orderNo, userId); return { success: true, message: '已记录滚动到底部', }; } catch (error) { this.logger.error(`Failed to mark scroll complete: ${error.message}`); return { success: false, message: error.message, }; } } /** * 记录用户确认法律效力 */ @Post('tasks/:orderNo/acknowledge') @HttpCode(HttpStatus.OK) async acknowledgeContract( @Param('orderNo') orderNo: string, @Request() req: { user: { id: string } }, ) { const userId = BigInt(req.user.id); try { await this.contractSigningService.acknowledgeContract(orderNo, userId); return { success: true, message: '已确认法律效力', }; } catch (error) { this.logger.error(`Failed to acknowledge contract: ${error.message}`); return { success: false, message: error.message, }; } } /** * 上传签名并完成签署 */ @Post('tasks/:orderNo/sign') @HttpCode(HttpStatus.OK) async signContract( @Param('orderNo') orderNo: string, @Body() dto: SignContractDto, @Request() req: { user: { id: string }; ip: string; headers: { 'user-agent'?: string } }, ) { const userId = BigInt(req.user.id); const ipAddress = req.ip || 'unknown'; const userAgent = req.headers['user-agent'] || 'unknown'; try { // 1. 获取任务详情 const task = await this.contractSigningService.getTask(orderNo, userId); if (!task) { throw new Error('签署任务不存在'); } // 2. 解码签名图片 const signatureBuffer = Buffer.from(dto.signatureBase64, 'base64'); this.logger.log(`Signature size: ${signatureBuffer.length} bytes for order ${orderNo}`); // 3. 计算签名哈希(如果前端没有提供) const signatureHash = dto.signatureHash || crypto.createHash('sha256').update(signatureBuffer).digest('hex'); // 4. 上传签名图片到 MinIO let signatureCloudUrl: string; try { signatureCloudUrl = await this.minioStorageService.uploadSignature(orderNo, signatureBuffer); this.logger.log(`Signature uploaded to: ${signatureCloudUrl}`); } catch (uploadError) { this.logger.warn(`Failed to upload signature, using placeholder: ${uploadError.message}`); // 如果上传失败,使用占位 URL(允许服务继续工作) signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`; } // 5. 生成带签名的 PDF(在同一个实例上完成填充和签名) const signingDate = getBeijingDateString(); const pdfBuffer = await this.pdfGeneratorService.generateSignedContractPdf( { contractNo: task.contractNo, userRealName: task.userRealName || '未认证', userIdCard: task.userIdCardNumber || '', userPhone: task.userPhoneNumber || '', treeCount: task.treeCount, signingDate, }, { signatureImagePng: signatureBuffer }, ); this.logger.log(`Signed PDF generated: ${pdfBuffer.length} bytes`); // 7. 上传签署后的 PDF 到 MinIO let signedPdfUrl: string; try { signedPdfUrl = await this.minioStorageService.uploadSignedPdf(orderNo, pdfBuffer); this.logger.log(`Signed PDF uploaded to: ${signedPdfUrl}`); } catch (uploadError) { this.logger.warn(`Failed to upload signed PDF: ${uploadError.message}`); // 如果上传失败,使用空 URL(数据库允许 null) signedPdfUrl = ''; } // 8. 记录签名轨迹数据(如果有) if (dto.signatureTrace) { this.logger.log(`Signature trace: ${dto.signatureTrace.strokeCount} strokes, ${dto.signatureTrace.totalDuration}ms`); } // 9. 完成签署 await this.contractSigningService.signContract(orderNo, userId, { signatureCloudUrl, signatureHash, signedPdfUrl, ipAddress, deviceInfo: dto.deviceInfo, userAgent, location: dto.location, signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined, }); return { success: true, message: '合同签署成功', }; } catch (error) { this.logger.error(`Failed to sign contract: ${error.message}`); return { success: false, message: error.message, }; } } /** * 补签合同(超时后用户仍可补签) */ @Post('tasks/:orderNo/late-sign') @HttpCode(HttpStatus.OK) async lateSignContract( @Param('orderNo') orderNo: string, @Body() dto: SignContractDto, @Request() req: { user: { id: string }; ip: string; headers: { 'user-agent'?: string } }, ) { const userId = BigInt(req.user.id); const ipAddress = req.ip || 'unknown'; const userAgent = req.headers['user-agent'] || 'unknown'; try { // 1. 获取任务详情 const task = await this.contractSigningService.getTask(orderNo, userId); if (!task) { throw new Error('签署任务不存在'); } // 2. 解码签名图片 const signatureBuffer = Buffer.from(dto.signatureBase64, 'base64'); this.logger.log(`Signature size: ${signatureBuffer.length} bytes for late-sign order ${orderNo}`); // 3. 计算签名哈希(如果前端没有提供) const signatureHash = dto.signatureHash || crypto.createHash('sha256').update(signatureBuffer).digest('hex'); // 4. 上传签名图片到 MinIO let signatureCloudUrl: string; try { signatureCloudUrl = await this.minioStorageService.uploadSignature(orderNo, signatureBuffer); this.logger.log(`Signature uploaded to: ${signatureCloudUrl}`); } catch (uploadError) { this.logger.warn(`Failed to upload signature, using placeholder: ${uploadError.message}`); signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`; } // 5. 生成带签名的 PDF(在同一个实例上完成填充和签名) const signingDate = getBeijingDateString(); const pdfBuffer = await this.pdfGeneratorService.generateSignedContractPdf( { contractNo: task.contractNo, userRealName: task.userRealName || '未认证', userIdCard: task.userIdCardNumber || '', userPhone: task.userPhoneNumber || '', treeCount: task.treeCount, signingDate, }, { signatureImagePng: signatureBuffer }, ); this.logger.log(`Signed PDF generated: ${pdfBuffer.length} bytes`); // 7. 上传签署后的 PDF 到 MinIO let signedPdfUrl: string; try { signedPdfUrl = await this.minioStorageService.uploadSignedPdf(orderNo, pdfBuffer); this.logger.log(`Signed PDF uploaded to: ${signedPdfUrl}`); } catch (uploadError) { this.logger.warn(`Failed to upload signed PDF: ${uploadError.message}`); signedPdfUrl = ''; } // 8. 记录签名轨迹数据(如果有) if (dto.signatureTrace) { this.logger.log(`Signature trace: ${dto.signatureTrace.strokeCount} strokes, ${dto.signatureTrace.totalDuration}ms`); } // 9. 完成补签 await this.contractSigningService.lateSignContract(orderNo, userId, { signatureCloudUrl, signatureHash, signedPdfUrl, ipAddress, deviceInfo: dto.deviceInfo, userAgent, location: dto.location, signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined, }); return { success: true, message: '合同补签成功', }; } catch (error) { this.logger.error(`Failed to late-sign contract: ${error.message}`); return { success: false, message: error.message, }; } } /** * 获取当前有效的合同模板(供前端预览) */ @Get('template') async getActiveTemplate() { const template = await this.contractSigningService.getActiveTemplate(); if (!template) { return { success: false, message: '没有可用的合同模板', }; } return { success: true, data: { version: template.version, title: template.title, content: template.content, }, }; } /** * 获取合同 PDF 文件 * 用于前端展示和签署 */ @Get('tasks/:orderNo/pdf') async getContractPdf( @Param('orderNo') orderNo: string, @Request() req: { user: { id: string } }, @Res({ passthrough: true }) res: Response, ): Promise { const userId = BigInt(req.user.id); try { const task = await this.contractSigningService.getTask(orderNo, userId); if (!task) { throw new Error('签署任务不存在'); } let pdfBuffer: Buffer; // 如果已签名且有签名PDF URL,从MinIO下载已签名的PDF if (task.status === 'SIGNED' && task.signedPdfUrl) { this.logger.log(`Downloading signed PDF from MinIO for order ${orderNo}`); try { pdfBuffer = await this.minioStorageService.downloadSignedPdf(task.signedPdfUrl); this.logger.log(`Signed PDF downloaded: ${pdfBuffer.length} bytes`); } catch (downloadError) { this.logger.warn(`Failed to download signed PDF, regenerating: ${downloadError.message}`); // 下载失败时回退到重新生成 pdfBuffer = await this.regeneratePreviewPdf(task); } } else { // 未签名,生成预览PDF pdfBuffer = await this.regeneratePreviewPdf(task); } // 设置响应头 res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="contract-${orderNo}.pdf"`, 'Content-Length': pdfBuffer.length, }); return new StreamableFile(pdfBuffer); } catch (error) { this.logger.error(`Failed to get PDF for order ${orderNo}:`, error); throw error; } } /** * 生成预览PDF(不含签名) */ private async regeneratePreviewPdf(task: { contractNo: string; userRealName?: string; userIdCardNumber?: string; userPhoneNumber?: string; treeCount: number; }): Promise { const signingDate = getBeijingDateString(); return this.pdfGeneratorService.generateContractPdf({ contractNo: task.contractNo, userRealName: task.userRealName || '未认证', userIdCard: task.userIdCardNumber || '', userPhone: task.userPhoneNumber || '', treeCount: task.treeCount, signingDate, }); } }