feat(contract): 增强合同生成功能
- 添加 IdentityServiceClient 从 identity-service 获取用户 KYC 信息 - 只允许已完成实名认证的用户创建合同 - 添加 KycVerifiedEventConsumer 监听 KYC 完成事件 - 用户完成 KYC 后自动为其之前已支付的订单补创建合同 - PDF 生成器支持 AcroForm 表单字段填充(更可靠) - 保留坐标定位方式作为后备方案 - 更新 PDF 模板为带表单字段版本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0e93d2a343
commit
929ae335c5
|
|
@ -41,6 +41,7 @@ describe('PlantingApplicationService (Integration)', () => {
|
|||
findReadyForMining: jest.fn(),
|
||||
countTreesByUserId: jest.fn(),
|
||||
countByUserId: jest.fn(),
|
||||
findPaidOrdersWithoutContract: jest.fn(),
|
||||
};
|
||||
|
||||
positionRepository = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export interface IPlantingOrderRepository {
|
|||
findReadyForMining(): Promise<PlantingOrder[]>;
|
||||
countTreesByUserId(userId: bigint): Promise<number>;
|
||||
countByUserId(userId: bigint): Promise<number>;
|
||||
/**
|
||||
* 查找用户已支付(PAID 及之后状态)但未创建合同的订单
|
||||
* 用于用户完成 KYC 后补创建合同
|
||||
*/
|
||||
findPaidOrdersWithoutContract(userId: bigint): Promise<PlantingOrder[]>;
|
||||
}
|
||||
|
||||
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');
|
||||
|
|
|
|||
196
backend/services/planting-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal file
196
backend/services/planting-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* 用户详情信息响应
|
||||
*/
|
||||
export interface UserDetailInfo {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
nickname: string;
|
||||
avatarUrl?: string;
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
registeredAt: string;
|
||||
inviterSequence?: string;
|
||||
kycStatus: string;
|
||||
realName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户 KYC 信息(用于合同签署)
|
||||
*/
|
||||
export interface UserKycInfo {
|
||||
phoneNumber?: string;
|
||||
realName?: string;
|
||||
idCardNumber?: string; // 身份证号目前 identity-service 不返回,需要单独处理
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 重试配置
|
||||
*/
|
||||
interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 5000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Identity Service 客户端
|
||||
* 用于获取用户信息和 KYC 数据
|
||||
*/
|
||||
@Injectable()
|
||||
export class IdentityServiceClient {
|
||||
private readonly logger = new Logger(IdentityServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly retryConfig: RetryConfig;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
this.configService.get<string>('IDENTITY_SERVICE_URL') ||
|
||||
'http://localhost:3001';
|
||||
this.retryConfig = DEFAULT_RETRY_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的 HTTP 请求包装器
|
||||
*/
|
||||
private async withRetry<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
config: RetryConfig = this.retryConfig,
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: unknown) {
|
||||
lastError = error as Error;
|
||||
|
||||
const shouldRetry = this.shouldRetry(error, attempt, config.maxRetries);
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = this.calculateBackoffDelay(attempt, config);
|
||||
this.logger.warn(
|
||||
`${operation} failed (attempt ${attempt + 1}/${config.maxRetries + 1}), ` +
|
||||
`retrying in ${delay}ms: ${(error as Error).message}`,
|
||||
);
|
||||
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private shouldRetry(
|
||||
error: unknown,
|
||||
attempt: number,
|
||||
maxRetries: number,
|
||||
): boolean {
|
||||
if (attempt >= maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const axiosError = error as { response?: { status?: number }; code?: string };
|
||||
|
||||
// 网络错误 - 应该重试
|
||||
if (!axiosError.response) {
|
||||
const retryableCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'];
|
||||
if (axiosError.code && retryableCodes.includes(axiosError.code)) {
|
||||
return true;
|
||||
}
|
||||
if ((error as Error).message?.includes('timeout')) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5xx 服务器错误 - 应该重试
|
||||
if (axiosError.response.status && axiosError.response.status >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 429 Too Many Requests - 可以重试
|
||||
if (axiosError.response.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private calculateBackoffDelay(attempt: number, config: RetryConfig): number {
|
||||
const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt);
|
||||
const jitter = Math.random() * config.baseDelayMs * 0.5;
|
||||
return Math.min(exponentialDelay + jitter, config.maxDelayMs);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 accountSequence 获取用户详情
|
||||
*/
|
||||
async getUserDetailBySequence(accountSequence: string): Promise<UserDetailInfo | null> {
|
||||
try {
|
||||
return await this.withRetry(
|
||||
`getUserDetailBySequence(${accountSequence})`,
|
||||
async () => {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<UserDetailInfo>(
|
||||
`${this.baseUrl}/internal/users/${accountSequence}/detail`,
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get user detail for accountSequence ${accountSequence}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 KYC 信息用于合同签署
|
||||
* 只有 KYC 状态为 VERIFIED 的用户才能签署合同
|
||||
* @returns KYC 信息,如果未认证则返回 null
|
||||
*/
|
||||
async getUserKycInfo(accountSequence: string): Promise<UserKycInfo | null> {
|
||||
const userDetail = await this.getUserDetailBySequence(accountSequence);
|
||||
|
||||
if (!userDetail) {
|
||||
this.logger.warn(`User not found for accountSequence: ${accountSequence}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查 KYC 状态 - 未认证不允许签署合同
|
||||
if (userDetail.kycStatus !== 'VERIFIED') {
|
||||
this.logger.warn(
|
||||
`User ${accountSequence} KYC status is ${userDetail.kycStatus}, not VERIFIED. Cannot create contract.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
phoneNumber: userDetail.phoneNumber,
|
||||
realName: userDetail.realName,
|
||||
// 身份证号暂时不从 identity-service 获取(涉及敏感数据处理)
|
||||
idCardNumber: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,12 @@ import { PaymentCompensationRepository } from './persistence/repositories/paymen
|
|||
import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
|
||||
import { WalletServiceClient } from './external/wallet-service.client';
|
||||
import { ReferralServiceClient } from './external/referral-service.client';
|
||||
import { IdentityServiceClient } from './external/identity-service.client';
|
||||
import { KafkaModule } from './kafka/kafka.module';
|
||||
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
||||
import { EventAckController } from './kafka/event-ack.controller';
|
||||
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
||||
import { KycVerifiedEventConsumer } from './kafka/kyc-verified-event.consumer';
|
||||
import { PdfGeneratorService } from './pdf/pdf-generator.service';
|
||||
import { MinioStorageService } from './storage/minio-storage.service';
|
||||
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
||||
|
|
@ -34,7 +36,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
|||
}),
|
||||
KafkaModule,
|
||||
],
|
||||
controllers: [EventAckController, ContractSigningEventConsumer],
|
||||
controllers: [EventAckController, ContractSigningEventConsumer, KycVerifiedEventConsumer],
|
||||
providers: [
|
||||
PrismaService,
|
||||
{
|
||||
|
|
@ -70,6 +72,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
|||
MinioStorageService,
|
||||
WalletServiceClient,
|
||||
ReferralServiceClient,
|
||||
IdentityServiceClient,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
|
|
@ -88,6 +91,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
|||
MinioStorageService,
|
||||
WalletServiceClient,
|
||||
ReferralServiceClient,
|
||||
IdentityServiceClient,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories';
|
||||
import { IdentityServiceClient } from '../external/identity-service.client';
|
||||
|
||||
/**
|
||||
* 认种事件消息结构
|
||||
|
|
@ -48,6 +49,7 @@ export class ContractSigningEventConsumer {
|
|||
private readonly contractSigningService: ContractSigningService,
|
||||
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||
private readonly orderRepo: IPlantingOrderRepository,
|
||||
private readonly identityServiceClient: IdentityServiceClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -97,10 +99,16 @@ export class ContractSigningEventConsumer {
|
|||
return;
|
||||
}
|
||||
|
||||
// 2. 获取用户 KYC 信息
|
||||
// TODO: 调用 identity-service 获取用户信息
|
||||
// 目前使用占位数据
|
||||
const kycInfo = await this.getUserKycInfo(order.userId);
|
||||
// 2. 获取用户 KYC 信息(只有实名认证通过的用户才能创建合同)
|
||||
const accountSequence = data.accountSequence || order.userId.toString();
|
||||
const kycInfo = await this.getUserKycInfo(accountSequence);
|
||||
|
||||
if (!kycInfo) {
|
||||
this.logger.warn(
|
||||
`[CONTRACT-SIGNING] User ${accountSequence} has not completed KYC verification. Skipping contract creation for order: ${orderNo}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 创建合同签署任务
|
||||
const provinceCitySelection = order.provinceCitySelection;
|
||||
|
|
@ -112,7 +120,7 @@ export class ContractSigningEventConsumer {
|
|||
await this.contractSigningService.createSigningTask({
|
||||
orderNo,
|
||||
userId: order.userId,
|
||||
accountSequence: data.accountSequence || order.userId.toString(),
|
||||
accountSequence,
|
||||
treeCount: order.treeCount.value,
|
||||
totalAmount: order.totalAmount,
|
||||
provinceCode: provinceCitySelection.provinceCode,
|
||||
|
|
@ -133,12 +141,18 @@ export class ContractSigningEventConsumer {
|
|||
|
||||
/**
|
||||
* 获取用户 KYC 信息
|
||||
* TODO: 实际实现应调用 identity-service
|
||||
*/
|
||||
private async getUserKycInfo(userId: bigint): Promise<UserKycInfo | null> {
|
||||
// TODO: 调用 identity-service 获取用户 KYC 信息
|
||||
// 目前返回 null,合同中会显示"未认证"
|
||||
this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for user: ${userId}`);
|
||||
return null;
|
||||
private async getUserKycInfo(accountSequence: string): Promise<UserKycInfo | null> {
|
||||
this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for accountSequence: ${accountSequence}`);
|
||||
try {
|
||||
const kycInfo = await this.identityServiceClient.getUserKycInfo(accountSequence);
|
||||
if (kycInfo) {
|
||||
this.logger.log(`[CONTRACT-SIGNING] Got KYC info for ${accountSequence}: realName=${kycInfo.realName ? '***' : 'null'}`);
|
||||
}
|
||||
return kycInfo;
|
||||
} catch (error) {
|
||||
this.logger.error(`[CONTRACT-SIGNING] Failed to get KYC info for ${accountSequence}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { Controller, Logger, Inject } from '@nestjs/common';
|
||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||
import { ContractSigningService } from '../../application/services/contract-signing.service';
|
||||
import {
|
||||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories';
|
||||
import { IdentityServiceClient } from '../external/identity-service.client';
|
||||
|
||||
/**
|
||||
* KYCVerified 事件消息结构
|
||||
*/
|
||||
interface KYCVerifiedEventMessage {
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
aggregateId: string;
|
||||
payload: {
|
||||
userId: string;
|
||||
verifiedAt: string;
|
||||
};
|
||||
_outbox?: {
|
||||
id: string;
|
||||
aggregateId: string;
|
||||
eventType: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KYC 认证完成事件消费者
|
||||
*
|
||||
* 监听 identity.KYCVerified 事件
|
||||
* 当用户完成实名认证后,为其之前已支付但未创建合同的订单补创建合同
|
||||
*/
|
||||
@Controller()
|
||||
export class KycVerifiedEventConsumer {
|
||||
private readonly logger = new Logger(KycVerifiedEventConsumer.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractSigningService: ContractSigningService,
|
||||
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||
private readonly orderRepo: IPlantingOrderRepository,
|
||||
private readonly identityServiceClient: IdentityServiceClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 监听 KYCVerified 事件
|
||||
*/
|
||||
@EventPattern('identity.KYCVerified')
|
||||
async handleKycVerified(@Payload() message: KYCVerifiedEventMessage): Promise<void> {
|
||||
const userId = message.payload?.userId || message.aggregateId;
|
||||
|
||||
this.logger.log(`[KYC-VERIFIED] Received KYCVerified event for user: ${userId}`);
|
||||
|
||||
try {
|
||||
// 1. 获取用户的 accountSequence(从 identity-service)
|
||||
const userDetail = await this.identityServiceClient.getUserDetailBySequence(userId);
|
||||
if (!userDetail) {
|
||||
// userId 可能是数字 ID,尝试查找
|
||||
this.logger.warn(`[KYC-VERIFIED] Could not find user detail for: ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountSequence = userDetail.accountSequence;
|
||||
const userIdBigint = BigInt(userDetail.userId);
|
||||
|
||||
this.logger.log(
|
||||
`[KYC-VERIFIED] User ${accountSequence} completed KYC, checking for pending contracts...`,
|
||||
);
|
||||
|
||||
// 2. 获取用户 KYC 信息
|
||||
const kycInfo = await this.identityServiceClient.getUserKycInfo(accountSequence);
|
||||
if (!kycInfo) {
|
||||
this.logger.warn(`[KYC-VERIFIED] KYC info not available for user: ${accountSequence}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 查找用户已支付但未创建合同的订单
|
||||
const ordersWithoutContract = await this.orderRepo.findPaidOrdersWithoutContract(userIdBigint);
|
||||
|
||||
if (ordersWithoutContract.length === 0) {
|
||||
this.logger.log(`[KYC-VERIFIED] No pending orders for user: ${accountSequence}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[KYC-VERIFIED] Found ${ordersWithoutContract.length} orders without contract for user: ${accountSequence}`,
|
||||
);
|
||||
|
||||
// 4. 为每个订单创建合同签署任务
|
||||
for (const order of ordersWithoutContract) {
|
||||
try {
|
||||
const provinceCitySelection = order.provinceCitySelection;
|
||||
if (!provinceCitySelection) {
|
||||
this.logger.warn(
|
||||
`[KYC-VERIFIED] Order ${order.orderNo} has no province/city selection, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.contractSigningService.createSigningTask({
|
||||
orderNo: order.orderNo,
|
||||
userId: order.userId,
|
||||
accountSequence,
|
||||
treeCount: order.treeCount.value,
|
||||
totalAmount: order.totalAmount,
|
||||
provinceCode: provinceCitySelection.provinceCode,
|
||||
provinceName: provinceCitySelection.provinceName,
|
||||
cityCode: provinceCitySelection.cityCode,
|
||||
cityName: provinceCitySelection.cityName,
|
||||
userPhoneNumber: kycInfo.phoneNumber,
|
||||
userRealName: kycInfo.realName,
|
||||
userIdCardNumber: kycInfo.idCardNumber,
|
||||
});
|
||||
|
||||
this.logger.log(`[KYC-VERIFIED] Created contract for order: ${order.orderNo}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[KYC-VERIFIED] Failed to create contract for order ${order.orderNo}:`,
|
||||
error,
|
||||
);
|
||||
// 继续处理下一个订单
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[KYC-VERIFIED] Completed processing KYCVerified event for user: ${accountSequence}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`[KYC-VERIFIED] Error processing KYCVerified event for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,27 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { PDFDocument, PDFFont, rgb } from 'pdf-lib';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fontkit = require('@pdf-lib/fontkit');
|
||||
|
||||
/**
|
||||
* 表单字段名称常量
|
||||
* 在 PDF 模板中创建这些命名的文本字段
|
||||
*/
|
||||
const FORM_FIELDS = {
|
||||
CONTRACT_NO: 'contractNo',
|
||||
USER_NAME: 'userName',
|
||||
USER_ID_CARD: 'userIdCard',
|
||||
USER_PHONE: 'userPhone',
|
||||
TREE_COUNT: 'treeCount',
|
||||
SIGN_YEAR: 'signYear',
|
||||
SIGN_MONTH: 'signMonth',
|
||||
SIGN_DAY: 'signDay',
|
||||
SIGNATURE: 'signature', // 签名图片按钮字段
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 合同 PDF 生成数据
|
||||
*/
|
||||
|
|
@ -28,9 +44,9 @@ export interface SignatureData {
|
|||
/**
|
||||
* PDF 生成服务
|
||||
*
|
||||
* 使用 pdf-lib 直接操作 PDF:
|
||||
* 1. 加载 PDF 模板
|
||||
* 2. 在指定坐标位置填充文字
|
||||
* 使用 pdf-lib 操作 PDF:
|
||||
* 1. 优先使用 AcroForm 表单字段填充(更可靠)
|
||||
* 2. 如果模板没有表单字段,回退到坐标定位方式
|
||||
* 3. 嵌入用户签名图片
|
||||
* 4. 输出最终 PDF
|
||||
*/
|
||||
|
|
@ -51,6 +67,19 @@ export class PdfGeneratorService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 PDF 是否包含表单字段
|
||||
*/
|
||||
private hasFormFields(pdfDoc: PDFDocument): boolean {
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
const fields = form.getFields();
|
||||
return fields.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成合同 PDF(填充用户信息,不含签名)
|
||||
* @param data 合同数据
|
||||
|
|
@ -71,89 +100,16 @@ export class PdfGeneratorService {
|
|||
const fontBytes = fs.readFileSync(this.fontPath);
|
||||
const customFont = await pdfDoc.embedFont(fontBytes);
|
||||
|
||||
// 4. 获取页面
|
||||
const pages = pdfDoc.getPages();
|
||||
const page1 = pages[0]; // 第1页:协议编号
|
||||
const page2 = pages[1]; // 第2页:乙方信息
|
||||
const page3 = pages[2]; // 第3页:种植期限、认种数量
|
||||
const page6 = pages[5]; // 第6页:签名区域
|
||||
// 4. 选择填充方式:优先使用表单字段,否则使用坐标定位
|
||||
if (this.hasFormFields(pdfDoc)) {
|
||||
this.logger.log('Using form fields mode');
|
||||
await this.fillFormFields(pdfDoc, data, customFont);
|
||||
} else {
|
||||
this.logger.log('Using coordinate mode (no form fields found)');
|
||||
this.fillByCoordinates(pdfDoc, data, customFont);
|
||||
}
|
||||
|
||||
const fontSize = 12;
|
||||
const textColor = rgb(0, 0, 0);
|
||||
|
||||
// 5. 填充第1页 - 协议编号(使用合同编号)
|
||||
// "协议编号:" 后面的位置(根据 PDF 布局调整坐标)
|
||||
page1.drawText(data.contractNo, {
|
||||
x: 390,
|
||||
y: 95,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 6. 填充第2页 - 乙方信息
|
||||
// 姓名/名称:
|
||||
page2.drawText(data.userRealName || '未认证', {
|
||||
x: 155,
|
||||
y: 583,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 身份证号:
|
||||
page2.drawText(this.maskIdCard(data.userIdCard), {
|
||||
x: 130,
|
||||
y: 557,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 联系方式:
|
||||
page2.drawText(this.maskPhone(data.userPhone), {
|
||||
x: 130,
|
||||
y: 531,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 7. 填充第3页 - 认种数量
|
||||
// 乙方认种 __ 棵榴莲树苗
|
||||
page3.drawText(data.treeCount.toString(), {
|
||||
x: 138,
|
||||
y: 477,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 8. 填充第6页 - 签约日期
|
||||
const [year, month, day] = data.signingDate.split('-');
|
||||
page6.drawText(year, {
|
||||
x: 290,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(month, {
|
||||
x: 345,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(day, {
|
||||
x: 385,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 9. 保存 PDF
|
||||
// 5. 保存 PDF
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
||||
this.logger.log(`PDF generated successfully for contract: ${data.contractNo}`);
|
||||
|
|
@ -167,6 +123,130 @@ export class PdfGeneratorService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用表单字段填充 PDF(推荐方式,更可靠)
|
||||
* 需要 PDF 模板中预先创建命名的文本字段
|
||||
*/
|
||||
private async fillFormFields(
|
||||
pdfDoc: PDFDocument,
|
||||
data: ContractPdfData,
|
||||
font: PDFFont,
|
||||
): Promise<void> {
|
||||
const form = pdfDoc.getForm();
|
||||
const [year, month, day] = data.signingDate.split('-');
|
||||
|
||||
// 填充各个字段
|
||||
const fieldMappings: Array<{ name: string; value: string }> = [
|
||||
{ name: FORM_FIELDS.CONTRACT_NO, value: data.contractNo },
|
||||
{ name: FORM_FIELDS.USER_NAME, value: data.userRealName || '未认证' },
|
||||
{ name: FORM_FIELDS.USER_ID_CARD, value: this.maskIdCard(data.userIdCard) },
|
||||
{ name: FORM_FIELDS.USER_PHONE, value: this.maskPhone(data.userPhone) },
|
||||
{ name: FORM_FIELDS.TREE_COUNT, value: data.treeCount.toString() },
|
||||
{ name: FORM_FIELDS.SIGN_YEAR, value: year },
|
||||
{ name: FORM_FIELDS.SIGN_MONTH, value: month },
|
||||
{ name: FORM_FIELDS.SIGN_DAY, value: day },
|
||||
];
|
||||
|
||||
for (const { name, value } of fieldMappings) {
|
||||
try {
|
||||
const field = form.getTextField(name);
|
||||
field.setText(value);
|
||||
field.updateAppearances(font);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Form field '${name}' not found, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
// 扁平化表单(将字段转换为静态文本,不可再编辑)
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用坐标定位填充 PDF(后备方式)
|
||||
* 坐标需要根据实际 PDF 布局调整
|
||||
*/
|
||||
private fillByCoordinates(
|
||||
pdfDoc: PDFDocument,
|
||||
data: ContractPdfData,
|
||||
customFont: PDFFont,
|
||||
): void {
|
||||
const pages = pdfDoc.getPages();
|
||||
const page1 = pages[0]; // 第1页:协议编号
|
||||
const page2 = pages[1]; // 第2页:乙方信息
|
||||
const page3 = pages[2]; // 第3页:种植期限、认种数量
|
||||
const page6 = pages[5]; // 第6页:签名区域
|
||||
|
||||
const fontSize = 12;
|
||||
const textColor = rgb(0, 0, 0);
|
||||
|
||||
// 填充第1页 - 协议编号(使用合同编号)
|
||||
page1.drawText(data.contractNo, {
|
||||
x: 390,
|
||||
y: 95,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第2页 - 乙方信息
|
||||
page2.drawText(data.userRealName || '未认证', {
|
||||
x: 155,
|
||||
y: 583,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
page2.drawText(this.maskIdCard(data.userIdCard), {
|
||||
x: 130,
|
||||
y: 557,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
page2.drawText(this.maskPhone(data.userPhone), {
|
||||
x: 130,
|
||||
y: 531,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第3页 - 认种数量
|
||||
page3.drawText(data.treeCount.toString(), {
|
||||
x: 138,
|
||||
y: 477,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第6页 - 签约日期
|
||||
const [year, month, day] = data.signingDate.split('-');
|
||||
page6.drawText(year, {
|
||||
x: 290,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(month, {
|
||||
x: 345,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(day, {
|
||||
x: 385,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已生成的 PDF 上嵌入签名
|
||||
* @param pdfBuffer 原始 PDF Buffer
|
||||
|
|
@ -181,36 +261,18 @@ export class PdfGeneratorService {
|
|||
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
const pages = pdfDoc.getPages();
|
||||
const page6 = pages[5]; // 第6页:签名区域
|
||||
|
||||
// 嵌入签名图片
|
||||
const signatureImage = await pdfDoc.embedPng(signature.signatureImagePng);
|
||||
|
||||
// 计算签名图片的尺寸(保持宽高比,最大宽度150,最大高度60)
|
||||
const maxWidth = 150;
|
||||
const maxHeight = 60;
|
||||
const { width, height } = signatureImage.scale(1);
|
||||
let scaledWidth = width;
|
||||
let scaledHeight = height;
|
||||
|
||||
if (width > maxWidth) {
|
||||
scaledWidth = maxWidth;
|
||||
scaledHeight = (height * maxWidth) / width;
|
||||
// 尝试使用表单字段放置签名
|
||||
if (await this.tryEmbedSignatureByFormField(pdfDoc, signatureImage)) {
|
||||
this.logger.log('Signature embedded using form field');
|
||||
} else {
|
||||
// 回退到坐标方式
|
||||
this.logger.log('Signature embedded using coordinates (no form field found)');
|
||||
this.embedSignatureByCoordinates(pdfDoc, signatureImage);
|
||||
}
|
||||
if (scaledHeight > maxHeight) {
|
||||
scaledHeight = maxHeight;
|
||||
scaledWidth = (width * maxHeight) / height;
|
||||
}
|
||||
|
||||
// 在"乙方(签字/盖章):"下方绘制签名
|
||||
// 坐标需要根据实际 PDF 布局调整
|
||||
page6.drawImage(signatureImage, {
|
||||
x: 350,
|
||||
y: 180,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
});
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
this.logger.log('Signature embedded successfully');
|
||||
|
|
@ -221,6 +283,60 @@ export class PdfGeneratorService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用表单字段放置签名图片
|
||||
* @returns 是否成功使用表单字段
|
||||
*/
|
||||
private async tryEmbedSignatureByFormField(
|
||||
pdfDoc: PDFDocument,
|
||||
signatureImage: Awaited<ReturnType<PDFDocument['embedPng']>>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
const signatureButton = form.getButton(FORM_FIELDS.SIGNATURE);
|
||||
signatureButton.setImage(signatureImage);
|
||||
form.flatten();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用坐标方式放置签名图片
|
||||
*/
|
||||
private embedSignatureByCoordinates(
|
||||
pdfDoc: PDFDocument,
|
||||
signatureImage: Awaited<ReturnType<PDFDocument['embedPng']>>,
|
||||
): void {
|
||||
const pages = pdfDoc.getPages();
|
||||
const page6 = pages[5]; // 第6页:签名区域
|
||||
|
||||
// 计算签名图片的尺寸(保持宽高比,最大宽度150,最大高度60)
|
||||
const maxWidth = 150;
|
||||
const maxHeight = 60;
|
||||
const { width, height } = signatureImage.scale(1);
|
||||
let scaledWidth = width;
|
||||
let scaledHeight = height;
|
||||
|
||||
if (width > maxWidth) {
|
||||
scaledWidth = maxWidth;
|
||||
scaledHeight = (height * maxWidth) / width;
|
||||
}
|
||||
if (scaledHeight > maxHeight) {
|
||||
scaledHeight = maxHeight;
|
||||
scaledWidth = (width * maxHeight) / height;
|
||||
}
|
||||
|
||||
// 在"乙方(签字/盖章):"下方绘制签名
|
||||
page6.drawImage(signatureImage, {
|
||||
x: 350,
|
||||
y: 180,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带签名的完整合同 PDF
|
||||
* @param data 合同数据
|
||||
|
|
|
|||
|
|
@ -189,4 +189,38 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findPaidOrdersWithoutContract(userId: bigint): Promise<PlantingOrder[]> {
|
||||
// 已支付及之后状态(不包括 CREATED, PROVINCE_CITY_CONFIRMED, CANCELLED)
|
||||
const paidStatuses = [
|
||||
PlantingOrderStatus.PAID,
|
||||
PlantingOrderStatus.FUND_ALLOCATED,
|
||||
PlantingOrderStatus.POOL_SCHEDULED,
|
||||
PlantingOrderStatus.POOL_INJECTED,
|
||||
PlantingOrderStatus.MINING_ENABLED,
|
||||
];
|
||||
|
||||
// 查找已支付但没有对应合同签署任务的订单
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: paidStatuses },
|
||||
// 使用子查询排除已有合同的订单
|
||||
NOT: {
|
||||
orderNo: {
|
||||
in: await this.prisma.contractSigningTask
|
||||
.findMany({
|
||||
where: { userId },
|
||||
select: { orderNo: true },
|
||||
})
|
||||
.then((tasks) => tasks.map((t) => t.orderNo)),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,9 +75,24 @@ async function bootstrap() {
|
|||
},
|
||||
});
|
||||
|
||||
// 微服务 3: 用于监听 identity 服务的 KYC 事件
|
||||
// 当用户完成实名认证后,为其补创建之前已支付订单的合同
|
||||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.KAFKA,
|
||||
options: {
|
||||
client: {
|
||||
clientId: 'planting-service-identity-events',
|
||||
brokers: kafkaBrokers,
|
||||
},
|
||||
consumer: {
|
||||
groupId: `${kafkaGroupId}-identity-events`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 启动所有 Kafka 微服务
|
||||
await app.startAllMicroservices();
|
||||
logger.log('Kafka microservices started (ACK + Contract Signing)');
|
||||
logger.log('Kafka microservices started (ACK + Contract Signing + Identity Events)');
|
||||
|
||||
const port = process.env.APP_PORT || 3003;
|
||||
await app.listen(port);
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue