From 929ae335c552d529fc8b882343be90688a459712 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 25 Dec 2025 05:35:40 -0800 Subject: [PATCH] =?UTF-8?q?feat(contract):=20=E5=A2=9E=E5=BC=BA=E5=90=88?= =?UTF-8?q?=E5=90=8C=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 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 --- ...ng-application.service.integration.spec.ts | 1 + .../planting-order.repository.interface.ts | 5 + .../external/identity-service.client.ts | 196 ++++++++++ .../infrastructure/infrastructure.module.ts | 6 +- .../kafka/contract-signing-event.consumer.ts | 36 +- .../kafka/kyc-verified-event.consumer.ts | 133 +++++++ .../pdf/pdf-generator.service.ts | 338 ++++++++++++------ .../planting-order.repository.impl.ts | 34 ++ backend/services/planting-service/src/main.ts | 17 +- .../templates/contract-template.pdf | Bin 172775 -> 165674 bytes 10 files changed, 642 insertions(+), 124 deletions(-) create mode 100644 backend/services/planting-service/src/infrastructure/external/identity-service.client.ts create mode 100644 backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts diff --git a/backend/services/planting-service/src/application/services/planting-application.service.integration.spec.ts b/backend/services/planting-service/src/application/services/planting-application.service.integration.spec.ts index 995ea4bf..9e59ace9 100644 --- a/backend/services/planting-service/src/application/services/planting-application.service.integration.spec.ts +++ b/backend/services/planting-service/src/application/services/planting-application.service.integration.spec.ts @@ -41,6 +41,7 @@ describe('PlantingApplicationService (Integration)', () => { findReadyForMining: jest.fn(), countTreesByUserId: jest.fn(), countByUserId: jest.fn(), + findPaidOrdersWithoutContract: jest.fn(), }; positionRepository = { diff --git a/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts b/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts index d25fd132..5a0625d7 100644 --- a/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts +++ b/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts @@ -19,6 +19,11 @@ export interface IPlantingOrderRepository { findReadyForMining(): Promise; countTreesByUserId(userId: bigint): Promise; countByUserId(userId: bigint): Promise; + /** + * 查找用户已支付(PAID 及之后状态)但未创建合同的订单 + * 用于用户完成 KYC 后补创建合同 + */ + findPaidOrdersWithoutContract(userId: bigint): Promise; } export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository'); diff --git a/backend/services/planting-service/src/infrastructure/external/identity-service.client.ts b/backend/services/planting-service/src/infrastructure/external/identity-service.client.ts new file mode 100644 index 00000000..5d3c0fb5 --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/external/identity-service.client.ts @@ -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('IDENTITY_SERVICE_URL') || + 'http://localhost:3001'; + this.retryConfig = DEFAULT_RETRY_CONFIG; + } + + /** + * 带重试的 HTTP 请求包装器 + */ + private async withRetry( + operation: string, + fn: () => Promise, + config: RetryConfig = this.retryConfig, + ): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * 根据 accountSequence 获取用户详情 + */ + async getUserDetailBySequence(accountSequence: string): Promise { + try { + return await this.withRetry( + `getUserDetailBySequence(${accountSequence})`, + async () => { + const response = await firstValueFrom( + this.httpService.get( + `${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 { + 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, + }; + } +} diff --git a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts index 959a0d9e..a9abe9d2 100644 --- a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/backend/services/planting-service/src/infrastructure/kafka/contract-signing-event.consumer.ts b/backend/services/planting-service/src/infrastructure/kafka/contract-signing-event.consumer.ts index 3ddc7487..0955752e 100644 --- a/backend/services/planting-service/src/infrastructure/kafka/contract-signing-event.consumer.ts +++ b/backend/services/planting-service/src/infrastructure/kafka/contract-signing-event.consumer.ts @@ -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 { - // TODO: 调用 identity-service 获取用户 KYC 信息 - // 目前返回 null,合同中会显示"未认证" - this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for user: ${userId}`); - return null; + private async getUserKycInfo(accountSequence: string): Promise { + 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; + } } } diff --git a/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts b/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts new file mode 100644 index 00000000..ec3ca6af --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts @@ -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 { + 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); + } + } +} diff --git a/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts b/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts index 43d6d655..17bebd24 100644 --- a/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts +++ b/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts @@ -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 { + 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>, + ): Promise { + 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>, + ): 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 合同数据 diff --git a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts index 51c17350..72ef057e 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts @@ -189,4 +189,38 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository { }, }); } + + async findPaidOrdersWithoutContract(userId: bigint): Promise { + // 已支付及之后状态(不包括 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); + } } diff --git a/backend/services/planting-service/src/main.ts b/backend/services/planting-service/src/main.ts index 1327bf09..98fe936a 100644 --- a/backend/services/planting-service/src/main.ts +++ b/backend/services/planting-service/src/main.ts @@ -75,9 +75,24 @@ async function bootstrap() { }, }); + // 微服务 3: 用于监听 identity 服务的 KYC 事件 + // 当用户完成实名认证后,为其补创建之前已支付订单的合同 + app.connectMicroservice({ + 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); diff --git a/backend/services/planting-service/templates/contract-template.pdf b/backend/services/planting-service/templates/contract-template.pdf index 0c369945bdc43518e1fdc8c109cd31320db347d8..3fa5219bf4ad57098df4a0563a65566ed3b3c269 100644 GIT binary patch delta 12335 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 G;Qs)L@*TJU delta 19359 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