rwadurian/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts

535 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>('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<StreamableFile> {
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<Buffer> {
const signingDate = getBeijingDateString();
return this.pdfGeneratorService.generateContractPdf({
contractNo: task.contractNo,
userRealName: task.userRealName || '未认证',
userIdCard: task.userIdCardNumber || '',
userPhone: task.userPhoneNumber || '',
treeCount: task.treeCount,
signingDate,
});
}
}