feat(contract-signing): 添加电子合同签署功能及单点配置优化

- planting-service: 添加合同签署任务管理和超时检测
  - 新增 ContractSigningTask 领域模型
  - 添加 24 小时合同签署超时定时任务
  - 支付后资金保持冻结,由 referral-service 统一确认扣款

- referral-service: 单点配置 CONTRACT_SIGNING_ENABLED
  - 新增 ContractSigningHandler 处理合同签署/超时事件
  - 新增 WalletServiceClient 调用钱包服务确认扣款
  - planting.created 处理后根据配置决定是否等待合同签署

- reward-service: 移除 CONTRACT_SIGNING_ENABLED 配置
  - 扣款确认由 referral-service 在发送事件前完成
  - 保持奖励分配逻辑不变

配置说明:
- CONTRACT_SIGNING_ENABLED=true (默认): 等待合同签署后分配奖励
- CONTRACT_SIGNING_ENABLED=false: 立即分配奖励(原有流程)

🤖 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-24 20:12:12 -08:00
parent 1b3d545c0d
commit 714ce42e4f
40 changed files with 3266 additions and 47 deletions

View File

@ -71,6 +71,9 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"jest": {
"moduleFileExtensions": [
"js",

View File

@ -0,0 +1,74 @@
-- CreateTable
CREATE TABLE "contract_templates" (
"template_id" SERIAL NOT NULL,
"version" VARCHAR(20) NOT NULL,
"title" VARCHAR(200) NOT NULL,
"content" TEXT NOT NULL,
"effective_from" TIMESTAMP(3) NOT NULL,
"effective_to" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "contract_templates_pkey" PRIMARY KEY ("template_id")
);
-- CreateTable
CREATE TABLE "contract_signing_tasks" (
"task_id" BIGSERIAL NOT NULL,
"order_no" VARCHAR(50) NOT NULL,
"user_id" BIGINT NOT NULL,
"account_sequence" VARCHAR(20) NOT NULL,
"template_id" INTEGER NOT NULL,
"contract_version" VARCHAR(20) NOT NULL,
"contract_content" TEXT NOT NULL,
"user_phone_number" VARCHAR(20),
"user_real_name" VARCHAR(50),
"user_id_card_number" VARCHAR(50),
"tree_count" INTEGER NOT NULL,
"total_amount" DECIMAL(20,8) NOT NULL,
"province_code" VARCHAR(10) NOT NULL,
"province_name" VARCHAR(50) NOT NULL,
"city_code" VARCHAR(10) NOT NULL,
"city_name" VARCHAR(50) NOT NULL,
"status" VARCHAR(30) NOT NULL DEFAULT 'PENDING',
"expires_at" TIMESTAMP(3) NOT NULL,
"scrolled_to_bottom_at" TIMESTAMP(3),
"acknowledged_at" TIMESTAMP(3),
"signed_at" TIMESTAMP(3),
"signature_cloud_url" VARCHAR(500),
"signature_hash" VARCHAR(64),
"signing_ip_address" VARCHAR(50),
"signing_device_info" TEXT,
"signing_user_agent" VARCHAR(500),
"signing_latitude" DECIMAL(10,8),
"signing_longitude" DECIMAL(11,8),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "contract_signing_tasks_pkey" PRIMARY KEY ("task_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "contract_templates_version_key" ON "contract_templates"("version");
-- CreateIndex
CREATE INDEX "contract_templates_is_active_effective_from_idx" ON "contract_templates"("is_active", "effective_from");
-- CreateIndex
CREATE UNIQUE INDEX "contract_signing_tasks_order_no_key" ON "contract_signing_tasks"("order_no");
-- CreateIndex
CREATE INDEX "contract_signing_tasks_user_id_idx" ON "contract_signing_tasks"("user_id");
-- CreateIndex
CREATE INDEX "contract_signing_tasks_status_idx" ON "contract_signing_tasks"("status");
-- CreateIndex
CREATE INDEX "contract_signing_tasks_expires_at_idx" ON "contract_signing_tasks"("expires_at");
-- CreateIndex
CREATE INDEX "contract_signing_tasks_status_expires_at_idx" ON "contract_signing_tasks"("status", "expires_at");
-- AddForeignKey
ALTER TABLE "contract_signing_tasks" ADD CONSTRAINT "contract_signing_tasks_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "contract_templates"("template_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -277,3 +277,99 @@ model PaymentCompensation {
@@index([createdAt])
@@map("payment_compensations")
}
// ============================================
// 合同模板表
// 存储电子认种合同的模板内容,支持版本管理
// ============================================
model ContractTemplate {
id Int @id @default(autoincrement()) @map("template_id")
version String @unique @map("version") @db.VarChar(20) // 版本号: v1.0.0
// 合同内容
title String @map("title") @db.VarChar(200) // 合同标题
content String @map("content") @db.Text // 合同HTML内容包含占位符
// 生效时间
effectiveFrom DateTime @map("effective_from")
effectiveTo DateTime? @map("effective_to") // null表示永久有效
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
signingTasks ContractSigningTask[]
@@index([isActive, effectiveFrom])
@@map("contract_templates")
}
// ============================================
// 合同签署任务表
// 独立模块,不影响现有认种流程
// 通过订阅 OrderPaid 事件创建签署任务
// ============================================
model ContractSigningTask {
id BigInt @id @default(autoincrement()) @map("task_id")
// 关联信息
orderNo String @unique @map("order_no") @db.VarChar(50)
userId BigInt @map("user_id")
accountSequence String @map("account_sequence") @db.VarChar(20)
// 合同信息
templateId Int @map("template_id")
contractVersion String @map("contract_version") @db.VarChar(20)
contractContent String @map("contract_content") @db.Text // 已填入用户信息的完整合同
// 用户信息快照 (签署时填入合同的信息)
userPhoneNumber String? @map("user_phone_number") @db.VarChar(20)
userRealName String? @map("user_real_name") @db.VarChar(50)
userIdCardNumber String? @map("user_id_card_number") @db.VarChar(50) // 脱敏存储
// 订单信息快照
treeCount Int @map("tree_count")
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
provinceCode String @map("province_code") @db.VarChar(10)
provinceName String @map("province_name") @db.VarChar(50)
cityCode String @map("city_code") @db.VarChar(10)
cityName String @map("city_name") @db.VarChar(50)
// 签署状态
// PENDING: 待签署
// SCROLLED: 已滚动到底部
// ACKNOWLEDGED: 已确认法律效力
// SIGNED: 已签署完成
// UNSIGNED_TIMEOUT: 超时未签署
status String @default("PENDING") @map("status") @db.VarChar(30)
expiresAt DateTime @map("expires_at") // 24小时过期时间
// 签署进度时间戳
scrolledToBottomAt DateTime? @map("scrolled_to_bottom_at")
acknowledgedAt DateTime? @map("acknowledged_at")
signedAt DateTime? @map("signed_at")
// 签名数据
signatureCloudUrl String? @map("signature_cloud_url") @db.VarChar(500)
signatureHash String? @map("signature_hash") @db.VarChar(64) // SHA256
// 法律合规证据链
signingIpAddress String? @map("signing_ip_address") @db.VarChar(50)
signingDeviceInfo String? @map("signing_device_info") @db.Text // JSON格式
signingUserAgent String? @map("signing_user_agent") @db.VarChar(500)
signingLatitude Decimal? @map("signing_latitude") @db.Decimal(10, 8)
signingLongitude Decimal? @map("signing_longitude") @db.Decimal(11, 8)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
template ContractTemplate @relation(fields: [templateId], references: [id])
@@index([userId])
@@index([status])
@@index([expiresAt])
@@index([status, expiresAt])
@@map("contract_signing_tasks")
}

View File

@ -0,0 +1,151 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 创建初始合同模板(基于真实合同文档)
const contractTemplate = await prisma.contractTemplate.upsert({
where: { version: 'v1.0.0' },
update: {},
create: {
version: 'v1.0.0',
title: '榴莲树联合种植协议',
content: `
<div class="contract-container" style="font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; padding: 20px; line-height: 1.8;">
<h1 style="text-align: center; font-size: 24px; font-weight: bold; margin-bottom: 30px; letter-spacing: 8px;"></h1>
<div class="contract-info" style="margin-bottom: 30px; padding: 15px; background: #f9f9f9; border-radius: 8px;">
<p style="margin: 5px 0;"><strong></strong>{{ORDER_NO}}</p>
</div>
<h2 style="font-size: 16px; font-weight: bold; margin: 20px 0 15px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">/</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 10px; width: 30%;"></td>
<td style="padding: 10px;"></td>
</tr>
<tr>
<td style="padding: 10px;"></td>
<td style="padding: 10px;">313F203</td>
</tr>
<tr>
<td style="padding: 10px;"></td>
<td style="padding: 10px;"></td>
</tr>
</table>
<h2 style="font-size: 16px; font-weight: bold; margin: 20px 0 15px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">/</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 10px; width: 30%;">/</td>
<td style="padding: 10px;">{{USER_REAL_NAME}}</td>
</tr>
<tr>
<td style="padding: 10px;"></td>
<td style="padding: 10px;">{{USER_ID_CARD}}</td>
</tr>
<tr>
<td style="padding: 10px;"></td>
<td style="padding: 10px;">{{USER_PHONE}}</td>
</tr>
<tr>
<td style="padding: 10px;"></td>
<td style="padding: 10px;">{{ACCOUNT_SEQUENCE}}</td>
</tr>
</table>
<p style="text-indent: 2em; margin: 20px 0;"></p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">14000100000</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">1</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;"></p>
<p style="text-indent: 2em; margin: 10px 0;"></p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;"> <strong style="color: #e74c3c; font-size: 18px;">{{TREE_COUNT}}</strong> </p>
<p style="text-indent: 2em; margin: 10px 0;"><strong style="color: #e74c3c; font-size: 18px;">{{TOTAL_AMOUNT}} USDT</strong></p>
<p style="text-indent: 2em; margin: 10px 0;">{{PROVINCE_NAME}} {{CITY_NAME}}</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">1</p>
<p style="text-indent: 2em; margin: 10px 0;">210</p>
<p style="text-indent: 2em; margin: 10px 0;">35</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">1</p>
<p style="text-indent: 2em; margin: 10px 0;">240%</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="font-weight: bold; margin: 15px 0 5px;"></p>
<p style="text-indent: 2em; margin: 5px 0;">1</p>
<p style="text-indent: 2em; margin: 5px 0;">2</p>
<p style="text-indent: 2em; margin: 5px 0;">3</p>
<p style="text-indent: 2em; margin: 5px 0;">4</p>
<p style="text-indent: 2em; margin: 5px 0;">5</p>
<p style="font-weight: bold; margin: 15px 0 5px;"></p>
<p style="text-indent: 2em; margin: 5px 0;">120</p>
<p style="text-indent: 2em; margin: 5px 0;">2</p>
<p style="text-indent: 2em; margin: 5px 0;">3</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;"></p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;"></p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">1</p>
<p style="text-indent: 2em; margin: 10px 0;">2</p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;"></p>
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;"></h3>
<p style="text-indent: 2em; margin: 10px 0;">1</p>
<p style="text-indent: 2em; margin: 10px 0; color: #e74c3c; font-weight: bold;">2</p>
<div class="signature-section" style="margin-top: 50px; padding-top: 30px; border-top: 2px solid #333;">
<div style="display: flex; justify-content: space-between; margin-bottom: 40px;">
<div style="width: 45%;">
<p style="margin-bottom: 10px; font-weight: bold;">/</p>
<p style="margin-bottom: 5px;"></p>
<div style="width: 150px; height: 60px; border: 1px dashed #ccc; margin: 10px 0; display: flex; align-items: center; justify-content: center; color: #999;">
[]
</div>
</div>
<div style="width: 45%;">
<p style="margin-bottom: 10px; font-weight: bold;">/</p>
<p style="margin-bottom: 5px;">{{USER_REAL_NAME}}</p>
<div style="width: 200px; height: 80px; border: 1px dashed #ccc; margin: 10px 0;">
{{USER_SIGNATURE}}
</div>
</div>
</div>
<p style="text-align: center; margin-top: 30px;"><strong></strong>{{SIGNING_DATE}}</p>
<p style="text-align: center; color: #666; font-size: 12px; margin-top: 10px;">{{SIGNING_TIMESTAMP}}</p>
</div>
</div>
`.trim(),
effectiveFrom: new Date('2024-01-01'),
effectiveTo: null,
isActive: true,
},
});
console.log('Created contract template:', contractTemplate.version);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PlantingOrderController } from './controllers/planting-order.controller';
import { PlantingPositionController } from './controllers/planting-position.controller';
import { HealthController } from './controllers/health.controller';
import { ContractSigningController } from './controllers/contract-signing.controller';
import { ApplicationModule } from '../application/application.module';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ -11,6 +12,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
PlantingOrderController,
PlantingPositionController,
HealthController,
ContractSigningController,
],
providers: [JwtAuthGuard],
})

View File

@ -0,0 +1,253 @@
import {
Controller,
Get,
Post,
Param,
Body,
UseGuards,
Request,
HttpCode,
HttpStatus,
Logger,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../infrastructure/auth/jwt-auth.guard';
import { ContractSigningService } from '../../application/services/contract-signing.service';
/**
* DTO
*/
interface SignContractDto {
signatureBase64: string; // Base64编码的签名图片
signatureHash: string; // SHA256哈希
deviceInfo: {
deviceId?: string;
deviceModel?: string;
osVersion?: string;
appVersion?: string;
};
location?: {
latitude?: number;
longitude?: number;
};
}
/**
*
*
* API
*/
@Controller('planting/contract-signing')
@UseGuards(JwtAuthGuard)
export class ContractSigningController {
private readonly logger = new Logger(ContractSigningController.name);
constructor(private readonly contractSigningService: ContractSigningService) {}
/**
*
*/
@Get('pending')
async getPendingTasks(@Request() req: { user: { userId: string } }) {
const userId = BigInt(req.user.userId);
const tasks = await this.contractSigningService.getPendingTasks(userId);
return {
success: true,
data: tasks,
};
}
/**
*
* App启动时检查
*/
@Get('unsigned')
async getUnsignedTasks(@Request() req: { user: { userId: string } }) {
const userId = BigInt(req.user.userId);
const tasks = await this.contractSigningService.getUnsignedTasks(userId);
return {
success: true,
data: tasks,
};
}
/**
*
*/
@Get('tasks/:orderNo')
async getTask(
@Param('orderNo') orderNo: string,
@Request() req: { user: { userId: string } },
) {
const userId = BigInt(req.user.userId);
const task = await this.contractSigningService.getTask(orderNo, userId);
if (!task) {
return {
success: false,
message: '签署任务不存在',
};
}
return {
success: true,
data: task,
};
}
/**
*
*/
@Post('tasks/:orderNo/scroll-complete')
@HttpCode(HttpStatus.OK)
async markScrollComplete(
@Param('orderNo') orderNo: string,
@Request() req: { user: { userId: string } },
) {
const userId = BigInt(req.user.userId);
try {
await this.contractSigningService.markScrollComplete(orderNo, userId);
return {
success: true,
message: '已记录滚动到底部',
};
} catch (error) {
this.logger.error(`Failed to mark scroll complete: ${error.message}`);
return {
success: false,
message: error.message,
};
}
}
/**
*
*/
@Post('tasks/:orderNo/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledgeContract(
@Param('orderNo') orderNo: string,
@Request() req: { user: { userId: string } },
) {
const userId = BigInt(req.user.userId);
try {
await this.contractSigningService.acknowledgeContract(orderNo, userId);
return {
success: true,
message: '已确认法律效力',
};
} catch (error) {
this.logger.error(`Failed to acknowledge contract: ${error.message}`);
return {
success: false,
message: error.message,
};
}
}
/**
*
*/
@Post('tasks/:orderNo/sign')
@HttpCode(HttpStatus.OK)
async signContract(
@Param('orderNo') orderNo: string,
@Body() dto: SignContractDto,
@Request() req: { user: { userId: string }; ip: string; headers: { 'user-agent'?: string } },
) {
const userId = BigInt(req.user.userId);
const ipAddress = req.ip || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
try {
// TODO: 上传签名图片到云存储获取URL
// 目前暂时使用base64数据作为URL占位
const signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`;
await this.contractSigningService.signContract(orderNo, userId, {
signatureCloudUrl,
signatureHash: dto.signatureHash,
ipAddress,
deviceInfo: dto.deviceInfo,
userAgent,
location: dto.location,
});
return {
success: true,
message: '合同签署成功',
};
} catch (error) {
this.logger.error(`Failed to sign contract: ${error.message}`);
return {
success: false,
message: error.message,
};
}
}
/**
*
*/
@Post('tasks/:orderNo/late-sign')
@HttpCode(HttpStatus.OK)
async lateSignContract(
@Param('orderNo') orderNo: string,
@Body() dto: SignContractDto,
@Request() req: { user: { userId: string }; ip: string; headers: { 'user-agent'?: string } },
) {
const userId = BigInt(req.user.userId);
const ipAddress = req.ip || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
try {
// TODO: 上传签名图片到云存储获取URL
const signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`;
await this.contractSigningService.lateSignContract(orderNo, userId, {
signatureCloudUrl,
signatureHash: dto.signatureHash,
ipAddress,
deviceInfo: dto.deviceInfo,
userAgent,
location: dto.location,
});
return {
success: true,
message: '合同补签成功',
};
} catch (error) {
this.logger.error(`Failed to late-sign contract: ${error.message}`);
return {
success: false,
message: error.message,
};
}
}
/**
*
*/
@Get('template')
async getActiveTemplate() {
const template = await this.contractSigningService.getActiveTemplate();
if (!template) {
return {
success: false,
message: '没有可用的合同模板',
};
}
return {
success: true,
data: {
version: template.version,
title: template.title,
content: template.content,
},
};
}
}

View File

@ -1,3 +1,4 @@
export * from './planting-order.controller';
export * from './planting-position.controller';
export * from './health.controller';
export * from './contract-signing.controller';

View File

@ -1,11 +1,19 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { PlantingApplicationService } from './services/planting-application.service';
import { PoolInjectionService } from './services/pool-injection.service';
import { ContractSigningService } from './services/contract-signing.service';
import { ContractSigningTimeoutJob } from './jobs/contract-signing-timeout.job';
import { DomainModule } from '../domain/domain.module';
@Module({
imports: [DomainModule],
providers: [PlantingApplicationService, PoolInjectionService],
exports: [PlantingApplicationService, PoolInjectionService],
imports: [DomainModule, ScheduleModule.forRoot()],
providers: [
PlantingApplicationService,
PoolInjectionService,
ContractSigningService,
ContractSigningTimeoutJob,
],
exports: [PlantingApplicationService, PoolInjectionService, ContractSigningService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ContractSigningService } from '../services/contract-signing.service';
/**
*
*
* 5
* UNSIGNED_TIMEOUT
*/
@Injectable()
export class ContractSigningTimeoutJob {
private readonly logger = new Logger(ContractSigningTimeoutJob.name);
constructor(private readonly contractSigningService: ContractSigningService) {}
/**
* 5
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async handleTimeout(): Promise<void> {
this.logger.debug('[CONTRACT-TIMEOUT] Starting timeout check...');
try {
const count = await this.contractSigningService.handleExpiredTasks();
if (count > 0) {
this.logger.log(`[CONTRACT-TIMEOUT] Processed ${count} expired signing tasks`);
} else {
this.logger.debug('[CONTRACT-TIMEOUT] No expired tasks found');
}
} catch (error) {
this.logger.error('[CONTRACT-TIMEOUT] Error processing expired tasks:', error);
}
}
}

View File

@ -0,0 +1 @@
export * from './contract-signing-timeout.job';

View File

@ -0,0 +1,318 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
IContractTemplateRepository,
CONTRACT_TEMPLATE_REPOSITORY,
IContractSigningTaskRepository,
CONTRACT_SIGNING_TASK_REPOSITORY,
} from '../../domain/repositories';
import {
ContractTemplate,
ContractSigningTask,
SignContractParams,
} from '../../domain/aggregates';
import { ContractSigningStatus } from '../../domain/value-objects';
import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service';
/**
*
*/
export interface CreateSigningTaskParams {
orderNo: string;
userId: bigint;
accountSequence: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
userPhoneNumber?: string;
userRealName?: string;
userIdCardNumber?: string;
}
/**
* DTO
*/
export interface ContractSigningTaskDto {
orderNo: string;
contractVersion: string;
contractContent: string;
status: string;
expiresAt: Date;
treeCount: number;
totalAmount: number;
provinceName: string;
cityName: string;
userRealName?: string;
scrolledToBottomAt?: Date;
acknowledgedAt?: Date;
signedAt?: Date;
}
/**
*
*
*
*/
@Injectable()
export class ContractSigningService {
private readonly logger = new Logger(ContractSigningService.name);
// 签署有效期24小时
private static readonly SIGNING_EXPIRY_HOURS = 24;
constructor(
@Inject(CONTRACT_TEMPLATE_REPOSITORY)
private readonly templateRepo: IContractTemplateRepository,
@Inject(CONTRACT_SIGNING_TASK_REPOSITORY)
private readonly taskRepo: IContractSigningTaskRepository,
private readonly eventPublisher: EventPublisherService,
) {}
/**
*
*
*/
async createSigningTask(params: CreateSigningTaskParams): Promise<ContractSigningTask> {
this.logger.log(`Creating contract signing task for order: ${params.orderNo}`);
// 1. 检查是否已存在签署任务
const exists = await this.taskRepo.existsByOrderNo(params.orderNo);
if (exists) {
this.logger.warn(`Signing task already exists for order: ${params.orderNo}`);
const existing = await this.taskRepo.findByOrderNo(params.orderNo);
return existing!;
}
// 2. 获取当前有效的合同模板
const template = await this.templateRepo.findActiveTemplate();
if (!template) {
throw new Error('没有可用的合同模板');
}
// 3. 生成填充用户信息后的合同内容
const contractContent = template.generateContractContent({
userPhoneNumber: params.userPhoneNumber,
userRealName: params.userRealName,
userIdCardNumber: params.userIdCardNumber,
treeCount: params.treeCount,
totalAmount: params.totalAmount,
provinceName: params.provinceName,
cityName: params.cityName,
orderNo: params.orderNo,
});
// 4. 计算过期时间24小时后
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + ContractSigningService.SIGNING_EXPIRY_HOURS);
// 5. 创建签署任务
const task = ContractSigningTask.create({
orderNo: params.orderNo,
userId: params.userId,
accountSequence: params.accountSequence,
templateId: template.id!,
contractVersion: template.version,
contractContent,
userPhoneNumber: params.userPhoneNumber,
userRealName: params.userRealName,
userIdCardNumber: params.userIdCardNumber,
treeCount: params.treeCount,
totalAmount: params.totalAmount,
provinceCode: params.provinceCode,
provinceName: params.provinceName,
cityCode: params.cityCode,
cityName: params.cityName,
expiresAt,
});
const savedTask = await this.taskRepo.save(task);
this.logger.log(`Created signing task for order: ${params.orderNo}, expires at: ${expiresAt}`);
return savedTask;
}
/**
*
*/
async getPendingTasks(userId: bigint): Promise<ContractSigningTaskDto[]> {
const tasks = await this.taskRepo.findPendingByUserId(userId);
return tasks.map((t) => this.toDto(t));
}
/**
*
*/
async getUnsignedTasks(userId: bigint): Promise<ContractSigningTaskDto[]> {
const tasks = await this.taskRepo.findUnsignedByUserId(userId);
return tasks.map((t) => this.toDto(t));
}
/**
*
*/
async getTask(orderNo: string, userId: bigint): Promise<ContractSigningTaskDto | null> {
const task = await this.taskRepo.findByOrderNo(orderNo);
if (!task || task.userId !== userId) {
return null;
}
return this.toDto(task);
}
/**
*
*/
async markScrollComplete(orderNo: string, userId: bigint): Promise<void> {
const task = await this.taskRepo.findByOrderNo(orderNo);
if (!task || task.userId !== userId) {
throw new Error('签署任务不存在');
}
task.markScrolledToBottom();
await this.taskRepo.save(task);
this.logger.log(`User ${userId} scrolled to bottom for order: ${orderNo}`);
}
/**
*
*/
async acknowledgeContract(orderNo: string, userId: bigint): Promise<void> {
const task = await this.taskRepo.findByOrderNo(orderNo);
if (!task || task.userId !== userId) {
throw new Error('签署任务不存在');
}
task.acknowledge();
await this.taskRepo.save(task);
this.logger.log(`User ${userId} acknowledged contract for order: ${orderNo}`);
}
/**
*
* contract.signed reward-service
*/
async signContract(
orderNo: string,
userId: bigint,
params: SignContractParams,
): Promise<void> {
const task = await this.taskRepo.findByOrderNo(orderNo);
if (!task || task.userId !== userId) {
throw new Error('签署任务不存在');
}
task.sign(params);
await this.taskRepo.save(task);
this.logger.log(`User ${userId} signed contract for order: ${orderNo}`);
// 发布合同签署完成事件,触发奖励分配
await this.eventPublisher.publishContractSigned({
orderNo: task.orderNo,
userId: task.userId.toString(),
accountSequence: task.accountSequence,
treeCount: task.treeCount,
totalAmount: task.totalAmount,
provinceCode: task.provinceCode,
cityCode: task.cityCode,
signedAt: task.signedAt?.toISOString(),
});
}
/**
*
*/
async lateSignContract(
orderNo: string,
userId: bigint,
params: SignContractParams,
): Promise<void> {
const task = await this.taskRepo.findByOrderNo(orderNo);
if (!task || task.userId !== userId) {
throw new Error('签署任务不存在');
}
task.lateSign(params);
await this.taskRepo.save(task);
this.logger.log(`User ${userId} late-signed contract for order: ${orderNo}`);
}
/**
*
*
* contract.expired reward-service
*/
async handleExpiredTasks(): Promise<number> {
const expiredTasks = await this.taskRepo.findExpiredPendingTasks();
let count = 0;
for (const task of expiredTasks) {
try {
task.markAsTimeout();
await this.taskRepo.save(task);
// 发布合同超时事件,触发系统账户奖励分配
await this.eventPublisher.publishContractExpired({
orderNo: task.orderNo,
userId: task.userId.toString(),
accountSequence: task.accountSequence,
treeCount: task.treeCount,
totalAmount: task.totalAmount,
provinceCode: task.provinceCode,
cityCode: task.cityCode,
expiredAt: new Date().toISOString(),
});
count++;
this.logger.log(`Marked task as timeout and published contract.expired: orderNo=${task.orderNo}`);
} catch (error) {
this.logger.error(`Failed to handle expired task: orderNo=${task.orderNo}`, error);
}
}
if (count > 0) {
this.logger.log(`Processed ${count} expired signing tasks`);
}
return count;
}
/**
*
*/
async getActiveTemplate(): Promise<ContractTemplate | null> {
return this.templateRepo.findActiveTemplate();
}
/**
* 使
*/
async createTemplate(params: {
version: string;
title: string;
content: string;
effectiveFrom: Date;
effectiveTo?: Date;
}): Promise<ContractTemplate> {
const template = ContractTemplate.create(params);
return this.templateRepo.save(template);
}
private toDto(task: ContractSigningTask): ContractSigningTaskDto {
return {
orderNo: task.orderNo,
contractVersion: task.contractVersion,
contractContent: task.contractContent,
status: task.status,
expiresAt: task.expiresAt,
treeCount: task.treeCount,
totalAmount: task.totalAmount,
provinceName: task.provinceName,
cityName: task.cityName,
userRealName: task.userRealName,
scrolledToBottomAt: task.scrolledToBottomAt,
acknowledgedAt: task.acknowledgedAt,
signedAt: task.signedAt,
};
}
}

View File

@ -1,2 +1,3 @@
export * from './planting-application.service';
export * from './pool-injection.service';
export * from './contract-signing.service';

View File

@ -288,20 +288,12 @@ export class PlantingApplicationService {
this.logger.log(`Local database transaction committed for order ${order.orderNo}`);
// ==================== 确认阶段 ====================
// 9. 确认扣款(从冻结金额中正式扣除)
// 钱会进入"待分配"状态,由 reward-service 通过事件触发后执行真正的分配
await this.walletService.confirmPlantingDeduction({
userId: userId.toString(),
accountSequence: accountSequence,
orderId: order.orderNo,
});
this.logger.log(`Wallet deduction confirmed for order ${order.orderNo}`);
// 注意:资金分配已移至 reward-service
// reward-service 收到 planting.order.paid 事件后,会:
// 1. 调用 authorization-service 获取考核后的分配方案
// 2. 调用 wallet-service 执行真正的资金分配
// 资金保持冻结状态,由 referral-service 在发送 planting.order.paid 前统一确认扣款
// 这样 planting-service 无需关心 CONTRACT_SIGNING_ENABLED 配置
this.logger.log(
`Order ${order.orderNo} paid, funds remain FROZEN. ` +
`Deduction will be confirmed by referral-service before reward distribution.`,
);
this.logger.log(`Order paid successfully: ${order.orderNo}`);

View File

@ -0,0 +1,440 @@
/**
*
*
*
* OrderPaid
*/
import { ContractSigningStatus } from '../value-objects/contract-signing-status.enum';
/**
*
*/
export interface DeviceInfo {
deviceId?: string;
deviceModel?: string;
osVersion?: string;
appVersion?: string;
}
/**
*
*/
export interface SigningLocation {
latitude?: number;
longitude?: number;
}
/**
*
*/
export interface CreateContractSigningTaskParams {
orderNo: string;
userId: bigint;
accountSequence: string;
templateId: number;
contractVersion: string;
contractContent: string;
userPhoneNumber?: string;
userRealName?: string;
userIdCardNumber?: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
expiresAt: Date;
}
/**
*
*/
export interface SignContractParams {
signatureCloudUrl: string;
signatureHash: string;
ipAddress: string;
deviceInfo: DeviceInfo;
userAgent: string;
location?: SigningLocation;
}
export class ContractSigningTask {
private _id?: bigint;
private _orderNo: string;
private _userId: bigint;
private _accountSequence: string;
private _templateId: number;
private _contractVersion: string;
private _contractContent: string;
private _userPhoneNumber?: string;
private _userRealName?: string;
private _userIdCardNumber?: string;
private _treeCount: number;
private _totalAmount: number;
private _provinceCode: string;
private _provinceName: string;
private _cityCode: string;
private _cityName: string;
private _status: ContractSigningStatus;
private _expiresAt: Date;
private _scrolledToBottomAt?: Date;
private _acknowledgedAt?: Date;
private _signedAt?: Date;
private _signatureCloudUrl?: string;
private _signatureHash?: string;
private _signingIpAddress?: string;
private _signingDeviceInfo?: DeviceInfo;
private _signingUserAgent?: string;
private _signingLatitude?: number;
private _signingLongitude?: number;
private _createdAt: Date;
private _updatedAt: Date;
private constructor() {
this._createdAt = new Date();
this._updatedAt = new Date();
}
// ============================================
// 工厂方法
// ============================================
/**
*
*/
static create(params: CreateContractSigningTaskParams): ContractSigningTask {
const task = new ContractSigningTask();
task._orderNo = params.orderNo;
task._userId = params.userId;
task._accountSequence = params.accountSequence;
task._templateId = params.templateId;
task._contractVersion = params.contractVersion;
task._contractContent = params.contractContent;
task._userPhoneNumber = params.userPhoneNumber;
task._userRealName = params.userRealName;
task._userIdCardNumber = params.userIdCardNumber;
task._treeCount = params.treeCount;
task._totalAmount = params.totalAmount;
task._provinceCode = params.provinceCode;
task._provinceName = params.provinceName;
task._cityCode = params.cityCode;
task._cityName = params.cityName;
task._status = ContractSigningStatus.PENDING;
task._expiresAt = params.expiresAt;
return task;
}
/**
*
*/
static reconstitute(data: {
id: bigint;
orderNo: string;
userId: bigint;
accountSequence: string;
templateId: number;
contractVersion: string;
contractContent: string;
userPhoneNumber?: string;
userRealName?: string;
userIdCardNumber?: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
expiresAt: Date;
scrolledToBottomAt?: Date;
acknowledgedAt?: Date;
signedAt?: Date;
signatureCloudUrl?: string;
signatureHash?: string;
signingIpAddress?: string;
signingDeviceInfo?: DeviceInfo;
signingUserAgent?: string;
signingLatitude?: number;
signingLongitude?: number;
createdAt: Date;
updatedAt: Date;
}): ContractSigningTask {
const task = new ContractSigningTask();
task._id = data.id;
task._orderNo = data.orderNo;
task._userId = data.userId;
task._accountSequence = data.accountSequence;
task._templateId = data.templateId;
task._contractVersion = data.contractVersion;
task._contractContent = data.contractContent;
task._userPhoneNumber = data.userPhoneNumber;
task._userRealName = data.userRealName;
task._userIdCardNumber = data.userIdCardNumber;
task._treeCount = data.treeCount;
task._totalAmount = data.totalAmount;
task._provinceCode = data.provinceCode;
task._provinceName = data.provinceName;
task._cityCode = data.cityCode;
task._cityName = data.cityName;
task._status = data.status as ContractSigningStatus;
task._expiresAt = data.expiresAt;
task._scrolledToBottomAt = data.scrolledToBottomAt;
task._acknowledgedAt = data.acknowledgedAt;
task._signedAt = data.signedAt;
task._signatureCloudUrl = data.signatureCloudUrl;
task._signatureHash = data.signatureHash;
task._signingIpAddress = data.signingIpAddress;
task._signingDeviceInfo = data.signingDeviceInfo;
task._signingUserAgent = data.signingUserAgent;
task._signingLatitude = data.signingLatitude;
task._signingLongitude = data.signingLongitude;
task._createdAt = data.createdAt;
task._updatedAt = data.updatedAt;
return task;
}
// ============================================
// Getters
// ============================================
get id(): bigint | undefined {
return this._id;
}
get orderNo(): string {
return this._orderNo;
}
get userId(): bigint {
return this._userId;
}
get accountSequence(): string {
return this._accountSequence;
}
get templateId(): number {
return this._templateId;
}
get contractVersion(): string {
return this._contractVersion;
}
get contractContent(): string {
return this._contractContent;
}
get userPhoneNumber(): string | undefined {
return this._userPhoneNumber;
}
get userRealName(): string | undefined {
return this._userRealName;
}
get userIdCardNumber(): string | undefined {
return this._userIdCardNumber;
}
get treeCount(): number {
return this._treeCount;
}
get totalAmount(): number {
return this._totalAmount;
}
get provinceCode(): string {
return this._provinceCode;
}
get provinceName(): string {
return this._provinceName;
}
get cityCode(): string {
return this._cityCode;
}
get cityName(): string {
return this._cityName;
}
get status(): ContractSigningStatus {
return this._status;
}
get expiresAt(): Date {
return this._expiresAt;
}
get scrolledToBottomAt(): Date | undefined {
return this._scrolledToBottomAt;
}
get acknowledgedAt(): Date | undefined {
return this._acknowledgedAt;
}
get signedAt(): Date | undefined {
return this._signedAt;
}
get signatureCloudUrl(): string | undefined {
return this._signatureCloudUrl;
}
get signatureHash(): string | undefined {
return this._signatureHash;
}
get signingIpAddress(): string | undefined {
return this._signingIpAddress;
}
get signingDeviceInfo(): DeviceInfo | undefined {
return this._signingDeviceInfo;
}
get signingUserAgent(): string | undefined {
return this._signingUserAgent;
}
get signingLatitude(): number | undefined {
return this._signingLatitude;
}
get signingLongitude(): number | undefined {
return this._signingLongitude;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
// ============================================
// 业务方法
// ============================================
/**
*
*/
isExpired(): boolean {
return new Date() > this._expiresAt;
}
/**
*
*/
canContinueSigning(): boolean {
return (
!this.isExpired() &&
this._status !== ContractSigningStatus.SIGNED &&
this._status !== ContractSigningStatus.UNSIGNED_TIMEOUT
);
}
/**
*
*/
markScrolledToBottom(): void {
if (!this.canContinueSigning()) {
throw new Error('无法继续签署流程:任务已过期或已完成');
}
if (this._status === ContractSigningStatus.PENDING) {
this._status = ContractSigningStatus.SCROLLED;
this._scrolledToBottomAt = new Date();
this._updatedAt = new Date();
}
}
/**
*
*/
acknowledge(): void {
if (!this.canContinueSigning()) {
throw new Error('无法继续签署流程:任务已过期或已完成');
}
if (
this._status !== ContractSigningStatus.SCROLLED &&
this._status !== ContractSigningStatus.PENDING
) {
throw new Error('请先滚动到合同底部');
}
this._status = ContractSigningStatus.ACKNOWLEDGED;
this._acknowledgedAt = new Date();
this._updatedAt = new Date();
}
/**
*
*/
sign(params: SignContractParams): void {
if (!this.canContinueSigning()) {
throw new Error('无法继续签署流程:任务已过期或已完成');
}
if (this._status !== ContractSigningStatus.ACKNOWLEDGED) {
throw new Error('请先确认法律效力');
}
this._status = ContractSigningStatus.SIGNED;
this._signedAt = new Date();
this._signatureCloudUrl = params.signatureCloudUrl;
this._signatureHash = params.signatureHash;
this._signingIpAddress = params.ipAddress;
this._signingDeviceInfo = params.deviceInfo;
this._signingUserAgent = params.userAgent;
if (params.location) {
this._signingLatitude = params.location.latitude;
this._signingLongitude = params.location.longitude;
}
this._updatedAt = new Date();
}
/**
*
*/
markAsTimeout(): void {
if (
this._status === ContractSigningStatus.SIGNED ||
this._status === ContractSigningStatus.UNSIGNED_TIMEOUT
) {
return; // 已完成或已超时,不做处理
}
this._status = ContractSigningStatus.UNSIGNED_TIMEOUT;
this._updatedAt = new Date();
}
/**
*
*/
lateSign(params: SignContractParams): void {
if (this._status === ContractSigningStatus.SIGNED) {
throw new Error('合同已签署');
}
this._status = ContractSigningStatus.SIGNED;
this._signedAt = new Date();
this._signatureCloudUrl = params.signatureCloudUrl;
this._signatureHash = params.signatureHash;
this._signingIpAddress = params.ipAddress;
this._signingDeviceInfo = params.deviceInfo;
this._signingUserAgent = params.userAgent;
if (params.location) {
this._signingLatitude = params.location.latitude;
this._signingLongitude = params.location.longitude;
}
this._updatedAt = new Date();
}
}

View File

@ -0,0 +1,195 @@
/**
*
*/
export class ContractTemplate {
private _id?: number;
private _version: string;
private _title: string;
private _content: string;
private _effectiveFrom: Date;
private _effectiveTo?: Date;
private _isActive: boolean;
private _createdAt: Date;
private _updatedAt: Date;
private constructor() {
this._createdAt = new Date();
this._updatedAt = new Date();
}
// ============================================
// 工厂方法
// ============================================
/**
*
*/
static create(params: {
version: string;
title: string;
content: string;
effectiveFrom: Date;
effectiveTo?: Date;
}): ContractTemplate {
const template = new ContractTemplate();
template._version = params.version;
template._title = params.title;
template._content = params.content;
template._effectiveFrom = params.effectiveFrom;
template._effectiveTo = params.effectiveTo;
template._isActive = true;
return template;
}
/**
*
*/
static reconstitute(data: {
id: number;
version: string;
title: string;
content: string;
effectiveFrom: Date;
effectiveTo?: Date;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}): ContractTemplate {
const template = new ContractTemplate();
template._id = data.id;
template._version = data.version;
template._title = data.title;
template._content = data.content;
template._effectiveFrom = data.effectiveFrom;
template._effectiveTo = data.effectiveTo;
template._isActive = data.isActive;
template._createdAt = data.createdAt;
template._updatedAt = data.updatedAt;
return template;
}
// ============================================
// Getters
// ============================================
get id(): number | undefined {
return this._id;
}
get version(): string {
return this._version;
}
get title(): string {
return this._title;
}
get content(): string {
return this._content;
}
get effectiveFrom(): Date {
return this._effectiveFrom;
}
get effectiveTo(): Date | undefined {
return this._effectiveTo;
}
get isActive(): boolean {
return this._isActive;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
// ============================================
// 业务方法
// ============================================
/**
*
*/
isEffectiveAt(date: Date): boolean {
if (!this._isActive) {
return false;
}
if (date < this._effectiveFrom) {
return false;
}
if (this._effectiveTo && date > this._effectiveTo) {
return false;
}
return true;
}
/**
*
*
* :
* - {{USER_PHONE}}:
* - {{USER_REAL_NAME}}:
* - {{USER_ID_CARD}}: ()
* - {{TREE_COUNT}}:
* - {{TOTAL_AMOUNT}}:
* - {{PROVINCE_NAME}}:
* - {{CITY_NAME}}:
* - {{CURRENT_DATE}}:
* - {{ORDER_NO}}:
*/
generateContractContent(params: {
userPhoneNumber?: string;
userRealName?: string;
userIdCardNumber?: string;
treeCount: number;
totalAmount: number;
provinceName: string;
cityName: string;
orderNo: string;
}): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
// 身份证号脱敏: 只显示前6位和后4位
const maskedIdCard = params.userIdCardNumber
? `${params.userIdCardNumber.slice(0, 6)}********${params.userIdCardNumber.slice(-4)}`
: '';
let content = this._content;
content = content.replace(/\{\{USER_PHONE\}\}/g, params.userPhoneNumber || '未认证');
content = content.replace(/\{\{USER_REAL_NAME\}\}/g, params.userRealName || '未认证');
content = content.replace(/\{\{USER_ID_CARD\}\}/g, maskedIdCard || '未认证');
content = content.replace(/\{\{TREE_COUNT\}\}/g, params.treeCount.toString());
content = content.replace(/\{\{TOTAL_AMOUNT\}\}/g, params.totalAmount.toFixed(2));
content = content.replace(/\{\{PROVINCE_NAME\}\}/g, params.provinceName);
content = content.replace(/\{\{CITY_NAME\}\}/g, params.cityName);
content = content.replace(/\{\{CURRENT_DATE\}\}/g, dateStr);
content = content.replace(/\{\{ORDER_NO\}\}/g, params.orderNo);
return content;
}
/**
*
*/
deactivate(): void {
this._isActive = false;
this._updatedAt = new Date();
}
/**
*
*/
setEffectiveTo(date: Date): void {
this._effectiveTo = date;
this._updatedAt = new Date();
}
}

View File

@ -1,3 +1,5 @@
export * from './planting-order.aggregate';
export * from './planting-position.aggregate';
export * from './pool-injection-batch.aggregate';
export * from './contract-template.aggregate';
export * from './contract-signing-task.aggregate';

View File

@ -0,0 +1,49 @@
import { ContractSigningTask } from '../aggregates';
import { ContractSigningStatus } from '../value-objects';
/**
*
*/
export interface IContractSigningTaskRepository {
/**
*
*/
save(task: ContractSigningTask): Promise<ContractSigningTask>;
/**
* ID查找
*/
findById(id: bigint): Promise<ContractSigningTask | null>;
/**
*
*/
findByOrderNo(orderNo: string): Promise<ContractSigningTask | null>;
/**
*
*/
findPendingByUserId(userId: bigint): Promise<ContractSigningTask[]>;
/**
*
*/
findUnsignedByUserId(userId: bigint): Promise<ContractSigningTask[]>;
/**
*
*/
findExpiredPendingTasks(): Promise<ContractSigningTask[]>;
/**
*
*/
findByStatus(status: ContractSigningStatus): Promise<ContractSigningTask[]>;
/**
*
*/
existsByOrderNo(orderNo: string): Promise<boolean>;
}
export const CONTRACT_SIGNING_TASK_REPOSITORY = Symbol('IContractSigningTaskRepository');

View File

@ -0,0 +1,33 @@
import { ContractTemplate } from '../aggregates';
/**
*
*/
export interface IContractTemplateRepository {
/**
*
*/
save(template: ContractTemplate): Promise<ContractTemplate>;
/**
* ID查找
*/
findById(id: number): Promise<ContractTemplate | null>;
/**
*
*/
findByVersion(version: string): Promise<ContractTemplate | null>;
/**
*
*/
findActiveTemplate(): Promise<ContractTemplate | null>;
/**
*
*/
findAll(): Promise<ContractTemplate[]>;
}
export const CONTRACT_TEMPLATE_REPOSITORY = Symbol('IContractTemplateRepository');

View File

@ -1,3 +1,5 @@
export * from './planting-order.repository.interface';
export * from './planting-position.repository.interface';
export * from './pool-injection-batch.repository.interface';
export * from './contract-template.repository.interface';
export * from './contract-signing-task.repository.interface';

View File

@ -0,0 +1,38 @@
/**
*
*/
export enum ContractSigningStatus {
/** 待签署 */
PENDING = 'PENDING',
/** 已滚动到底部 */
SCROLLED = 'SCROLLED',
/** 已确认法律效力 */
ACKNOWLEDGED = 'ACKNOWLEDGED',
/** 已签署完成 */
SIGNED = 'SIGNED',
/** 超时未签署 */
UNSIGNED_TIMEOUT = 'UNSIGNED_TIMEOUT',
}
/**
*
*/
export function needsUserSigning(status: ContractSigningStatus): boolean {
return (
status === ContractSigningStatus.PENDING ||
status === ContractSigningStatus.SCROLLED ||
status === ContractSigningStatus.ACKNOWLEDGED ||
status === ContractSigningStatus.UNSIGNED_TIMEOUT
);
}
/**
*
*/
export function isSigningCompleted(status: ContractSigningStatus): boolean {
return status === ContractSigningStatus.SIGNED;
}

View File

@ -5,3 +5,4 @@ export * from './tree-count.vo';
export * from './province-city-selection.vo';
export * from './fund-allocation.vo';
export * from './money.vo';
export * from './contract-signing-status.enum';

View File

@ -4,6 +4,8 @@ import { PrismaService } from './persistence/prisma/prisma.service';
import { PlantingOrderRepositoryImpl } from './persistence/repositories/planting-order.repository.impl';
import { PlantingPositionRepositoryImpl } from './persistence/repositories/planting-position.repository.impl';
import { PoolInjectionBatchRepositoryImpl } from './persistence/repositories/pool-injection-batch.repository.impl';
import { ContractTemplateRepositoryImpl } from './persistence/repositories/contract-template.repository.impl';
import { ContractSigningTaskRepositoryImpl } from './persistence/repositories/contract-signing-task.repository.impl';
import { OutboxRepository } from './persistence/repositories/outbox.repository';
import { PaymentCompensationRepository } from './persistence/repositories/payment-compensation.repository';
import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
@ -12,10 +14,14 @@ import { ReferralServiceClient } from './external/referral-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 { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface';
import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface';
import { CONTRACT_TEMPLATE_REPOSITORY } from '../domain/repositories/contract-template.repository.interface';
import { CONTRACT_SIGNING_TASK_REPOSITORY } from '../domain/repositories/contract-signing-task.repository.interface';
import { PaymentCompensationService } from '../application/services/payment-compensation.service';
import { ContractSigningService } from '../application/services/contract-signing.service';
@Global()
@Module({
@ -26,7 +32,7 @@ import { PaymentCompensationService } from '../application/services/payment-comp
}),
KafkaModule,
],
controllers: [EventAckController],
controllers: [EventAckController, ContractSigningEventConsumer],
providers: [
PrismaService,
{
@ -41,6 +47,14 @@ import { PaymentCompensationService } from '../application/services/payment-comp
provide: POOL_INJECTION_BATCH_REPOSITORY,
useClass: PoolInjectionBatchRepositoryImpl,
},
{
provide: CONTRACT_TEMPLATE_REPOSITORY,
useClass: ContractTemplateRepositoryImpl,
},
{
provide: CONTRACT_SIGNING_TASK_REPOSITORY,
useClass: ContractSigningTaskRepositoryImpl,
},
{
provide: UNIT_OF_WORK,
useClass: UnitOfWork,
@ -49,6 +63,7 @@ import { PaymentCompensationService } from '../application/services/payment-comp
PaymentCompensationRepository,
OutboxPublisherService,
PaymentCompensationService,
ContractSigningService,
WalletServiceClient,
ReferralServiceClient,
],
@ -57,11 +72,14 @@ import { PaymentCompensationService } from '../application/services/payment-comp
PLANTING_ORDER_REPOSITORY,
PLANTING_POSITION_REPOSITORY,
POOL_INJECTION_BATCH_REPOSITORY,
CONTRACT_TEMPLATE_REPOSITORY,
CONTRACT_SIGNING_TASK_REPOSITORY,
UNIT_OF_WORK,
OutboxRepository,
PaymentCompensationRepository,
OutboxPublisherService,
PaymentCompensationService,
ContractSigningService,
WalletServiceClient,
ReferralServiceClient,
],

View File

@ -0,0 +1,144 @@
import { Controller, Logger, Inject } from '@nestjs/common';
import { EventPattern, Payload, Ctx, KafkaContext } from '@nestjs/microservices';
import { ContractSigningService } from '../../application/services/contract-signing.service';
import {
IPlantingOrderRepository,
PLANTING_ORDER_REPOSITORY,
} from '../../domain/repositories';
/**
*
*/
interface PlantingEventMessage {
eventName: string;
aggregateId: string;
occurredAt: string;
data: {
orderId: string;
orderNo?: string;
userId: string;
accountSequence?: string;
treeCount: number;
totalAmount?: number;
provinceCode?: string;
cityCode?: string;
};
}
/**
* Identity Service
*/
interface UserKycInfo {
phoneNumber?: string;
realName?: string;
idCardNumber?: string;
}
/**
*
*
* OrderPaid
* planting-service
*/
@Controller()
export class ContractSigningEventConsumer {
private readonly logger = new Logger(ContractSigningEventConsumer.name);
constructor(
private readonly contractSigningService: ContractSigningService,
@Inject(PLANTING_ORDER_REPOSITORY)
private readonly orderRepo: IPlantingOrderRepository,
) {}
/**
*
* PlantingOrderPaid
*/
@EventPattern('planting-events')
async handlePlantingEvent(
@Payload() message: PlantingEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
const eventName = message.eventName;
try {
this.logger.debug(`[CONTRACT-SIGNING] Received event: ${eventName}`);
// 处理 PlantingOrderPaid 事件(订单支付完成后触发)
// 支持多种事件名称格式以确保兼容性
if (
eventName === 'PlantingOrderPaid' ||
eventName === 'planting.order.paid' ||
eventName === 'planting.tree.planted'
) {
await this.handleOrderPaid(message);
}
} catch (error) {
this.logger.error(`[CONTRACT-SIGNING] Error processing event ${eventName}:`, error);
// 不抛出异常,避免影响其他消费者
}
}
/**
*
*
*/
private async handleOrderPaid(message: PlantingEventMessage): Promise<void> {
const { data } = message;
const orderNo = data.orderNo || data.orderId;
this.logger.log(`[CONTRACT-SIGNING] Processing PlantingOrderPaid for order: ${orderNo}`);
try {
// 1. 获取订单详情
const order = await this.orderRepo.findByOrderNo(orderNo);
if (!order) {
this.logger.warn(`[CONTRACT-SIGNING] Order not found: ${orderNo}`);
return;
}
// 2. 获取用户 KYC 信息
// TODO: 调用 identity-service 获取用户信息
// 目前使用占位数据
const kycInfo = await this.getUserKycInfo(order.userId);
// 3. 创建合同签署任务
const provinceCitySelection = order.provinceCitySelection;
if (!provinceCitySelection) {
this.logger.warn(`[CONTRACT-SIGNING] Order ${orderNo} has no province/city selection`);
return;
}
await this.contractSigningService.createSigningTask({
orderNo,
userId: order.userId,
accountSequence: data.accountSequence || order.userId.toString(),
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(`[CONTRACT-SIGNING] Created signing task for order: ${orderNo}`);
} catch (error) {
this.logger.error(`[CONTRACT-SIGNING] Failed to create signing task for order ${orderNo}:`, error);
// 不抛出异常,合同签署任务创建失败不应影响主流程
}
}
/**
* 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;
}
}

View File

@ -7,9 +7,11 @@ import { DomainEvent } from '../../domain/events/domain-event.interface';
* Kafka Topic
*
* :
* - reward-service: 监听 planting.order.paid
* - reward-service: 监听 contract.signed, contract.expired
* - authorization-service: 监听 planting-events
* - referral-service: 监听 planting.planting.created
*
* planting.order.paid 使
*/
const EVENT_TOPIC_MAP: Record<string, string> = {
PlantingOrderCreated: 'planting.order.created',
@ -18,8 +20,26 @@ const EVENT_TOPIC_MAP: Record<string, string> = {
FundsAllocated: 'planting.order.funds-allocated',
PoolInjected: 'planting.pool.injected',
MiningEnabled: 'planting.mining.enabled',
// 合同签署事件
ContractSigned: 'contract.signed',
ContractExpired: 'contract.expired',
};
/**
*
*/
export interface ContractSigningEventData {
orderNo: string;
userId: string;
accountSequence: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
cityCode: string;
signedAt?: string; // contract.signed
expiredAt?: string; // contract.expired
}
@Injectable()
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EventPublisherService.name);
@ -209,6 +229,74 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* (reward-service )
* +
*/
async publishContractSigned(data: ContractSigningEventData): Promise<void> {
const topic = 'contract.signed';
const message = {
key: data.orderNo,
value: JSON.stringify({
eventName: 'contract.signed',
data,
}),
};
this.logger.debug(`[PUBLISH] Publishing ContractSigned for reward-service:
- OrderNo: ${data.orderNo}
- UserId: ${data.userId}
- TreeCount: ${data.treeCount}
- SignedAt: ${data.signedAt}`);
if (!this.isConnected) {
this.logger.warn(`[PUBLISH] Kafka not connected, skipping contract.signed`);
return;
}
try {
this.kafkaClient.emit(topic, message);
this.logger.log(`[PUBLISH] ✓ ContractSigned published for order ${data.orderNo}`);
} catch (error) {
this.logger.error(`[PUBLISH] ✗ Failed to publish ContractSigned:`, error);
throw error;
}
}
/**
* (reward-service )
* +
*/
async publishContractExpired(data: ContractSigningEventData): Promise<void> {
const topic = 'contract.expired';
const message = {
key: data.orderNo,
value: JSON.stringify({
eventName: 'contract.expired',
data,
}),
};
this.logger.debug(`[PUBLISH] Publishing ContractExpired for reward-service:
- OrderNo: ${data.orderNo}
- UserId: ${data.userId}
- TreeCount: ${data.treeCount}
- ExpiredAt: ${data.expiredAt}`);
if (!this.isConnected) {
this.logger.warn(`[PUBLISH] Kafka not connected, skipping contract.expired`);
return;
}
try {
this.kafkaClient.emit(topic, message);
this.logger.log(`[PUBLISH] ✓ ContractExpired published for order ${data.orderNo}`);
} catch (error) {
this.logger.error(`[PUBLISH] ✗ Failed to publish ContractExpired:`, error);
throw error;
}
}
private getTopicForEvent(event: DomainEvent): string {
const topic = EVENT_TOPIC_MAP[event.type] || 'planting.events';
this.logger.debug(`[TOPIC] Mapped event type ${event.type} to topic ${topic}`);

View File

@ -1,2 +1,3 @@
export * from './kafka.module';
export * from './event-publisher.service';
export * from './contract-signing-event.consumer';

View File

@ -0,0 +1,222 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { IContractSigningTaskRepository } from '../../../domain/repositories/contract-signing-task.repository.interface';
import {
ContractSigningTask,
DeviceInfo,
} from '../../../domain/aggregates/contract-signing-task.aggregate';
import { ContractSigningStatus } from '../../../domain/value-objects/contract-signing-status.enum';
@Injectable()
export class ContractSigningTaskRepositoryImpl implements IContractSigningTaskRepository {
constructor(private readonly prisma: PrismaService) {}
async save(task: ContractSigningTask): Promise<ContractSigningTask> {
const deviceInfoJson = task.signingDeviceInfo
? JSON.stringify(task.signingDeviceInfo)
: null;
if (task.id) {
// 更新
const updated = await this.prisma.contractSigningTask.update({
where: { id: task.id },
data: {
status: task.status,
scrolledToBottomAt: task.scrolledToBottomAt,
acknowledgedAt: task.acknowledgedAt,
signedAt: task.signedAt,
signatureCloudUrl: task.signatureCloudUrl,
signatureHash: task.signatureHash,
signingIpAddress: task.signingIpAddress,
signingDeviceInfo: deviceInfoJson,
signingUserAgent: task.signingUserAgent,
signingLatitude: task.signingLatitude
? new Prisma.Decimal(task.signingLatitude)
: null,
signingLongitude: task.signingLongitude
? new Prisma.Decimal(task.signingLongitude)
: null,
},
});
return this.mapToDomain(updated);
} else {
// 创建
const created = await this.prisma.contractSigningTask.create({
data: {
orderNo: task.orderNo,
userId: task.userId,
accountSequence: task.accountSequence,
templateId: task.templateId,
contractVersion: task.contractVersion,
contractContent: task.contractContent,
userPhoneNumber: task.userPhoneNumber,
userRealName: task.userRealName,
userIdCardNumber: task.userIdCardNumber,
treeCount: task.treeCount,
totalAmount: new Prisma.Decimal(task.totalAmount),
provinceCode: task.provinceCode,
provinceName: task.provinceName,
cityCode: task.cityCode,
cityName: task.cityName,
status: task.status,
expiresAt: task.expiresAt,
},
});
return this.mapToDomain(created);
}
}
async findById(id: bigint): Promise<ContractSigningTask | null> {
const task = await this.prisma.contractSigningTask.findUnique({
where: { id },
});
return task ? this.mapToDomain(task) : null;
}
async findByOrderNo(orderNo: string): Promise<ContractSigningTask | null> {
const task = await this.prisma.contractSigningTask.findUnique({
where: { orderNo },
});
return task ? this.mapToDomain(task) : null;
}
async findPendingByUserId(userId: bigint): Promise<ContractSigningTask[]> {
const tasks = await this.prisma.contractSigningTask.findMany({
where: {
userId,
status: {
in: [
ContractSigningStatus.PENDING,
ContractSigningStatus.SCROLLED,
ContractSigningStatus.ACKNOWLEDGED,
],
},
expiresAt: { gte: new Date() },
},
orderBy: { createdAt: 'desc' },
});
return tasks.map((t) => this.mapToDomain(t));
}
async findUnsignedByUserId(userId: bigint): Promise<ContractSigningTask[]> {
const tasks = await this.prisma.contractSigningTask.findMany({
where: {
userId,
status: {
notIn: [ContractSigningStatus.SIGNED],
},
},
orderBy: { createdAt: 'desc' },
});
return tasks.map((t) => this.mapToDomain(t));
}
async findExpiredPendingTasks(): Promise<ContractSigningTask[]> {
const now = new Date();
const tasks = await this.prisma.contractSigningTask.findMany({
where: {
status: {
in: [
ContractSigningStatus.PENDING,
ContractSigningStatus.SCROLLED,
ContractSigningStatus.ACKNOWLEDGED,
],
},
expiresAt: { lt: now },
},
orderBy: { expiresAt: 'asc' },
});
return tasks.map((t) => this.mapToDomain(t));
}
async findByStatus(status: ContractSigningStatus): Promise<ContractSigningTask[]> {
const tasks = await this.prisma.contractSigningTask.findMany({
where: { status },
orderBy: { createdAt: 'desc' },
});
return tasks.map((t) => this.mapToDomain(t));
}
async existsByOrderNo(orderNo: string): Promise<boolean> {
const count = await this.prisma.contractSigningTask.count({
where: { orderNo },
});
return count > 0;
}
private mapToDomain(data: {
id: bigint;
orderNo: string;
userId: bigint;
accountSequence: string;
templateId: number;
contractVersion: string;
contractContent: string;
userPhoneNumber: string | null;
userRealName: string | null;
userIdCardNumber: string | null;
treeCount: number;
totalAmount: Prisma.Decimal;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
expiresAt: Date;
scrolledToBottomAt: Date | null;
acknowledgedAt: Date | null;
signedAt: Date | null;
signatureCloudUrl: string | null;
signatureHash: string | null;
signingIpAddress: string | null;
signingDeviceInfo: string | null;
signingUserAgent: string | null;
signingLatitude: Prisma.Decimal | null;
signingLongitude: Prisma.Decimal | null;
createdAt: Date;
updatedAt: Date;
}): ContractSigningTask {
let deviceInfo: DeviceInfo | undefined;
if (data.signingDeviceInfo) {
try {
deviceInfo = JSON.parse(data.signingDeviceInfo);
} catch {
deviceInfo = undefined;
}
}
return ContractSigningTask.reconstitute({
id: data.id,
orderNo: data.orderNo,
userId: data.userId,
accountSequence: data.accountSequence,
templateId: data.templateId,
contractVersion: data.contractVersion,
contractContent: data.contractContent,
userPhoneNumber: data.userPhoneNumber ?? undefined,
userRealName: data.userRealName ?? undefined,
userIdCardNumber: data.userIdCardNumber ?? undefined,
treeCount: data.treeCount,
totalAmount: data.totalAmount.toNumber(),
provinceCode: data.provinceCode,
provinceName: data.provinceName,
cityCode: data.cityCode,
cityName: data.cityName,
status: data.status,
expiresAt: data.expiresAt,
scrolledToBottomAt: data.scrolledToBottomAt ?? undefined,
acknowledgedAt: data.acknowledgedAt ?? undefined,
signedAt: data.signedAt ?? undefined,
signatureCloudUrl: data.signatureCloudUrl ?? undefined,
signatureHash: data.signatureHash ?? undefined,
signingIpAddress: data.signingIpAddress ?? undefined,
signingDeviceInfo: deviceInfo,
signingUserAgent: data.signingUserAgent ?? undefined,
signingLatitude: data.signingLatitude?.toNumber(),
signingLongitude: data.signingLongitude?.toNumber(),
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
}
}

View File

@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { IContractTemplateRepository } from '../../../domain/repositories/contract-template.repository.interface';
import { ContractTemplate } from '../../../domain/aggregates/contract-template.aggregate';
@Injectable()
export class ContractTemplateRepositoryImpl implements IContractTemplateRepository {
constructor(private readonly prisma: PrismaService) {}
async save(template: ContractTemplate): Promise<ContractTemplate> {
if (template.id) {
// 更新
const updated = await this.prisma.contractTemplate.update({
where: { id: template.id },
data: {
version: template.version,
title: template.title,
content: template.content,
effectiveFrom: template.effectiveFrom,
effectiveTo: template.effectiveTo,
isActive: template.isActive,
},
});
return this.mapToDomain(updated);
} else {
// 创建
const created = await this.prisma.contractTemplate.create({
data: {
version: template.version,
title: template.title,
content: template.content,
effectiveFrom: template.effectiveFrom,
effectiveTo: template.effectiveTo,
isActive: template.isActive,
},
});
return this.mapToDomain(created);
}
}
async findById(id: number): Promise<ContractTemplate | null> {
const template = await this.prisma.contractTemplate.findUnique({
where: { id },
});
return template ? this.mapToDomain(template) : null;
}
async findByVersion(version: string): Promise<ContractTemplate | null> {
const template = await this.prisma.contractTemplate.findUnique({
where: { version },
});
return template ? this.mapToDomain(template) : null;
}
async findActiveTemplate(): Promise<ContractTemplate | null> {
const now = new Date();
const template = await this.prisma.contractTemplate.findFirst({
where: {
isActive: true,
effectiveFrom: { lte: now },
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: now } },
],
},
orderBy: { effectiveFrom: 'desc' },
});
return template ? this.mapToDomain(template) : null;
}
async findAll(): Promise<ContractTemplate[]> {
const templates = await this.prisma.contractTemplate.findMany({
orderBy: { createdAt: 'desc' },
});
return templates.map((t) => this.mapToDomain(t));
}
private mapToDomain(data: {
id: number;
version: string;
title: string;
content: string;
effectiveFrom: Date;
effectiveTo: Date | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}): ContractTemplate {
return ContractTemplate.reconstitute({
id: data.id,
version: data.version,
title: data.title,
content: data.content,
effectiveFrom: data.effectiveFrom,
effectiveTo: data.effectiveTo ?? undefined,
isActive: data.isActive,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
}
}

View File

@ -1,3 +1,5 @@
export * from './planting-order.repository.impl';
export * from './planting-position.repository.impl';
export * from './pool-injection-batch.repository.impl';
export * from './contract-template.repository.impl';
export * from './contract-signing-task.repository.impl';

View File

@ -42,10 +42,11 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// Kafka 微服务 - 用于接收 ACK 确认消息
// Kafka 微服务配置
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'planting-service-group';
// 微服务 1: 用于接收 ACK 确认消息
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
@ -59,9 +60,24 @@ async function bootstrap() {
},
});
// 启动 Kafka 微服务
// 微服务 2: 用于合同签署事件消费(监听 planting-events
// 注意planting-service 自己发布事件,也自己消费(松耦合的合同签署模块)
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'planting-service-contract-signing',
brokers: kafkaBrokers,
},
consumer: {
groupId: `${kafkaGroupId}-contract-signing`,
},
},
});
// 启动所有 Kafka 微服务
await app.startAllMicroservices();
logger.log('Kafka microservice started for ACK consumption');
logger.log('Kafka microservices started (ACK + Contract Signing)');
const port = process.env.APP_PORT || 3003;
await app.listen(port);

View File

@ -0,0 +1,277 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KafkaService, WalletServiceClient } from '../../infrastructure';
/**
*
*/
interface ContractSigningEvent {
eventName: string;
data: {
orderNo: string;
userId: string;
accountSequence: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
cityCode: string;
signedAt?: string; // contract.signed
expiredAt?: string; // contract.expired
};
}
/**
*
* planting-service
*
*
* - contract.signed planting.order.paid reward-service
* - contract.expired planting.order.expired reward-service
*
* referral-service
* 1. referral-service planting.order.paid
* 2. planting.created
* 3. reward-service handlePlantingOrderPaid 100%
*/
@Injectable()
export class ContractSigningHandler implements OnModuleInit {
private readonly logger = new Logger(ContractSigningHandler.name);
constructor(
private readonly kafkaService: KafkaService,
private readonly walletService: WalletServiceClient,
) {}
async onModuleInit() {
// 订阅合同签署完成事件
await this.kafkaService.subscribe(
'referral-service-contract-signed',
['contract.signed'],
this.handleContractSigned.bind(this),
);
this.logger.log('Subscribed to contract.signed events');
// 订阅合同超时事件
await this.kafkaService.subscribe(
'referral-service-contract-expired',
['contract.expired'],
this.handleContractExpired.bind(this),
);
this.logger.log('Subscribed to contract.expired events');
}
/**
*
* planting.order.paid
*/
private async handleContractSigned(
topic: string,
message: Record<string, unknown>,
): Promise<void> {
const event = message as unknown as ContractSigningEvent;
if (event.eventName !== 'contract.signed') {
return;
}
const eventData = event.data;
this.logger.log(
`Received contract.signed for order ${eventData.orderNo}, ` +
`accountSequence: ${eventData.accountSequence}, signedAt: ${eventData.signedAt}`,
);
try {
// 发送 planting.order.paid 事件给 reward-service
// 这会触发原有的奖励分配流程100% 不变)
await this.publishOrderPaidEvent(eventData);
this.logger.log(
`Successfully forwarded contract.signed to planting.order.paid for order ${eventData.orderNo}`,
);
} catch (error) {
this.logger.error(
`Failed to forward contract.signed for order ${eventData.orderNo}:`,
error,
);
throw error;
}
}
/**
*
* planting.order.expired
*/
private async handleContractExpired(
topic: string,
message: Record<string, unknown>,
): Promise<void> {
const event = message as unknown as ContractSigningEvent;
if (event.eventName !== 'contract.expired') {
return;
}
const eventData = event.data;
this.logger.log(
`Received contract.expired for order ${eventData.orderNo}, ` +
`accountSequence: ${eventData.accountSequence}, expiredAt: ${eventData.expiredAt}`,
);
try {
// 发送 planting.order.expired 事件给 reward-service
// 这会触发系统账户奖励分配流程
await this.publishOrderExpiredEvent(eventData);
this.logger.log(
`Successfully forwarded contract.expired to planting.order.expired for order ${eventData.orderNo}`,
);
} catch (error) {
this.logger.error(
`Failed to forward contract.expired for order ${eventData.orderNo}:`,
error,
);
throw error;
}
}
/**
* planting.order.paid reward-service
*
*
* 1. wallet-service
* 2. planting.order.paid
*/
private async publishOrderPaidEvent(
eventData: ContractSigningEvent['data'],
): Promise<void> {
// 步骤1确认扣款
const confirmResult = await this.walletService.confirmPlantingDeduction({
userId: eventData.userId,
accountSequence: eventData.accountSequence,
orderId: eventData.orderNo,
});
if (!confirmResult.success) {
throw new Error(`确认扣款失败: ${confirmResult.error}`);
}
this.logger.log(`Deduction confirmed for order ${eventData.orderNo}`);
// 步骤2发送 planting.order.paid 事件
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.kafkaService.publish({
topic: 'planting.order.paid',
key: eventData.accountSequence,
value: {
eventName: 'planting.order.paid',
data: {
orderId: eventData.orderNo,
userId: eventData.userId,
accountSequence: eventData.accountSequence,
treeCount: eventData.treeCount,
provinceCode: eventData.provinceCode,
cityCode: eventData.cityCode,
paidAt: eventData.signedAt || new Date().toISOString(),
},
},
});
this.logger.log(
`Published planting.order.paid for order ${eventData.orderNo}, ` +
`accountSequence ${eventData.accountSequence}`,
);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`Failed to publish planting.order.paid (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
);
if (attempt < maxRetries) {
await this.sleep(1000 * attempt);
}
}
}
throw new Error(
`Failed to publish planting.order.paid after ${maxRetries} attempts: ${lastError?.message}`,
);
}
/**
* planting.order.expired reward-service
*
*
* 1. wallet-service
* 2. planting.order.expired
*/
private async publishOrderExpiredEvent(
eventData: ContractSigningEvent['data'],
): Promise<void> {
// 步骤1确认扣款即使合同超时资金也要正式扣除用于分配
const confirmResult = await this.walletService.confirmPlantingDeduction({
userId: eventData.userId,
accountSequence: eventData.accountSequence,
orderId: eventData.orderNo,
});
if (!confirmResult.success) {
throw new Error(`确认扣款失败: ${confirmResult.error}`);
}
this.logger.log(`Deduction confirmed for expired order ${eventData.orderNo}`);
// 步骤2发送 planting.order.expired 事件
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.kafkaService.publish({
topic: 'planting.order.expired',
key: eventData.accountSequence,
value: {
eventName: 'planting.order.expired',
data: {
orderId: eventData.orderNo,
userId: eventData.userId,
accountSequence: eventData.accountSequence,
treeCount: eventData.treeCount,
provinceCode: eventData.provinceCode,
cityCode: eventData.cityCode,
expiredAt: eventData.expiredAt || new Date().toISOString(),
},
},
});
this.logger.log(
`Published planting.order.expired for order ${eventData.orderNo}, ` +
`accountSequence ${eventData.accountSequence}`,
);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`Failed to publish planting.order.expired (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
);
if (attempt < maxRetries) {
await this.sleep(1000 * attempt);
}
}
}
throw new Error(
`Failed to publish planting.order.expired after ${maxRetries} attempts: ${lastError?.message}`,
);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -1,2 +1,3 @@
export * from './user-registered.handler';
export * from './planting-created.handler';
export * from './contract-signing.handler';

View File

@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KafkaService, PrismaService } from '../../infrastructure';
import { ConfigService } from '@nestjs/config';
import { KafkaService, PrismaService, WalletServiceClient } from '../../infrastructure';
import { EventAckPublisher } from '../../infrastructure/kafka/event-ack.publisher';
import { TeamStatisticsService } from '../services';
import { UpdateTeamStatisticsCommand } from '../commands';
@ -29,20 +30,31 @@ interface PlantingCreatedEvent {
*
*
* 1.
* 2. planting.order.paid reward-service
* 2. CONTRACT_SIGNING_ENABLED
* - true ContractSigningHandler
* - false planting.order.paid
*
*
* CONTRACT_SIGNING_ENABLED=true
* - contract.signed planting.order.paid reward-service
* - contract.expired planting.order.expired reward-service
*/
@Injectable()
export class PlantingCreatedHandler implements OnModuleInit {
private readonly logger = new Logger(PlantingCreatedHandler.name);
private readonly contractSigningEnabled: boolean;
constructor(
private readonly kafkaService: KafkaService,
private readonly teamStatisticsService: TeamStatisticsService,
private readonly eventAckPublisher: EventAckPublisher,
private readonly prisma: PrismaService,
) {}
private readonly configService: ConfigService,
private readonly walletService: WalletServiceClient,
) {
// 默认启用合同签署功能
this.contractSigningEnabled = this.configService.get<string>('CONTRACT_SIGNING_ENABLED', 'true') === 'true';
this.logger.log(`Contract signing feature: ${this.contractSigningEnabled ? 'ENABLED' : 'DISABLED'}`);
}
async onModuleInit() {
await this.kafkaService.subscribe(
@ -96,9 +108,23 @@ export class PlantingCreatedHandler implements OnModuleInit {
`Updated team statistics for accountSequence ${event.data.accountSequence}, count: ${event.data.treeCount}`,
);
// 步骤2发送 planting.order.paid 事件给 reward-service
// 统计更新完成后再触发奖励计算,确保数据一致性
await this.publishOrderPaidEvent(event);
// 步骤2根据配置决定是否立即发送 planting.order.paid
if (this.contractSigningEnabled) {
// 合同签署功能已启用:等待合同签署结果后再发送
// - contract.signed → 发送 planting.order.paid → 正常奖励分配
// - contract.expired → 发送 planting.order.expired → 系统账户分配
this.logger.log(
`[CONTRACT_SIGNING=ON] Team statistics updated for order ${event.data.orderId}. ` +
`Waiting for contract signing result (contract.signed or contract.expired) to trigger reward distribution.`,
);
} else {
// 合同签署功能未启用:立即发送 planting.order.paid原流程
await this.publishOrderPaidEvent(event);
this.logger.log(
`[CONTRACT_SIGNING=OFF] Published planting.order.paid for order ${event.data.orderId}. ` +
`Using original reward distribution flow.`,
);
}
// 记录已处理的事件
if (eventId !== 'unknown') {
@ -129,10 +155,30 @@ export class PlantingCreatedHandler implements OnModuleInit {
}
/**
* planting.order.paid reward-service
*
* planting.order.paid reward-service
*
* CONTRACT_SIGNING_ENABLED=false
* CONTRACT_SIGNING_ENABLED=true ContractSigningHandler
*
*
* 1. wallet-service
* 2. planting.order.paid
*/
private async publishOrderPaidEvent(event: PlantingCreatedEvent): Promise<void> {
// 步骤1确认扣款
const confirmResult = await this.walletService.confirmPlantingDeduction({
userId: event.data.userId,
accountSequence: event.data.accountSequence,
orderId: event.data.orderId,
});
if (!confirmResult.success) {
throw new Error(`确认扣款失败: ${confirmResult.error}`);
}
this.logger.log(`Deduction confirmed for order ${event.data.orderId}`);
// 步骤2发送 planting.order.paid 事件
const maxRetries = 3;
let lastError: Error | null = null;
@ -140,13 +186,13 @@ export class PlantingCreatedHandler implements OnModuleInit {
try {
await this.kafkaService.publish({
topic: 'planting.order.paid',
key: event.data.accountSequence || event.data.userId,
key: event.data.accountSequence,
value: {
eventName: 'planting.order.paid',
data: {
orderId: event.data.orderId,
userId: event.data.userId,
accountSequence: event.data.accountSequence, // 跨服务关联标识
accountSequence: event.data.accountSequence,
treeCount: event.data.treeCount,
provinceCode: event.data.provinceCode,
cityCode: event.data.cityCode,
@ -156,9 +202,9 @@ export class PlantingCreatedHandler implements OnModuleInit {
});
this.logger.log(
`Published planting.order.paid event for order ${event.data.orderId}, user ${event.data.userId}`,
`Published planting.order.paid event for order ${event.data.orderId}, accountSequence ${event.data.accountSequence}`,
);
return; // 成功后退出
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
@ -166,13 +212,11 @@ export class PlantingCreatedHandler implements OnModuleInit {
);
if (attempt < maxRetries) {
// 等待后重试,使用指数退避
await this.sleep(1000 * attempt);
}
}
}
// 所有重试都失败了,抛出错误
throw new Error(
`Failed to publish planting.order.paid event after ${maxRetries} attempts: ${lastError?.message}`,
);

View File

@ -1 +1,2 @@
export * from './authorization-service.client';
export * from './wallet-service.client';

View File

@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface ConfirmPlantingDeductionRequest {
userId: string;
accountSequence?: string;
orderId: string;
}
export interface ConfirmPlantingDeductionResult {
success: boolean;
error?: string;
}
@Injectable()
export class WalletServiceClient {
private readonly logger = new Logger(WalletServiceClient.name);
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl =
this.configService.get<string>('WALLET_SERVICE_URL') ||
'http://localhost:3002';
}
/**
*
* "待分配"
*
* referral-service planting.order.paid
*/
async confirmPlantingDeduction(
request: ConfirmPlantingDeductionRequest,
): Promise<ConfirmPlantingDeductionResult> {
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
this.logger.log(
`Confirming planting deduction for order ${request.orderId} (attempt ${attempt}/${maxRetries})`,
);
const response = await fetch(
`${this.baseUrl}/api/v1/wallets/confirm-planting-deduction`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this.logger.error(
`Failed to confirm deduction for order ${request.orderId}:`,
errorData,
);
return {
success: false,
error:
errorData.message ||
`Confirm deduction failed with status ${response.status}`,
};
}
this.logger.log(
`Successfully confirmed deduction for order ${request.orderId}`,
);
return { success: true };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`Failed to confirm planting deduction (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
);
if (attempt < maxRetries) {
await this.sleep(1000 * attempt);
}
}
}
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn(
'Development mode: simulating successful deduction confirmation',
);
return { success: true };
}
return {
success: false,
error: `Failed to confirm deduction after ${maxRetries} attempts: ${lastError?.message}`,
};
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -6,6 +6,7 @@ import {
TeamStatisticsService,
UserRegisteredHandler,
PlantingCreatedHandler,
ContractSigningHandler,
} from '../application';
@Module({
@ -15,6 +16,7 @@ import {
TeamStatisticsService,
UserRegisteredHandler,
PlantingCreatedHandler,
ContractSigningHandler,
],
exports: [ReferralService, TeamStatisticsService],
})

View File

@ -10,6 +10,7 @@ import {
RedisService,
EventAckPublisher,
AuthorizationServiceClient,
WalletServiceClient,
} from '../infrastructure';
import {
REFERRAL_RELATIONSHIP_REPOSITORY,
@ -26,6 +27,7 @@ import {
EventPublisherService,
EventAckPublisher,
AuthorizationServiceClient,
WalletServiceClient,
{
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
useClass: ReferralRelationshipRepository,
@ -42,6 +44,7 @@ import {
EventPublisherService,
EventAckPublisher,
AuthorizationServiceClient,
WalletServiceClient,
REFERRAL_RELATIONSHIP_REPOSITORY,
TEAM_STATISTICS_REPOSITORY,
],

View File

@ -35,12 +35,16 @@ export class RewardApplicationService {
) {}
/**
* ()
* ( planting.order.paid )
*
* referral-service
*
*
* 1. authorization-service
* 2. wallet-service
* 3.
*
* referral-service
*/
async distributeRewards(params: {
sourceOrderNo: string; // 订单号是字符串格式如 PLT1765391584505Q0Q6QD
@ -120,6 +124,94 @@ export class RewardApplicationService {
this.logger.log(`Distributed ${rewards.length} rewards for order ${params.sourceOrderNo}`);
}
/**
* ( planting.order.expired )
*
* referral-service
*
*
* -
* -
*
*
* - S0000000005
* - 7+
* - 6+
* - S0000000001
* - 9+
* - 8+
*
* referral-service
*/
async distributeRewardsForExpiredContract(params: {
sourceOrderNo: string;
sourceUserId: bigint;
sourceAccountSequence?: string;
treeCount: number;
provinceCode: string;
cityCode: string;
}): Promise<void> {
this.logger.log(`Distributing rewards for EXPIRED contract, order ${params.sourceOrderNo}`);
// 1. 计算奖励,但使用系统账户作为用户权益的接收方
const rewards = await this.rewardCalculationService.calculateRewardsForExpiredContract(params);
// 2. 调用 wallet-service 执行资金分配
const allocations: import('../../infrastructure/external/wallet-service/wallet-service.client').FundAllocationItem[] = rewards.map(reward => ({
targetType: (reward.accountSequence.startsWith('S') || /^\d+$/.test(reward.accountSequence) ? 'SYSTEM' : 'USER') as 'USER' | 'SYSTEM',
targetId: reward.accountSequence,
allocationType: reward.rewardSource.rightType,
amount: reward.usdtAmount.amount,
hashpowerPercent: reward.hashpowerAmount.value > 0 ? reward.hashpowerAmount.value : undefined,
metadata: {
rightType: reward.rewardSource.rightType,
sourceOrderNo: params.sourceOrderNo,
sourceUserId: params.sourceUserId.toString(),
memo: reward.memo,
reason: 'CONTRACT_EXPIRED', // 标记为合同超时
},
}));
const allocateResult = await this.walletService.allocateFunds({
orderId: params.sourceOrderNo,
allocations,
});
if (!allocateResult.success) {
this.logger.error(`Failed to allocate funds for expired contract order ${params.sourceOrderNo}: ${allocateResult.error}`);
throw new Error(`资金分配失败: ${allocateResult.error}`);
}
this.logger.log(`Wallet allocation completed for expired contract order ${params.sourceOrderNo}`);
// 3. 保存奖励流水
await this.rewardLedgerEntryRepository.saveAll(rewards);
// 4. 更新各系统账户的汇总数据
const userIds = [...new Set(rewards.map(r => r.userId))];
for (const userId of userIds) {
const userRewards = rewards.filter(r => r.userId === userId);
const accountSequence = userRewards[0].accountSequence;
const summary = await this.rewardSummaryRepository.getOrCreate(userId, accountSequence);
for (const reward of userRewards) {
if (reward.isSettleable) {
summary.addSettleable(reward.usdtAmount, reward.hashpowerAmount);
}
}
await this.rewardSummaryRepository.save(summary);
}
// 5. 发布领域事件
for (const reward of rewards) {
await this.eventPublisher.publishAll(reward.domainEvents);
reward.clearDomainEvents();
}
this.logger.log(`Distributed ${rewards.length} rewards to system accounts for expired contract order ${params.sourceOrderNo}`);
}
/**
*
* @deprecated 使 claimPendingRewardsForAccountSequence

View File

@ -678,4 +678,315 @@ export class RewardCalculationService {
return rewards;
}
// ============================================
// 合同超时场景的奖励计算
// ============================================
/**
*
*
*
* -
* - /
*
*
* - 3600 S0000000005
* - 144 7+
* - 288 6+
* - 576 S0000000001
* - 108 9+
* - 252 8+
*/
async calculateRewardsForExpiredContract(params: {
sourceOrderNo: string;
sourceUserId: bigint;
sourceAccountSequence?: string;
treeCount: number;
provinceCode: string;
cityCode: string;
}): Promise<RewardLedgerEntry[]> {
this.logger.log(
`[calculateRewardsForExpiredContract] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` +
`treeCount=${params.treeCount}, province=${params.provinceCode}, city=${params.cityCode}`,
);
const rewards: RewardLedgerEntry[] = [];
// ============================================
// 系统费用类 (10863 USDT) - 正常分配
// ============================================
// 1. 成本费 (2800 USDT) → S0000000002
rewards.push(this.calculateCostFee(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
// 2. 运营费 (2100 USDT) → S0000000003
rewards.push(this.calculateOperationFee(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
// 3. 总部社区基础费 (203 USDT) → S0000000001
rewards.push(this.calculateHeadquartersBaseFee(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
// 4. RWAD底池注入 (5760 USDT) → S0000000004
rewards.push(this.calculateRwadPoolInjection(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
// ============================================
// 用户权益类 (4968 USDT) - 全部进入系统默认账户
// ============================================
// 5. 分享权益 (3600 USDT) → S0000000005分享权益池
rewards.push(this.calculateShareRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
// 6. 省团队权益 (144 USDT) → 7+省代码
rewards.push(this.calculateProvinceTeamRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.provinceCode,
params.treeCount,
));
// 7. 省区域权益 (108 USDT + 1%算力) → 9+省代码
rewards.push(this.calculateProvinceAreaRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.provinceCode,
params.treeCount,
));
// 8. 市团队权益 (288 USDT) → 6+市代码
rewards.push(this.calculateCityTeamRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.cityCode,
params.treeCount,
));
// 9. 市区域权益 (252 USDT + 2%算力) → 8+市代码
rewards.push(this.calculateCityAreaRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.cityCode,
params.treeCount,
));
// 10. 社区权益 (576 USDT) → S0000000001总部社区
rewards.push(this.calculateCommunityRightToSystemAccount(
params.sourceOrderNo,
params.sourceUserId,
params.treeCount,
));
this.logger.log(
`[calculateRewardsForExpiredContract] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`,
);
return rewards;
}
// ============================================
// 合同超时场景的用户权益计算(进入系统账户)
// ============================================
/**
*
*/
private calculateShareRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.SHARE_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.SHARE_RIGHT,
sourceOrderNo,
sourceUserId,
);
return RewardLedgerEntry.createSettleable({
userId: SHARE_RIGHT_POOL_USER_ID,
accountSequence: 'S0000000005',
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `分享权益:合同超时未签署,进分享权益池`,
});
}
/**
* (7+)
*/
private calculateProvinceTeamRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
provinceCode: string,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_TEAM_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.PROVINCE_TEAM_RIGHT,
sourceOrderNo,
sourceUserId,
);
// 省团队默认账户: 7 + 省代码
const accountSequence = `7${provinceCode}`;
return RewardLedgerEntry.createSettleable({
userId: BigInt(accountSequence),
accountSequence,
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `省团队权益(${provinceCode}):合同超时未签署,进省团队默认账户`,
});
}
/**
* (9+)
*/
private calculateProvinceAreaRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
provinceCode: string,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_AREA_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.PROVINCE_AREA_RIGHT,
sourceOrderNo,
sourceUserId,
);
// 省区域账户: 9 + 省代码
const accountSequence = `9${provinceCode}`;
return RewardLedgerEntry.createSettleable({
userId: BigInt(accountSequence),
accountSequence,
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `省区域权益(${provinceCode}):合同超时未签署,进省区域账户`,
});
}
/**
* (6+)
*/
private calculateCityTeamRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
cityCode: string,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_TEAM_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.CITY_TEAM_RIGHT,
sourceOrderNo,
sourceUserId,
);
// 市团队默认账户: 6 + 市代码
const accountSequence = `6${cityCode}`;
return RewardLedgerEntry.createSettleable({
userId: BigInt(accountSequence),
accountSequence,
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `市团队权益(${cityCode}):合同超时未签署,进市团队默认账户`,
});
}
/**
* (8+)
*/
private calculateCityAreaRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
cityCode: string,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_AREA_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.CITY_AREA_RIGHT,
sourceOrderNo,
sourceUserId,
);
// 市区域账户: 8 + 市代码
const accountSequence = `8${cityCode}`;
return RewardLedgerEntry.createSettleable({
userId: BigInt(accountSequence),
accountSequence,
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `市区域权益(${cityCode}):合同超时未签署,进市区域账户`,
});
}
/**
* (S0000000001)
*/
private calculateCommunityRightToSystemAccount(
sourceOrderNo: string,
sourceUserId: bigint,
treeCount: number,
): RewardLedgerEntry {
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.COMMUNITY_RIGHT];
const usdtAmount = Money.USDT(usdt * treeCount);
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
const rewardSource = RewardSource.create(
RightType.COMMUNITY_RIGHT,
sourceOrderNo,
sourceUserId,
);
return RewardLedgerEntry.createSettleable({
userId: HEADQUARTERS_COMMUNITY_USER_ID,
accountSequence: 'S0000000001',
rewardSource,
usdtAmount,
hashpowerAmount: hashpower,
memo: `社区权益:合同超时未签署,进总部社区`,
});
}
}

View File

@ -103,6 +103,47 @@ export class WalletServiceClient {
}
}
/**
*
* "待分配"
* contract.signed contract.expired
*/
async confirmPlantingDeduction(params: {
userId: string;
accountSequence?: string;
orderId: string;
}): Promise<{ success: boolean; error?: string }> {
try {
this.logger.log(`Confirming planting deduction for order ${params.orderId}`);
const response = await fetch(`${this.baseUrl}/api/v1/wallets/confirm-planting-deduction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this.logger.error(`Failed to confirm deduction for order ${params.orderId}:`, errorData);
return {
success: false,
error: errorData.message || `Confirm deduction failed with status ${response.status}`,
};
}
this.logger.log(`Successfully confirmed deduction for order ${params.orderId}`);
return { success: true };
} catch (error) {
this.logger.error(`Error confirming deduction for order ${params.orderId}:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
*
*

View File

@ -3,6 +3,10 @@ import { MessagePattern, Payload } from '@nestjs/microservices';
import { RewardApplicationService } from '../../application/services/reward-application.service';
import { EventAckPublisher } from './event-ack.publisher';
/**
*
* referral-service contract.signed
*/
interface PlantingOrderPaidEvent {
eventName?: string;
data?: {
@ -30,6 +34,23 @@ interface PlantingOrderPaidEvent {
};
}
/**
*
* referral-service contract.expired
*/
interface PlantingOrderExpiredEvent {
eventName: string;
data: {
orderId: string;
userId: string;
accountSequence: string;
treeCount: number;
provinceCode: string;
cityCode: string;
expiredAt: string;
};
}
@Controller()
export class EventConsumerController {
private readonly logger = new Logger(EventConsumerController.name);
@ -41,6 +62,9 @@ export class EventConsumerController {
/**
*
*
* referral-service contract.signed
*
*/
@MessagePattern('planting.order.paid')
async handlePlantingOrderPaid(@Payload() message: PlantingOrderPaidEvent) {
@ -57,32 +81,27 @@ export class EventConsumerController {
paidAt: message.paidAt!,
};
// 优先使用 accountSequence如果未提供则使用 userId
const userIdentifier = eventData.accountSequence || eventData.userId;
this.logger.log(`Processing event with userIdentifier: ${userIdentifier} (accountSequence: ${eventData.accountSequence}, userId: ${eventData.userId})`);
// B方案提取 outbox 信息用于发送确认
const outboxInfo = message._outbox;
const eventId = outboxInfo?.aggregateId || eventData.orderId;
try {
// 1. 计算并分配奖励
// 1. 计算并分配奖励正常流程100% 不变)
await this.rewardService.distributeRewards({
sourceOrderNo: eventData.orderId, // orderId 实际是 orderNo 字符串格式
sourceOrderNo: eventData.orderId,
sourceUserId: BigInt(eventData.userId),
sourceAccountSequence: userIdentifier, // 优先使用 accountSequence
sourceAccountSequence: eventData.accountSequence,
treeCount: eventData.treeCount,
provinceCode: eventData.provinceCode,
cityCode: eventData.cityCode,
});
// 2. 检查该用户是否有待领取奖励需要转为可结算
// 使用 accountSequence 查找,因为奖励是按 accountSequence 存储的
if (eventData.accountSequence) {
await this.rewardService.claimPendingRewardsForAccountSequence(eventData.accountSequence);
}
this.logger.log(`Successfully processed planting.order.paid for order ${eventData.orderId}`);
this.logger.log(`Successfully distributed rewards for order ${eventData.orderId}`);
// B方案发送处理成功确认
if (outboxInfo) {
@ -100,4 +119,42 @@ export class EventConsumerController {
throw error;
}
}
/**
*
*
* referral-service contract.expired
*
* -
* -
*/
@MessagePattern('planting.order.expired')
async handlePlantingOrderExpired(@Payload() message: PlantingOrderExpiredEvent) {
this.logger.log(`Received planting.order.expired event: ${JSON.stringify(message)}`);
const eventData = message.data;
try {
this.logger.warn(
`Order ${eventData.orderId} expired (contract not signed within 24h), ` +
`accountSequence: ${eventData.accountSequence}, expiredAt: ${eventData.expiredAt}. ` +
`Starting system account distribution (user rights go to default system accounts)...`,
);
// 分配奖励到系统账户(用户权益进入系统默认账户)
await this.rewardService.distributeRewardsForExpiredContract({
sourceOrderNo: eventData.orderId,
sourceUserId: BigInt(eventData.userId),
sourceAccountSequence: eventData.accountSequence,
treeCount: eventData.treeCount,
provinceCode: eventData.provinceCode,
cityCode: eventData.cityCode,
});
this.logger.log(`Successfully distributed rewards to system accounts for expired order ${eventData.orderId}`);
} catch (error) {
this.logger.error(`Error processing planting.order.expired for order ${eventData.orderId}:`, error);
throw error;
}
}
}