fix(contract-signing): 修复合同签署流程的持仓更新时机

问题:支付后直接更新持仓和开启挖矿,导致款还在冻结中树就种下去了

修复:
- planting-application.service: 支付时不再更新持仓和开启挖矿
- contract-signing.service: signContract 在事务里同时完成合同+持仓+挖矿
- contract-signing.service: handleExpiredTasks 超时也更新持仓+挖矿(钱扣总部)
- KYCVerifiedEvent 添加 accountSequence 字段
- kyc-verified-event.consumer 直接用事件里的 accountSequence

流程变为:支付冻结 → 签署合同 → [事务: 合同+持仓+挖矿] → 发事件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-25 06:50:03 -08:00
parent 2e0df30473
commit 8163804f23
8 changed files with 119 additions and 37 deletions

View File

@ -405,7 +405,9 @@
"Bash(git branch:*)",
"Bash(echo \"docker exec rwa-planting-service npx prisma db execute --stdin <<< \"\"SELECT template_id, version, title, is_active FROM contract_templates;\"\"\")",
"Bash(npm uninstall:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(contract\\): 使用合同编号代替订单号\n\n合同编号格式: accountSequence-yyyyMMddHHmm\n例如: 10001-202512251003\n\n修改内容:\n- 数据库: 添加 contract_no 字段\n- 后端: 聚合根、Repository、Service、PDF生成器支持 contractNo\n- 前端: 显示合同编号代替订单号\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(contract\\): 使用合同编号代替订单号\n\n合同编号格式: accountSequence-yyyyMMddHHmm\n例如: 10001-202512251003\n\n修改内容:\n- 数据库: 添加 contract_no 字段\n- 后端: 聚合根、Repository、Service、PDF生成器支持 contractNo\n- 前端: 显示合同编号代替订单号\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(npx ts-node:*)",
"Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0xab2ecb00ef473cfbbf3df13faa7ed3ff84eea229'';\n\nasync function transfer\\(\\) {\n const provider = new ethers.JsonRpcProvider\\(KAVA_TESTNET_RPC\\);\n const wallet = new ethers.Wallet\\(privateKey, provider\\);\n \n const abi = [''function transfer\\(address to, uint256 amount\\) returns \\(bool\\)'', ''function balanceOf\\(address\\) view returns \\(uint256\\)''];\n const contract = new ethers.Contract\\(USDT_CONTRACT, abi, wallet\\);\n \n const amount = BigInt\\(1000000\\) * BigInt\\(1000000\\);\n \n console.log\\(''Transferring 1,000,000 USDT to'', TO_ADDRESS\\);\n const tx = await contract.transfer\\(TO_ADDRESS, amount, { gasLimit: 100000 }\\);\n console.log\\(''TX Hash:'', tx.hash\\);\n await tx.wait\\(\\);\n \n const newBalance = await contract.balanceOf\\(TO_ADDRESS\\);\n console.log\\(''New balance:'', Number\\(newBalance\\) / 1e6, ''USDT''\\);\n}\n\ntransfer\\(\\).catch\\(e => console.error\\(''Error:'', e.message\\)\\);\n\")"
],
"deny": [],
"ask": []

View File

@ -425,6 +425,7 @@ export class UserAccount {
this.addDomainEvent(
new KYCVerifiedEvent({
userId: this.userId.toString(),
accountSequence: this.accountSequence.value,
verifiedAt: new Date(),
}),
);

View File

@ -136,7 +136,7 @@ export class KYCSubmittedEvent extends DomainEvent {
}
export class KYCVerifiedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
constructor(public readonly payload: { userId: string; accountSequence: string; verifiedAt: Date }) {
super();
}

View File

@ -4,6 +4,8 @@ import {
CONTRACT_TEMPLATE_REPOSITORY,
IContractSigningTaskRepository,
CONTRACT_SIGNING_TASK_REPOSITORY,
IPlantingOrderRepository,
PLANTING_ORDER_REPOSITORY,
} from '../../domain/repositories';
import {
ContractTemplate,
@ -12,6 +14,7 @@ import {
} from '../../domain/aggregates';
import { ContractSigningStatus } from '../../domain/value-objects';
import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service';
import { UnitOfWork, UNIT_OF_WORK } from '../../infrastructure/persistence/unit-of-work';
/**
*
@ -71,6 +74,10 @@ export class ContractSigningService {
private readonly templateRepo: IContractTemplateRepository,
@Inject(CONTRACT_SIGNING_TASK_REPOSITORY)
private readonly taskRepo: IContractSigningTaskRepository,
@Inject(PLANTING_ORDER_REPOSITORY)
private readonly orderRepo: IPlantingOrderRepository,
@Inject(UNIT_OF_WORK)
private readonly unitOfWork: UnitOfWork,
private readonly eventPublisher: EventPublisherService,
) {}
@ -200,7 +207,8 @@ export class ContractSigningService {
/**
*
* contract.signed reward-service
* + +
* contract.signed
*/
async signContract(
orderNo: string,
@ -212,11 +220,52 @@ export class ContractSigningService {
throw new Error('签署任务不存在');
}
// 幂等性检查:如果已签署,直接返回
if (task.status === ContractSigningStatus.SIGNED) {
this.logger.log(`Contract already signed for order: ${orderNo}`);
return;
}
// 获取订单
const order = await this.orderRepo.findByOrderNo(orderNo);
if (!order) {
throw new Error('订单不存在');
}
// 幂等性检查:如果订单已开启挖矿,直接返回
if (order.isMiningEnabled) {
this.logger.log(`Mining already enabled for order: ${orderNo}`);
return;
}
const selection = order.provinceCitySelection;
if (!selection) {
throw new Error('订单缺少省市选择信息');
}
// 在同一个事务里完成所有操作
await this.unitOfWork.executeInTransaction(async (uow) => {
// 1. 更新合同状态
task.sign(params);
await this.taskRepo.save(task);
this.logger.log(`User ${userId} signed contract for order: ${orderNo}`);
// 发布合同签署完成事件,触发奖励分配
// 2. 更新用户持仓
const position = await uow.getOrCreatePosition(userId);
position.addPlanting(
order.treeCount.value,
selection.provinceCode,
selection.cityCode,
);
await uow.savePosition(position);
// 3. 开启挖矿
order.enableMining();
await uow.saveOrder(order);
});
this.logger.log(`User ${userId} signed contract for order: ${orderNo}, position updated, mining enabled`);
// 事务成功后发布事件(给 reward-service
await this.eventPublisher.publishContractSigned({
orderNo: task.orderNo,
userId: task.userId.toString(),
@ -250,7 +299,8 @@ export class ContractSigningService {
/**
*
*
* contract.expired reward-service
* + +
* contract.expired reward-service
*/
async handleExpiredTasks(): Promise<number> {
const expiredTasks = await this.taskRepo.findExpiredPendingTasks();
@ -258,10 +308,46 @@ export class ContractSigningService {
for (const task of expiredTasks) {
try {
// 获取订单
const order = await this.orderRepo.findByOrderNo(task.orderNo);
if (!order) {
this.logger.error(`Order not found for expired task: ${task.orderNo}`);
continue;
}
// 幂等性检查
if (order.isMiningEnabled) {
this.logger.log(`Mining already enabled for expired order: ${task.orderNo}`);
continue;
}
const selection = order.provinceCitySelection;
if (!selection) {
this.logger.error(`Order ${task.orderNo} has no province/city selection`);
continue;
}
// 在同一个事务里完成所有操作
await this.unitOfWork.executeInTransaction(async (uow) => {
// 1. 更新合同状态为超时
task.markAsTimeout();
await this.taskRepo.save(task);
// 发布合同超时事件,触发系统账户奖励分配
// 2. 更新用户持仓(树还是种了,权益归总部)
const position = await uow.getOrCreatePosition(task.userId);
position.addPlanting(
order.treeCount.value,
selection.provinceCode,
selection.cityCode,
);
await uow.savePosition(position);
// 3. 开启挖矿
order.enableMining();
await uow.saveOrder(order);
});
// 事务成功后发布合同超时事件,触发系统账户奖励分配
await this.eventPublisher.publishContractExpired({
orderNo: task.orderNo,
userId: task.userId.toString(),
@ -274,7 +360,7 @@ export class ContractSigningService {
});
count++;
this.logger.log(`Marked task as timeout and published contract.expired: orderNo=${task.orderNo}`);
this.logger.log(`Expired task processed: orderNo=${task.orderNo}, position updated, mining enabled`);
} catch (error) {
this.logger.error(`Failed to handle expired task: orderNo=${task.orderNo}`, error);
}

View File

@ -257,23 +257,15 @@ export class PlantingApplicationService {
order.markAsPaid(accountSequence || '');
// 7. 使用事务保存本地数据库的所有变更 + Outbox事件
// 这确保了订单状态、用户持仓、批次数据、以及事件发布的原子性
// 这确保了订单状态和事件发布的原子性
// 注意:用户持仓更新和挖矿开启在合同签署完成后执行
await this.unitOfWork.executeInTransaction(async (uow) => {
// 保存订单状态
// 保存订单状态(此时为 PAID 状态,资金冻结中)
await uow.saveOrder(order);
// 更新用户持仓(直接生效)
const position = await uow.getOrCreatePosition(userId);
position.addPlanting(
order.treeCount.value,
selection.provinceCode,
selection.cityCode,
);
await uow.savePosition(position);
// 直接开启挖矿(跳过底池注入流程)
order.enableMining();
await uow.saveOrder(order);
// 注意:不在此处更新用户持仓和开启挖矿
// 持仓更新和挖矿开启在合同签署完成后由 contract.signed 事件触发
// 这确保了正确的业务流程:支付冻结 -> 签署合同 -> 确认扣款 -> 更新持仓
// 8. 添加 Outbox 事件(在同一事务中保存)
// 使用 Outbox Pattern 保证事件发布的可靠性

View File

@ -17,6 +17,7 @@ interface KYCVerifiedEventMessage {
aggregateId: string;
payload: {
userId: string;
accountSequence: string;
verifiedAt: string;
};
_outbox?: {
@ -49,20 +50,17 @@ export class KycVerifiedEventConsumer {
@EventPattern('identity.KYCVerified')
async handleKycVerified(@Payload() message: KYCVerifiedEventMessage): Promise<void> {
const userId = message.payload?.userId || message.aggregateId;
const accountSequence = message.payload?.accountSequence;
this.logger.log(`[KYC-VERIFIED] Received KYCVerified event for user: ${userId}`);
this.logger.log(`[KYC-VERIFIED] Received KYCVerified event for user: ${userId}, accountSequence: ${accountSequence}`);
try {
// 1. 获取用户的 accountSequence从 identity-service
const userDetail = await this.identityServiceClient.getUserDetailBySequence(userId);
if (!userDetail) {
// userId 可能是数字 ID尝试查找
this.logger.warn(`[KYC-VERIFIED] Could not find user detail for: ${userId}`);
if (!accountSequence) {
this.logger.error(`[KYC-VERIFIED] Missing accountSequence in event payload for userId: ${userId}`);
return;
}
const accountSequence = userDetail.accountSequence;
const userIdBigint = BigInt(userDetail.userId);
try {
const userIdBigint = BigInt(userId);
this.logger.log(
`[KYC-VERIFIED] User ${accountSequence} completed KYC, checking for pending contracts...`,

View File

@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'kyc_entry_page.dart';
import '../../../home/presentation/pages/home_shell_page.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../routes/route_paths.dart';
/// KYC 1: (: ++)
class KycIdPage extends ConsumerStatefulWidget {