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 0c369945..3fa5219b 100644 Binary files a/backend/services/planting-service/templates/contract-template.pdf and b/backend/services/planting-service/templates/contract-template.pdf differ