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:
parent
2e0df30473
commit
8163804f23
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ export class UserAccount {
|
|||
this.addDomainEvent(
|
||||
new KYCVerifiedEvent({
|
||||
userId: this.userId.toString(),
|
||||
accountSequence: this.accountSequence.value,
|
||||
verifiedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 保证事件发布的可靠性
|
||||
|
|
|
|||
|
|
@ -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...`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue