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:
hailin 2025-12-25 05:35:40 -08:00
parent 0e93d2a343
commit 929ae335c5
10 changed files with 642 additions and 124 deletions

View File

@ -41,6 +41,7 @@ describe('PlantingApplicationService (Integration)', () => {
findReadyForMining: jest.fn(),
countTreesByUserId: jest.fn(),
countByUserId: jest.fn(),
findPaidOrdersWithoutContract: jest.fn(),
};
positionRepository = {

View File

@ -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');

View 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,
};
}
}

View File

@ -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 {}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);