diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 499e70da..9340b45d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 \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 \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": [] diff --git a/backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts b/backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts index 49bec9cc..8f2c12f3 100644 --- a/backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts +++ b/backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts @@ -425,6 +425,7 @@ export class UserAccount { this.addDomainEvent( new KYCVerifiedEvent({ userId: this.userId.toString(), + accountSequence: this.accountSequence.value, verifiedAt: new Date(), }), ); diff --git a/backend/services/identity-service/src/domain/events/index.ts b/backend/services/identity-service/src/domain/events/index.ts index 38a5ce0b..6fa8b298 100644 --- a/backend/services/identity-service/src/domain/events/index.ts +++ b/backend/services/identity-service/src/domain/events/index.ts @@ -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(); } diff --git a/backend/services/planting-service/src/application/services/contract-signing.service.ts b/backend/services/planting-service/src/application/services/contract-signing.service.ts index e11435f6..52fa660d 100644 --- a/backend/services/planting-service/src/application/services/contract-signing.service.ts +++ b/backend/services/planting-service/src/application/services/contract-signing.service.ts @@ -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('签署任务不存在'); } - task.sign(params); - await this.taskRepo.save(task); - this.logger.log(`User ${userId} signed contract for order: ${orderNo}`); + // 幂等性检查:如果已签署,直接返回 + 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); + + // 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 { const expiredTasks = await this.taskRepo.findExpiredPendingTasks(); @@ -258,10 +308,46 @@ export class ContractSigningService { for (const task of expiredTasks) { try { - task.markAsTimeout(); - await this.taskRepo.save(task); + // 获取订单 + 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); } diff --git a/backend/services/planting-service/src/application/services/planting-application.service.ts b/backend/services/planting-service/src/application/services/planting-application.service.ts index 72fec8e5..92744f1d 100644 --- a/backend/services/planting-service/src/application/services/planting-application.service.ts +++ b/backend/services/planting-service/src/application/services/planting-application.service.ts @@ -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 保证事件发布的可靠性 diff --git a/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts b/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts index ec3ca6af..4437e3f4 100644 --- a/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts +++ b/backend/services/planting-service/src/infrastructure/kafka/kyc-verified-event.consumer.ts @@ -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 { 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}`); + + if (!accountSequence) { + this.logger.error(`[KYC-VERIFIED] Missing accountSequence in event payload for userId: ${userId}`); + return; + } try { - // 1. 获取用户的 accountSequence(从 identity-service) - const userDetail = await this.identityServiceClient.getUserDetailBySequence(userId); - if (!userDetail) { - // userId 可能是数字 ID,尝试查找 - this.logger.warn(`[KYC-VERIFIED] Could not find user detail for: ${userId}`); - return; - } - - const accountSequence = userDetail.accountSequence; - const userIdBigint = BigInt(userDetail.userId); + const userIdBigint = BigInt(userId); this.logger.log( `[KYC-VERIFIED] User ${accountSequence} completed KYC, checking for pending contracts...`, diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart index 589421ec..f5245a45 100644 --- a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart @@ -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 { diff --git a/榴莲树认种权益协议.pdf b/榴莲树认种权益协议_form.pdf similarity index 88% rename from 榴莲树认种权益协议.pdf rename to 榴莲树认种权益协议_form.pdf index 0c369945..3fa5219b 100644 Binary files a/榴莲树认种权益协议.pdf and b/榴莲树认种权益协议_form.pdf differ