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(),
|
findReadyForMining: jest.fn(),
|
||||||
countTreesByUserId: jest.fn(),
|
countTreesByUserId: jest.fn(),
|
||||||
countByUserId: jest.fn(),
|
countByUserId: jest.fn(),
|
||||||
|
findPaidOrdersWithoutContract: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
positionRepository = {
|
positionRepository = {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ export interface IPlantingOrderRepository {
|
||||||
findReadyForMining(): Promise<PlantingOrder[]>;
|
findReadyForMining(): Promise<PlantingOrder[]>;
|
||||||
countTreesByUserId(userId: bigint): Promise<number>;
|
countTreesByUserId(userId: bigint): Promise<number>;
|
||||||
countByUserId(userId: bigint): Promise<number>;
|
countByUserId(userId: bigint): Promise<number>;
|
||||||
|
/**
|
||||||
|
* 查找用户已支付(PAID 及之后状态)但未创建合同的订单
|
||||||
|
* 用于用户完成 KYC 后补创建合同
|
||||||
|
*/
|
||||||
|
findPaidOrdersWithoutContract(userId: bigint): Promise<PlantingOrder[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');
|
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 { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
|
||||||
import { WalletServiceClient } from './external/wallet-service.client';
|
import { WalletServiceClient } from './external/wallet-service.client';
|
||||||
import { ReferralServiceClient } from './external/referral-service.client';
|
import { ReferralServiceClient } from './external/referral-service.client';
|
||||||
|
import { IdentityServiceClient } from './external/identity-service.client';
|
||||||
import { KafkaModule } from './kafka/kafka.module';
|
import { KafkaModule } from './kafka/kafka.module';
|
||||||
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
||||||
import { EventAckController } from './kafka/event-ack.controller';
|
import { EventAckController } from './kafka/event-ack.controller';
|
||||||
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
||||||
|
import { KycVerifiedEventConsumer } from './kafka/kyc-verified-event.consumer';
|
||||||
import { PdfGeneratorService } from './pdf/pdf-generator.service';
|
import { PdfGeneratorService } from './pdf/pdf-generator.service';
|
||||||
import { MinioStorageService } from './storage/minio-storage.service';
|
import { MinioStorageService } from './storage/minio-storage.service';
|
||||||
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
||||||
|
|
@ -34,7 +36,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
||||||
}),
|
}),
|
||||||
KafkaModule,
|
KafkaModule,
|
||||||
],
|
],
|
||||||
controllers: [EventAckController, ContractSigningEventConsumer],
|
controllers: [EventAckController, ContractSigningEventConsumer, KycVerifiedEventConsumer],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
{
|
{
|
||||||
|
|
@ -70,6 +72,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
||||||
MinioStorageService,
|
MinioStorageService,
|
||||||
WalletServiceClient,
|
WalletServiceClient,
|
||||||
ReferralServiceClient,
|
ReferralServiceClient,
|
||||||
|
IdentityServiceClient,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -88,6 +91,7 @@ import { ContractSigningService } from '../application/services/contract-signing
|
||||||
MinioStorageService,
|
MinioStorageService,
|
||||||
WalletServiceClient,
|
WalletServiceClient,
|
||||||
ReferralServiceClient,
|
ReferralServiceClient,
|
||||||
|
IdentityServiceClient,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
IPlantingOrderRepository,
|
IPlantingOrderRepository,
|
||||||
PLANTING_ORDER_REPOSITORY,
|
PLANTING_ORDER_REPOSITORY,
|
||||||
} from '../../domain/repositories';
|
} from '../../domain/repositories';
|
||||||
|
import { IdentityServiceClient } from '../external/identity-service.client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认种事件消息结构
|
* 认种事件消息结构
|
||||||
|
|
@ -48,6 +49,7 @@ export class ContractSigningEventConsumer {
|
||||||
private readonly contractSigningService: ContractSigningService,
|
private readonly contractSigningService: ContractSigningService,
|
||||||
@Inject(PLANTING_ORDER_REPOSITORY)
|
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||||
private readonly orderRepo: IPlantingOrderRepository,
|
private readonly orderRepo: IPlantingOrderRepository,
|
||||||
|
private readonly identityServiceClient: IdentityServiceClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,10 +99,16 @@ export class ContractSigningEventConsumer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取用户 KYC 信息
|
// 2. 获取用户 KYC 信息(只有实名认证通过的用户才能创建合同)
|
||||||
// TODO: 调用 identity-service 获取用户信息
|
const accountSequence = data.accountSequence || order.userId.toString();
|
||||||
// 目前使用占位数据
|
const kycInfo = await this.getUserKycInfo(accountSequence);
|
||||||
const kycInfo = await this.getUserKycInfo(order.userId);
|
|
||||||
|
if (!kycInfo) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[CONTRACT-SIGNING] User ${accountSequence} has not completed KYC verification. Skipping contract creation for order: ${orderNo}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 创建合同签署任务
|
// 3. 创建合同签署任务
|
||||||
const provinceCitySelection = order.provinceCitySelection;
|
const provinceCitySelection = order.provinceCitySelection;
|
||||||
|
|
@ -112,7 +120,7 @@ export class ContractSigningEventConsumer {
|
||||||
await this.contractSigningService.createSigningTask({
|
await this.contractSigningService.createSigningTask({
|
||||||
orderNo,
|
orderNo,
|
||||||
userId: order.userId,
|
userId: order.userId,
|
||||||
accountSequence: data.accountSequence || order.userId.toString(),
|
accountSequence,
|
||||||
treeCount: order.treeCount.value,
|
treeCount: order.treeCount.value,
|
||||||
totalAmount: order.totalAmount,
|
totalAmount: order.totalAmount,
|
||||||
provinceCode: provinceCitySelection.provinceCode,
|
provinceCode: provinceCitySelection.provinceCode,
|
||||||
|
|
@ -133,12 +141,18 @@ export class ContractSigningEventConsumer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户 KYC 信息
|
* 获取用户 KYC 信息
|
||||||
* TODO: 实际实现应调用 identity-service
|
|
||||||
*/
|
*/
|
||||||
private async getUserKycInfo(userId: bigint): Promise<UserKycInfo | null> {
|
private async getUserKycInfo(accountSequence: string): Promise<UserKycInfo | null> {
|
||||||
// TODO: 调用 identity-service 获取用户 KYC 信息
|
this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for accountSequence: ${accountSequence}`);
|
||||||
// 目前返回 null,合同中会显示"未认证"
|
try {
|
||||||
this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for user: ${userId}`);
|
const kycInfo = await this.identityServiceClient.getUserKycInfo(accountSequence);
|
||||||
return null;
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const fontkit = require('@pdf-lib/fontkit');
|
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 生成数据
|
* 合同 PDF 生成数据
|
||||||
*/
|
*/
|
||||||
|
|
@ -28,9 +44,9 @@ export interface SignatureData {
|
||||||
/**
|
/**
|
||||||
* PDF 生成服务
|
* PDF 生成服务
|
||||||
*
|
*
|
||||||
* 使用 pdf-lib 直接操作 PDF:
|
* 使用 pdf-lib 操作 PDF:
|
||||||
* 1. 加载 PDF 模板
|
* 1. 优先使用 AcroForm 表单字段填充(更可靠)
|
||||||
* 2. 在指定坐标位置填充文字
|
* 2. 如果模板没有表单字段,回退到坐标定位方式
|
||||||
* 3. 嵌入用户签名图片
|
* 3. 嵌入用户签名图片
|
||||||
* 4. 输出最终 PDF
|
* 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(填充用户信息,不含签名)
|
* 生成合同 PDF(填充用户信息,不含签名)
|
||||||
* @param data 合同数据
|
* @param data 合同数据
|
||||||
|
|
@ -71,89 +100,16 @@ export class PdfGeneratorService {
|
||||||
const fontBytes = fs.readFileSync(this.fontPath);
|
const fontBytes = fs.readFileSync(this.fontPath);
|
||||||
const customFont = await pdfDoc.embedFont(fontBytes);
|
const customFont = await pdfDoc.embedFont(fontBytes);
|
||||||
|
|
||||||
// 4. 获取页面
|
// 4. 选择填充方式:优先使用表单字段,否则使用坐标定位
|
||||||
const pages = pdfDoc.getPages();
|
if (this.hasFormFields(pdfDoc)) {
|
||||||
const page1 = pages[0]; // 第1页:协议编号
|
this.logger.log('Using form fields mode');
|
||||||
const page2 = pages[1]; // 第2页:乙方信息
|
await this.fillFormFields(pdfDoc, data, customFont);
|
||||||
const page3 = pages[2]; // 第3页:种植期限、认种数量
|
} else {
|
||||||
const page6 = pages[5]; // 第6页:签名区域
|
this.logger.log('Using coordinate mode (no form fields found)');
|
||||||
|
this.fillByCoordinates(pdfDoc, data, customFont);
|
||||||
|
}
|
||||||
|
|
||||||
const fontSize = 12;
|
// 5. 保存 PDF
|
||||||
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
|
|
||||||
const pdfBytes = await pdfDoc.save();
|
const pdfBytes = await pdfDoc.save();
|
||||||
|
|
||||||
this.logger.log(`PDF generated successfully for contract: ${data.contractNo}`);
|
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 上嵌入签名
|
* 在已生成的 PDF 上嵌入签名
|
||||||
* @param pdfBuffer 原始 PDF Buffer
|
* @param pdfBuffer 原始 PDF Buffer
|
||||||
|
|
@ -181,36 +261,18 @@ export class PdfGeneratorService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||||
const pages = pdfDoc.getPages();
|
|
||||||
const page6 = pages[5]; // 第6页:签名区域
|
|
||||||
|
|
||||||
// 嵌入签名图片
|
// 嵌入签名图片
|
||||||
const signatureImage = await pdfDoc.embedPng(signature.signatureImagePng);
|
const signatureImage = await pdfDoc.embedPng(signature.signatureImagePng);
|
||||||
|
|
||||||
// 计算签名图片的尺寸(保持宽高比,最大宽度150,最大高度60)
|
// 尝试使用表单字段放置签名
|
||||||
const maxWidth = 150;
|
if (await this.tryEmbedSignatureByFormField(pdfDoc, signatureImage)) {
|
||||||
const maxHeight = 60;
|
this.logger.log('Signature embedded using form field');
|
||||||
const { width, height } = signatureImage.scale(1);
|
} else {
|
||||||
let scaledWidth = width;
|
// 回退到坐标方式
|
||||||
let scaledHeight = height;
|
this.logger.log('Signature embedded using coordinates (no form field found)');
|
||||||
|
this.embedSignatureByCoordinates(pdfDoc, signatureImage);
|
||||||
if (width > maxWidth) {
|
|
||||||
scaledWidth = maxWidth;
|
|
||||||
scaledHeight = (height * maxWidth) / width;
|
|
||||||
}
|
}
|
||||||
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();
|
const pdfBytes = await pdfDoc.save();
|
||||||
this.logger.log('Signature embedded successfully');
|
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
|
* 生成带签名的完整合同 PDF
|
||||||
* @param data 合同数据
|
* @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 微服务
|
// 启动所有 Kafka 微服务
|
||||||
await app.startAllMicroservices();
|
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;
|
const port = process.env.APP_PORT || 3003;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue