535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|