# 树转让功能详细实施方案 > 版本:v1.0 | 日期:2026-02-18 | 状态:待审批 --- ## 一、概述与设计原则 ### 1.1 功能目标 实现已认种树(状态为 `MINING_ENABLED`)在用户间的所有权转让,包括: - 持仓变更(planting-service) - 算力增量调整(contribution-service) - 团队统计增减(referral-service) - 资金结算(wallet-service) ### 1.2 核心设计原则 | # | 原则 | 说明 | |---|------|------| | 1 | **纯新增** | 各现有服务只新增事件消费者、方法、数据表,不修改已有业务逻辑 | | 2 | **事务流水型** | 所有算力变动通过追加新流水记录实现,历史记录零修改、零删除 | | 3 | **保持原始值** | 转让时沿用树的原始 `contributionPerTree`,不按转让日重算 | | 4 | **历史不追回** | 已分配给卖方链路的历史算力记录保持不变,只从转让时刻起追加对冲/新增流水 | | 5 | **可回滚** | Saga 编排每一步都有对应的补偿操作 | | 6 | **奖励不重分** | reward-service 的历史分配记录(USDT 权益)不受转让影响 | | 7 | **运营算力不动** | 仅运营账户 12% 为全局固定分配不受影响;省公司 1% 和市公司 2% 需随所有权变更调整 | | 8 | **快照自动适配** | 挖矿快照每日按最新 effectiveContribution 全量生成,无需特殊处理 | ### 1.3 转让单位 - 转让以 **PlantingOrder(认种订单)** 为单位 - 支持整单转让(全部树)和部分转让(订单内指定棵数) - 部分转让时,原订单不修改,通过 TransferRecord 追踪已转出的棵数 ### 1.4 转让资格 - 卖方:树状态为 `MINING_ENABLED` 且未被锁定 - 买方:已完成实名认证的平台用户 - 同一棵树可多次转让(每次创建新的转让记录) --- ## 二、transfer-service 新微服务设计 ### 2.1 服务概要 | 项目 | 值 | |------|-----| | 服务名 | transfer-service | | 容器名 | rwa-transfer-service | | 端口 | 3013 | | Redis DB | 12 | | 数据库 | rwa_transfer | | 技术栈 | NestJS 10 + Prisma 5.7 + PostgreSQL 16 | | Kafka Consumer Group | transfer-service-group | ### 2.2 数据模型 #### TransferOrder(转让订单 - Saga 聚合根) ```prisma model TransferOrder { id BigInt @id @default(autoincrement()) transferOrderNo String @unique @map("transfer_order_no") @db.VarChar(50) // ========== 卖方信息 ========== sellerUserId BigInt @map("seller_user_id") sellerAccountSequence String @map("seller_account_sequence") @db.VarChar(20) // ========== 买方信息 ========== buyerUserId BigInt @map("buyer_user_id") buyerAccountSequence String @map("buyer_account_sequence") @db.VarChar(20) // ========== 转让标的 ========== sourceOrderNo String @map("source_order_no") @db.VarChar(50) // 原认种订单号 sourceAdoptionId BigInt @map("source_adoption_id") // 原认种ID treeCount Int @map("tree_count") // 转让棵数 contributionPerTree Decimal @map("contribution_per_tree") @db.Decimal(20, 10) // 原始每棵算力值 originalAdoptionDate DateTime @map("original_adoption_date") @db.Date // 原始认种日期 originalExpireDate DateTime @map("original_expire_date") @db.Date // 原始算力到期日 selectedProvince String @map("selected_province") @db.VarChar(10) // 树所在省 selectedCity String @map("selected_city") @db.VarChar(10) // 树所在市 // ========== 价格与费用 ========== transferPrice Decimal @map("transfer_price") @db.Decimal(20, 8) // 转让总价(USDT) platformFeeRate Decimal @map("platform_fee_rate") @db.Decimal(5, 4) // 平台手续费率 platformFeeAmount Decimal @map("platform_fee_amount") @db.Decimal(20, 8) // 平台手续费 sellerReceiveAmount Decimal @map("seller_receive_amount") @db.Decimal(20, 8) // 卖方实收 // ========== Saga 状态 ========== status String @default("PENDING") @map("status") @db.VarChar(30) sagaStep String @default("INIT") @map("saga_step") @db.VarChar(30) failReason String? @map("fail_reason") @db.VarChar(500) retryCount Int @default(0) @map("retry_count") // ========== 各步骤确认时间戳 ========== sellerConfirmedAt DateTime? @map("seller_confirmed_at") paymentFrozenAt DateTime? @map("payment_frozen_at") treesLockedAt DateTime? @map("trees_locked_at") ownershipTransferredAt DateTime? @map("ownership_transferred_at") contributionAdjustedAt DateTime? @map("contribution_adjusted_at") statsUpdatedAt DateTime? @map("stats_updated_at") paymentSettledAt DateTime? @map("payment_settled_at") completedAt DateTime? @map("completed_at") cancelledAt DateTime? @map("cancelled_at") rolledBackAt DateTime? @map("rolled_back_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([sellerUserId]) @@index([buyerUserId]) @@index([sourceOrderNo]) @@index([status]) @@index([createdAt]) @@map("transfer_orders") } ``` #### TransferStatusLog(状态变更日志 - 审计) ```prisma model TransferStatusLog { id BigInt @id @default(autoincrement()) transferOrderNo String @map("transfer_order_no") @db.VarChar(50) fromStatus String @map("from_status") @db.VarChar(30) toStatus String @map("to_status") @db.VarChar(30) fromSagaStep String @map("from_saga_step") @db.VarChar(30) toSagaStep String @map("to_saga_step") @db.VarChar(30) operatorType String @map("operator_type") @db.VarChar(20) // USER / SYSTEM / ADMIN operatorId String? @map("operator_id") @db.VarChar(50) remark String? @db.VarChar(500) createdAt DateTime @default(now()) @map("created_at") @@index([transferOrderNo]) @@map("transfer_status_logs") } ``` #### OutboxEvent(复用标准 Outbox 结构) 与其他服务完全相同的 Outbox 表结构。 ### 2.3 Saga 状态机 #### TransferOrderStatus 枚举 ``` PENDING - 待卖方确认 SELLER_CONFIRMED - 卖方已确认 PAYMENT_FROZEN - 买方资金已冻结 TREES_LOCKED - 卖方树已锁定 OWNERSHIP_TRANSFERRED - 所有权已变更 CONTRIBUTION_ADJUSTED - 算力已调整 STATS_UPDATED - 团队统计已更新 PAYMENT_SETTLED - 资金已结算 COMPLETED - 转让完成 CANCELLED - 已取消(PENDING/SELLER_CONFIRMED 阶段可取消) FAILED - 失败 ROLLING_BACK - 补偿中 ROLLED_BACK - 已回滚 ``` #### SagaStep 枚举(编排步骤) ``` INIT - 初始化 FREEZE_BUYER_PAYMENT - 冻结买方资金 LOCK_SELLER_TREES - 锁定卖方树 TRANSFER_OWNERSHIP - 变更所有权 ADJUST_CONTRIBUTION - 调整算力 UPDATE_TEAM_STATS - 更新团队统计 SETTLE_PAYMENT - 结算资金 FINALIZE - 完成 COMPENSATE_UNLOCK_TREES - 补偿:解锁树 COMPENSATE_UNFREEZE - 补偿:解冻资金 ``` #### 状态机流转图 ``` 用户发起转让 ↓ PENDING (INIT) ↓ 卖方确认 SELLER_CONFIRMED (FREEZE_BUYER_PAYMENT) ↓ 冻结买方资金 → wallet-service PAYMENT_FROZEN (LOCK_SELLER_TREES) ↓ 锁定卖方树 → planting-service TREES_LOCKED (TRANSFER_OWNERSHIP) ↓ 变更所有权 → planting-service(发布事件) OWNERSHIP_TRANSFERRED (ADJUST_CONTRIBUTION) ↓ 等待 contribution-service 确认(事件驱动) CONTRIBUTION_ADJUSTED (UPDATE_TEAM_STATS) ↓ 等待 referral-service 确认(事件驱动) STATS_UPDATED (SETTLE_PAYMENT) ↓ 结算资金 → wallet-service PAYMENT_SETTLED (FINALIZE) ↓ COMPLETED 失败补偿路径(任何步骤失败): FAILED → ROLLING_BACK → COMPENSATE_UNLOCK_TREES(如果树已锁定) → COMPENSATE_UNFREEZE(如果资金已冻结) → ROLLED_BACK ``` ### 2.4 API 设计 #### 用户端 API ``` POST /transfers - 发起转让(买方向卖方购买) POST /transfers/:id/seller-confirm - 卖方确认 POST /transfers/:id/cancel - 取消转让 GET /transfers - 查询我的转让记录(买/卖) GET /transfers/:id - 转让详情 ``` #### 管理端 API ``` GET /admin/transfers - 转让列表(筛选、分页) GET /admin/transfers/:id - 转让详情 GET /admin/transfers/stats - 转让统计 POST /admin/transfers/:id/force-cancel - 强制取消 ``` ### 2.5 转让号生成规则 ``` TRF + YYMMDD + 随机字符串(8位) 示例:TRF260218A3B7C9D1 ``` --- ## 三、planting-service 纯新增设计 ### 3.1 新增数据表 #### PlantingTransferRecord(转让记录) ```prisma model PlantingTransferRecord { id BigInt @id @default(autoincrement()) transferOrderNo String @unique @map("transfer_order_no") @db.VarChar(50) // ========== 原订单信息 ========== sourceOrderNo String @map("source_order_no") @db.VarChar(50) sourceOrderId BigInt @map("source_order_id") // ========== 卖方 ========== fromUserId BigInt @map("from_user_id") fromAccountSequence String @map("from_account_sequence") @db.VarChar(20) // ========== 买方 ========== toUserId BigInt @map("to_user_id") toAccountSequence String @map("to_account_sequence") @db.VarChar(20) // ========== 转让内容 ========== treeCount Int @map("tree_count") selectedProvince String @map("selected_province") @db.VarChar(10) selectedCity String @map("selected_city") @db.VarChar(10) contributionPerTree Decimal @map("contribution_per_tree") @db.Decimal(20, 10) originalAdoptionDate DateTime @map("original_adoption_date") @db.Date originalExpireDate DateTime @map("original_expire_date") @db.Date // ========== 状态 ========== status String @default("LOCKED") @map("status") @db.VarChar(20) // LOCKED → TRANSFERRED → ROLLED_BACK lockedAt DateTime? @map("locked_at") transferredAt DateTime? @map("transferred_at") rolledBackAt DateTime? @map("rolled_back_at") createdAt DateTime @default(now()) @map("created_at") @@index([sourceOrderNo]) @@index([fromUserId]) @@index([toUserId]) @@index([status]) @@map("planting_transfer_records") } ``` ### 3.2 PlantingOrder 新增字段 在 PlantingOrder 表上新增一个字段用于标记转让锁定状态(通过 Prisma migration 新增列): ```prisma // 新增字段(可空,不影响现有数据) transferLockedCount Int @default(0) @map("transfer_locked_count") // 被锁定的转让棵数 ``` **可转让棵数** = `treeCount - transferLockedCount - 已完成转让的棵数` 已完成转让的棵数通过查询 `PlantingTransferRecord` 表(status = TRANSFERRED)聚合得到。 ### 3.3 新增事件消费处理器 #### transfer-lock.handler.ts(锁定树) - **消费 Topic**:`transfer.trees.lock` - **处理逻辑**: 1. 验证卖方持有该订单且有足够可转让棵数 2. 创建 `PlantingTransferRecord`(status = LOCKED) 3. 更新 `PlantingOrder.transferLockedCount += treeCount` 4. 发布确认事件到 `transfer.trees.lock.ack` #### transfer-execute.handler.ts(执行所有权变更) - **消费 Topic**:`transfer.ownership.execute` - **处理逻辑**: 1. 查找对应的 `PlantingTransferRecord` 2. 更新 `PlantingTransferRecord.status = TRANSFERRED` 3. 更新卖方 `PlantingPosition`: - `effectiveTreeCount -= treeCount` - `totalTreeCount -= treeCount` 4. 更新卖方 `PositionDistribution`(对应省市的持仓数 -= treeCount) 5. 更新/创建买方 `PlantingPosition`: - `effectiveTreeCount += treeCount` - `totalTreeCount += treeCount` 6. 更新/创建买方 `PositionDistribution`(对应省市的持仓数 += treeCount) 7. 发布 Outbox 事件(3 个): - `planting.transfer.completed`(通知 transfer-service) - `planting.ownership.removed`(通知 contribution + referral) - `planting.ownership.added`(通知 contribution + referral) #### transfer-rollback.handler.ts(回滚锁定) - **消费 Topic**:`transfer.trees.unlock` - **处理逻辑**: 1. 查找对应的 `PlantingTransferRecord` 2. 更新 `PlantingTransferRecord.status = ROLLED_BACK` 3. 更新 `PlantingOrder.transferLockedCount -= treeCount` 4. 发布确认事件到 `transfer.trees.unlock.ack` ### 3.4 新增 Outbox 事件 | 事件 | Topic | AggregateType | Payload | |------|-------|---------------|---------| | `PlantingTransferLocked` | `planting.transfer.locked` | `PlantingTransfer` | transferOrderNo, sourceOrderNo, treeCount | | `PlantingTransferCompleted` | `planting.transfer.completed` | `PlantingTransfer` | transferOrderNo, 完整卖方/买方信息 | | `PlantingOwnershipRemoved` | `planting.ownership.removed` | `PlantingTransfer` | 卖方信息 + 树信息 | | `PlantingOwnershipAdded` | `planting.ownership.added` | `PlantingTransfer` | 买方信息 + 树信息 | | `PlantingTransferRolledBack` | `planting.transfer.rolledback` | `PlantingTransfer` | transferOrderNo | --- ## 四、referral-service 纯新增设计 ### 4.1 新增事件处理器 #### planting-transferred.handler.ts - **消费 Topic**:`planting.ownership.removed` + `planting.ownership.added` - **Consumer Group**:`referral-service-transfer` ### 4.2 处理 planting.ownership.removed(卖方减少) ```typescript async handleOwnershipRemoved(event: PlantingOwnershipRemovedEvent): Promise { // 1. 更新卖方个人认种统计 const sellerStats = await this.teamStatsRepo.findByUserId(event.sellerUserId); sellerStats.removePersonalPlanting(event.treeCount, event.provinceCode, event.cityCode); // selfPlantingCount -= treeCount // selfPlantingAmount -= treeCount * pricePerTree // provinceCityDistribution 对应省市 -= treeCount await this.teamStatsRepo.save(sellerStats); // 2. 获取卖方的上级链 const relationship = await this.referralRepo.findByUserId(event.sellerUserId); const ancestors = relationship.getAllAncestorIds(); // 3. 批量更新上级团队统计(负数 delta) const updates = ancestors.map((ancestorId, i) => ({ userId: ancestorId, countDelta: -event.treeCount, // 关键:负数 provinceCode: event.provinceCode, cityCode: event.cityCode, fromDirectReferralId: i === 0 ? event.sellerUserId : ancestors[0], })); await this.teamStatsRepo.batchUpdateTeamCountsForTransfer(updates); // totalTeamPlantingCount -= treeCount // DirectReferral.teamPlantingCount -= treeCount(increment 负数) // 重新扫描所有直推的 teamPlantingCount 找新 max(关键修正) // effectivePlantingCountForRanking = totalTeamPlantingCount - newMax } ``` ### 4.3 处理 planting.ownership.added(买方增加) ```typescript async handleOwnershipAdded(event: PlantingOwnershipAddedEvent): Promise { // 1. 更新买方个人认种统计 const buyerStats = await this.teamStatsRepo.findByUserId(event.buyerUserId); buyerStats.addPersonalPlanting(event.treeCount, event.provinceCode, event.cityCode); await this.teamStatsRepo.save(buyerStats); // 2. 获取买方的上级链 const relationship = await this.referralRepo.findByUserId(event.buyerUserId); const ancestors = relationship.getAllAncestorIds(); // 3. 批量更新上级团队统计(正数 delta,复用现有 batchUpdateTeamCounts) const updates = ancestors.map((ancestorId, i) => ({ userId: ancestorId, countDelta: event.treeCount, // 正数 provinceCode: event.provinceCode, cityCode: event.cityCode, fromDirectReferralId: i === 0 ? event.buyerUserId : ancestors[0], })); await this.teamStatsRepo.batchUpdateTeamCounts(updates); // 复用现有方法 } ``` ### 4.4 新增方法 #### TeamStatistics.removePersonalPlanting()(聚合根新增方法) ```typescript removePersonalPlanting(count: number, provinceCode: string, cityCode: string): void { this._selfPlantingCount = Math.max(0, this._selfPlantingCount - count); this._selfPlantingAmount = this._selfPlantingAmount.minus(count * PRICE_PER_TREE); // 更新省市分布 const dist = this._provinceCityDistribution; if (dist[provinceCode]?.[cityCode]) { dist[provinceCode][cityCode] = Math.max(0, dist[provinceCode][cityCode] - count); } this._updatedAt = new Date(); } ``` #### TeamStatisticsRepository.batchUpdateTeamCountsForTransfer()(新增方法) 与现有 `batchUpdateTeamCounts` 逻辑类似,但在处理负数 delta 时: ```typescript // 关键差异:负数 delta 时重新扫描所有直推找 max if (update.countDelta < 0) { // 更新直推的 teamPlantingCount(允许负数 increment) await tx.directReferral.updateMany({ where: { referrerId: update.userId, referralId: update.fromDirectReferralId }, data: { teamPlantingCount: { increment: update.countDelta } }, }); // 重新扫描所有直推的 teamPlantingCount 找最大值 const allDirectReferrals = await tx.directReferral.findMany({ where: { referrerId: update.userId }, select: { teamPlantingCount: true }, }); const newMax = Math.max(0, ...allDirectReferrals.map(r => r.teamPlantingCount)); const newTotal = currentStats.totalTeamPlantingCount + update.countDelta; const newEffective = Math.max(0, newTotal - newMax); await tx.teamStatistics.update({ where: { userId: update.userId }, data: { totalTeamPlantingCount: Math.max(0, newTotal), maxSingleTeamPlantingCount: newMax, effectivePlantingCountForRanking: newEffective, }, }); } ``` ### 4.5 发布确认事件 处理完成后发布事件到 `referral.transfer.stats-updated`,供 transfer-service 确认 Saga 步骤。 --- ## 五、contribution-service 纯新增设计(核心) ### 5.1 算力调整原则 ``` 原则一:历史记录不修改、不删除 原则二:所有调整通过追加新流水记录实现(对冲式) 原则三:保持原始 contributionPerTree,沿用原认种参数 原则四:新记录的 expireDate = 原始认种的 expireDate(剩余有效期) 原则五:解锁状态需要更新(影响未来新认种的分配,但不追回历史) 原则六:调整 88%(个人70% + 团队层级7.5% + 个人加成7.5% + 省1% + 市2%),仅运营12% 不动 原则七:挖矿快照每日按最新 effectiveContribution 自动生成,无需干预 ``` ### 5.1.1 算力影响范围明确界定 | 算力类别 | 比例 | 分配对象 | 转让时需调整? | 原因 | |---------|------|---------|:---:|------| | 个人算力 | 70% | 认种人 | **是** | 所有权转移,算力跟着树走 | | 团队层级 | 7.5% | 认种人 15 级上线 | **是** | 上线链从卖方链路换成买方链路 | | 个人加成 | 7.5% | 认种人(T1/T2/T3) | **是** | 所有权转移,加成跟着树走 | | 省公司 | 1% | 按树所在省的系统账户 | **是** | 所有权变更,省公司授权体系随新所有者调整 | | 市公司 | 2% | 按树所在市的系统账户 | **是** | 所有权变更,市公司授权体系随新所有者调整 | | 运营账户 | 12% | OPERATION 系统账户 | **否** | 全局固定分配,与用户和地域无关 | **结论**:转让需处理 88% 的算力调整,仅运营账户 12% 完全不受影响。 ### 5.2 新增 sourceType 枚举值 在现有 ContributionRecord 的 sourceType 基础上新增: ``` // 转让转出(卖方侧,金额为负) TRANSFER_OUT_PERSONAL - 卖方个人算力转出 TRANSFER_OUT_TEAM_LEVEL - 卖方上线团队层级算力转出 TRANSFER_OUT_BONUS - 卖方个人加成算力转出 // 转让转入(买方侧,金额为正) TRANSFER_IN_PERSONAL - 买方个人算力转入 TRANSFER_IN_TEAM_LEVEL - 买方上线团队层级算力转入 TRANSFER_IN_BONUS - 买方个人加成算力转入 // 系统账户调整(省公司 1% + 市公司 2%,随所有权变更) TRANSFER_OUT_SYSTEM_PROVINCE - 卖方省公司系统账户算力转出 TRANSFER_OUT_SYSTEM_CITY - 卖方市公司系统账户算力转出 TRANSFER_IN_SYSTEM_PROVINCE - 买方省公司系统账户算力转入 TRANSFER_IN_SYSTEM_CITY - 买方市公司系统账户算力转入 // 注意:仅运营账户 12% 为全局固定分配,不受转让影响 ``` ### 5.3 ContributionRecord 新增字段 ```prisma // 新增可空字段(不影响现有数据) transferOrderNo String? @map("transfer_order_no") @db.VarChar(50) // 关联转让订单号 ``` ### 5.4 卖方算力扣减流水(planting.ownership.removed 事件处理) #### 5.4.1 个人算力扣减 ```typescript // 创建一条负数流水 ContributionRecord.create({ accountSequence: seller.accountSequence, sourceType: 'TRANSFER_OUT_PERSONAL', sourceAdoptionId: originalAdoptionId, sourceAccountSequence: seller.accountSequence, treeCount: transferTreeCount, baseContribution: originalContributionPerTree, distributionRate: 0.70, amount: -(originalContributionPerTree * transferTreeCount * 0.70), // 负数 transferOrderNo: transferOrderNo, effectiveDate: transferDate, // 转让日期次日 expireDate: originalExpireDate, // 原始到期日 status: 'EFFECTIVE', remark: `转让转出至 ${buyer.accountSequence}`, }); // 更新卖方 ContributionAccount sellerAccount.personalContribution -= amount; sellerAccount.effectiveContribution -= amount; ``` #### 5.4.2 卖方上线团队层级扣减 对卖方的 15 级上线,每级创建一条负数流水: ```typescript for (let level = 1; level <= 15; level++) { const ancestor = sellerAncestorChain[level - 1]; if (!ancestor) break; const levelAmount = originalContributionPerTree * transferTreeCount * 0.005; // 检查当时这一级是否已分配(查询原始 ContributionRecord) const originalRecord = await findOriginalTeamLevelRecord( ancestor.accountSequence, originalAdoptionId, level ); if (originalRecord && originalRecord.status === 'EFFECTIVE') { // 原始记录是已生效的 → 创建负数对冲流水 ContributionRecord.create({ accountSequence: ancestor.accountSequence, sourceType: 'TRANSFER_OUT_TEAM_LEVEL', sourceAdoptionId: originalAdoptionId, sourceAccountSequence: seller.accountSequence, treeCount: transferTreeCount, baseContribution: originalContributionPerTree, distributionRate: 0.005, levelDepth: level, amount: -levelAmount, transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, status: 'EFFECTIVE', remark: `转让转出:${seller.accountSequence} → ${buyer.accountSequence}`, }); // 更新上线 ContributionAccount ancestorAccount.effectiveContribution -= levelAmount; // 对应层级的 pending/unlocked 字段也需调整 } // 如果原始记录在 UnallocatedContribution 中(上线未解锁), // 则不需要扣减(因为从未分配给该上线),但需标记该 Unallocated 记录已失效 } ``` #### 5.4.3 卖方加成扣减 对卖方自身已生效的加成档位,创建负数流水: ```typescript for (let tier = 1; tier <= 3; tier++) { // 查询卖方在原始认种中该档位是否已获得 const originalBonusRecord = await findOriginalBonusRecord( seller.accountSequence, originalAdoptionId, tier ); if (originalBonusRecord && originalBonusRecord.status === 'EFFECTIVE') { const bonusAmount = originalContributionPerTree * transferTreeCount * 0.025; ContributionRecord.create({ accountSequence: seller.accountSequence, sourceType: 'TRANSFER_OUT_BONUS', sourceAdoptionId: originalAdoptionId, sourceAccountSequence: seller.accountSequence, treeCount: transferTreeCount, baseContribution: originalContributionPerTree, distributionRate: 0.025, bonusTier: tier, amount: -bonusAmount, transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, status: 'EFFECTIVE', remark: `转让转出加成T${tier}`, }); sellerAccount.effectiveContribution -= bonusAmount; } } ``` #### 5.4.4 卖方解锁状态更新 ```typescript // 重新计算卖方的 selfPlantingCount const remainingAdoptions = await syncedDataRepository.countAdoptionsByAccount( seller.accountSequence, excludeTransferred: true ); if (remainingAdoptions === 0) { // 卖方不再持有任何树 sellerAccount.hasAdopted = false; sellerAccount.unlockedLevelDepth = 0; // unlockedBonusTiers 的 T1 失效,但 T2/T3 取决于 directReferralAdoptedCount sellerAccount.recalculateUnlockStatus(); } // 检查卖方的推荐人的 directReferralAdoptedCount 是否需要更新 if (remainingAdoptions === 0) { const sellerReferrer = await findReferrer(seller.accountSequence); if (sellerReferrer) { const newCount = await recalculateDirectReferralAdoptedCount(sellerReferrer.accountSequence); if (newCount < sellerReferrer.directReferralAdoptedCount) { sellerReferrerAccount.directReferralAdoptedCount = newCount; sellerReferrerAccount.recalculateUnlockStatus(); // 注意:不追回历史分配,只更新状态(影响未来分配) } } } ``` ### 5.5 买方算力新增流水(planting.ownership.added 事件处理) #### 5.5.1 买方个人算力 ```typescript ContributionRecord.create({ accountSequence: buyer.accountSequence, sourceType: 'TRANSFER_IN_PERSONAL', sourceAdoptionId: originalAdoptionId, sourceAccountSequence: buyer.accountSequence, // 新的所有者 treeCount: transferTreeCount, baseContribution: originalContributionPerTree, // 保持原始值 distributionRate: 0.70, amount: originalContributionPerTree * transferTreeCount * 0.70, // 正数 transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, status: 'EFFECTIVE', remark: `转让转入来自 ${seller.accountSequence}`, }); buyerAccount.personalContribution += amount; buyerAccount.effectiveContribution += amount; ``` #### 5.5.2 买方上线团队层级分配 ```typescript for (let level = 1; level <= 15; level++) { const ancestor = buyerAncestorChain[level - 1]; if (!ancestor) { // 无上线 → UnallocatedContribution(归总部) createUnallocatedContribution({ sourceAdoptionId: originalAdoptionId, sourceAccountSequence: buyer.accountSequence, unallocType: 'TRANSFER_IN_LEVEL_NO_ANCESTOR', levelDepth: level, amount: levelAmount, transferOrderNo: transferOrderNo, }); continue; } const ancestorAccount = await findContributionAccount(ancestor.accountSequence); if (ancestorAccount && ancestorAccount.unlockedLevelDepth >= level) { // 上线已解锁 → 直接分配 ContributionRecord.create({ accountSequence: ancestor.accountSequence, sourceType: 'TRANSFER_IN_TEAM_LEVEL', sourceAdoptionId: originalAdoptionId, sourceAccountSequence: buyer.accountSequence, levelDepth: level, amount: levelAmount, // 正数 transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, status: 'EFFECTIVE', }); ancestorAccount.effectiveContribution += levelAmount; } else { // 上线未解锁 → UnallocatedContribution(待补发) createUnallocatedContribution({ sourceAdoptionId: originalAdoptionId, sourceAccountSequence: buyer.accountSequence, unallocType: 'LEVEL_OVERFLOW', wouldBeAccountSequence: ancestor.accountSequence, levelDepth: level, amount: levelAmount, transferOrderNo: transferOrderNo, }); } } ``` #### 5.5.3 买方加成分配 ```typescript const buyerUnlockedBonusTiers = calculateUnlockedBonusTiers( buyerAccount.hasAdopted || true, // 买方获得树后 hasAdopted = true buyerAccount.directReferralAdoptedCount ); for (let tier = 1; tier <= 3; tier++) { if (buyerUnlockedBonusTiers >= tier) { ContributionRecord.create({ accountSequence: buyer.accountSequence, sourceType: 'TRANSFER_IN_BONUS', bonusTier: tier, amount: bonusAmount, transferOrderNo: transferOrderNo, // ... }); buyerAccount.effectiveContribution += bonusAmount; } else { createUnallocatedContribution({ unallocType: `BONUS_TIER_${tier}_PENDING`, wouldBeAccountSequence: buyer.accountSequence, amount: bonusAmount, transferOrderNo: transferOrderNo, }); } } ``` #### 5.5.4 买方解锁状态更新 ```typescript // 更新买方 hasAdopted if (!buyerAccount.hasAdopted) { buyerAccount.markAsAdopted(); // hasAdopted = true, unlockedLevelDepth = 5, unlockedBonusTiers = 1 } // 检查买方的推荐人的 directReferralAdoptedCount 是否需要更新 const buyerReferrer = await findReferrer(buyer.accountSequence); if (buyerReferrer) { const newCount = await recalculateDirectReferralAdoptedCount(buyerReferrer.accountSequence); if (newCount > buyerReferrer.directReferralAdoptedCount) { // 可能触发 checkAndClaimBonus()(复用现有逻辑) await bonusClaimService.checkAndClaimBonus( buyerReferrer.accountSequence, buyerReferrer.directReferralAdoptedCount, newCount ); buyerReferrerAccount.directReferralAdoptedCount = newCount; buyerReferrerAccount.recalculateUnlockStatus(); } } ``` ### 5.6 系统账户处理 **运营账户 12% 不受影响**(全局固定分配)。 **省公司 1% 和市公司 2% 需随所有权变更调整**: ```typescript // 卖方省市系统账户:创建负数对冲流水 const provinceAmount = originalContributionPerTree * transferTreeCount * 0.01; const cityAmount = originalContributionPerTree * transferTreeCount * 0.02; // 卖方省公司系统账户扣减 SystemContributionRecord.create({ accountType: 'PROVINCE', regionCode: sellerSelectedProvince, // 原始认种时选择的省 sourceType: 'TRANSFER_OUT_SYSTEM_PROVINCE', sourceAdoptionId: originalAdoptionId, amount: -provinceAmount, // 负数 transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, }); // 更新 SystemAccount (PROVINCE, sellerSelectedProvince) 的 contributionBalance // 卖方市公司系统账户扣减 SystemContributionRecord.create({ accountType: 'CITY', regionCode: sellerSelectedCity, // 原始认种时选择的市 sourceType: 'TRANSFER_OUT_SYSTEM_CITY', sourceAdoptionId: originalAdoptionId, amount: -cityAmount, transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, }); // 买方省公司系统账户增加 SystemContributionRecord.create({ accountType: 'PROVINCE', regionCode: buyerSelectedProvince, // 买方的省(来自 PlantingOwnershipAdded 事件) sourceType: 'TRANSFER_IN_SYSTEM_PROVINCE', sourceAdoptionId: originalAdoptionId, amount: +provinceAmount, transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, }); // 买方市公司系统账户增加 SystemContributionRecord.create({ accountType: 'CITY', regionCode: buyerSelectedCity, sourceType: 'TRANSFER_IN_SYSTEM_CITY', sourceAdoptionId: originalAdoptionId, amount: +cityAmount, transferOrderNo: transferOrderNo, effectiveDate: transferDate, expireDate: originalExpireDate, }); ``` **注意**:如果卖方和买方在同一省市,省市系统账户的对冲流水互相抵消,净变化为零,但流水记录仍然要写(审计需要)。 ### 5.7 挖矿快照自动适配 **挖矿分配同样无需特殊处理**: 1. 转让完成后,受影响用户的 `ContributionAccount.effectiveContribution` 已实时更新 2. 每日定时任务 `SnapshotService.createDailySnapshot()` 读取最新的 `effectiveContribution` 3. 为每个用户生成新的 `contributionRatio = effectiveContribution / networkTotal` 4. `mining-service` 消费快照,按新占比分配挖矿收益 **无需任何代码处理**——快照天然支持算力变更。 ### 5.7 发布确认事件 处理完成后发布事件到 `contribution.transfer.adjusted`,供 transfer-service 确认 Saga 步骤。 ### 5.8 新增文件清单 | 文件 | 说明 | |------|------| | `src/application/event-handlers/ownership-removed.handler.ts` | 消费 planting.ownership.removed | | `src/application/event-handlers/ownership-added.handler.ts` | 消费 planting.ownership.added | | `src/application/services/transfer-adjustment.service.ts` | 转让算力调整编排服务 | | `src/domain/services/transfer-contribution-calculator.service.ts` | 转让算力计算(查询原始记录 + 生成对冲/新增流水) | --- ## 六、wallet-service 复用方案 wallet-service **无需新增代码**,完全复用现有能力: ### 6.1 冻结买方资金 transfer-service 调用 wallet-service 的 `freezeForPlanting` 或通用 `freeze` 接口: ``` POST /wallet/freeze { accountSequence: buyer.accountSequence, amount: transferPrice, assetType: "USDT", refOrderId: transferOrderNo, memo: "树转让冻结" } ``` 对应 LedgerEntry:`entryType = FREEZE` ### 6.2 结算(转账给卖方 + 平台手续费) transfer-service 调用 wallet-service 的内部转账: ``` // 1. 从买方冻结余额中扣款 POST /wallet/confirm-deduction { accountSequence: buyer.accountSequence, amount: transferPrice, refOrderId: transferOrderNo } // 2. 转账给卖方(实收金额 = 转让价 - 手续费) POST /wallet/internal-transfer { fromAccountSequence: buyer.accountSequence, toAccountSequence: seller.accountSequence, amount: sellerReceiveAmount, refOrderId: transferOrderNo } // 3. 手续费归集(平台收入) POST /wallet/fee-collection { fromAccountSequence: buyer.accountSequence, toAccountSequence: "S0000000006", // 手续费归集账户 amount: platformFeeAmount, refOrderId: transferOrderNo } ``` ### 6.3 回滚(解冻) ``` POST /wallet/unfreeze { accountSequence: buyer.accountSequence, amount: transferPrice, refOrderId: transferOrderNo, memo: "树转让取消,解冻" } ``` --- ## 七、完整事件契约 ### 7.1 transfer-service 发出的事件 | 事件 | Topic | Key | 消费者 | |------|-------|-----|--------| | `TransferTreesLockRequest` | `transfer.trees.lock` | transferOrderNo | planting-service | | `TransferOwnershipExecute` | `transfer.ownership.execute` | transferOrderNo | planting-service | | `TransferTreesUnlockRequest` | `transfer.trees.unlock` | transferOrderNo | planting-service | | `TransferPaymentFreezeRequest` | `transfer.payment.freeze` | transferOrderNo | wallet-service | | `TransferPaymentSettleRequest` | `transfer.payment.settle` | transferOrderNo | wallet-service | | `TransferPaymentUnfreezeRequest` | `transfer.payment.unfreeze` | transferOrderNo | wallet-service | ### 7.2 planting-service 发出的事件 | 事件 | Topic | Key | 消费者 | |------|-------|-----|--------| | `PlantingTransferLocked` | `planting.transfer.locked` | transferOrderNo | transfer-service | | `PlantingTransferCompleted` | `planting.transfer.completed` | transferOrderNo | transfer-service | | `PlantingOwnershipRemoved` | `planting.ownership.removed` | sellerAccountSequence | contribution-service, referral-service | | `PlantingOwnershipAdded` | `planting.ownership.added` | buyerAccountSequence | contribution-service, referral-service | | `PlantingTransferRolledBack` | `planting.transfer.rolledback` | transferOrderNo | transfer-service | ### 7.3 contribution-service 发出的事件 | 事件 | Topic | Key | 消费者 | |------|-------|-----|--------| | `TransferContributionAdjusted` | `contribution.transfer.adjusted` | transferOrderNo | transfer-service | ### 7.4 referral-service 发出的事件 | 事件 | Topic | Key | 消费者 | |------|-------|-----|--------| | `TransferStatsUpdated` | `referral.transfer.stats-updated` | transferOrderNo | transfer-service | ### 7.5 标准 Payload 格式 #### PlantingOwnershipRemoved Payload ```json { "eventType": "PlantingOwnershipRemoved", "transferOrderNo": "TRF260218A3B7C9D1", "sourceOrderNo": "PLT2501150001A2B3", "sourceAdoptionId": "12345", "sellerUserId": "100001", "sellerAccountSequence": "D25011500001", "treeCount": 2, "contributionPerTree": "22684.1000000000", "selectedProvince": "440000", "selectedCity": "440100", "originalAdoptionDate": "2025-01-15", "originalExpireDate": "2027-01-16", "transferDate": "2026-02-18" } ``` #### PlantingOwnershipAdded Payload ```json { "eventType": "PlantingOwnershipAdded", "transferOrderNo": "TRF260218A3B7C9D1", "sourceOrderNo": "PLT2501150001A2B3", "sourceAdoptionId": "12345", "buyerUserId": "200002", "buyerAccountSequence": "D25121400002", "treeCount": 2, "contributionPerTree": "22684.1000000000", "selectedProvince": "440000", "selectedCity": "440100", "originalAdoptionDate": "2025-01-15", "originalExpireDate": "2027-01-16", "transferDate": "2026-02-18" } ``` --- ## 八、完整 Saga 流程图 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ transfer-service (Saga 编排器) │ └────────────────────────────────┬────────────────────────────────────┘ │ Step 1: 买方发起转让 │ ───────────────────── │ 创建 TransferOrder │ status = PENDING │ │ Step 2: 卖方确认 │ ───────────────────── │ status = SELLER_CONFIRMED │ │ Step 3: 冻结买方资金 │ ──── wallet-service ──── ───────────────────── │ freeze(buyer, transferPrice) status = PAYMENT_FROZEN │ ← 确认冻结成功 │ Step 4: 锁定卖方树 │ ──── planting-service ──── ───────────────────── │ → transfer.trees.lock status = TREES_LOCKED │ ← planting.transfer.locked │ Step 5: 执行所有权变更 │ ──── planting-service ──── ───────────────────── │ → transfer.ownership.execute status = OWNERSHIP_ │ ← planting.transfer.completed TRANSFERRED │ │ planting-service 同时发布: │ → planting.ownership.removed │ → planting.ownership.added │ Step 6: 等待算力调整确认 │ ──── contribution-service ──── ───────────────────── │ 消费 planting.ownership.removed status = CONTRIBUTION_ │ 消费 planting.ownership.added ADJUSTED │ ← contribution.transfer.adjusted │ Step 7: 等待统计更新确认 │ ──── referral-service ──── ───────────────────── │ 消费 planting.ownership.removed status = STATS_UPDATED │ 消费 planting.ownership.added │ ← referral.transfer.stats-updated │ Step 8: 结算资金 │ ──── wallet-service ──── ───────────────────── │ confirmDeduction(buyer) status = PAYMENT_SETTLED │ internalTransfer(buyer→seller) │ feeCollection(buyer→S0000000006) │ Step 9: 完成 │ ───────────────────── │ status = COMPLETED │ │ ═══════════════════════════════════════════════════════════════════════ 补偿路径(从失败步骤反向执行): ═══════════════════════════════════════════════════════════════════════ │ 如果 Step 5/6/7 失败: │ → planting: transfer.trees.unlock (解锁树) → wallet: unfreeze (解冻资金) │ │ 如果 Step 4 失败: │ → wallet: unfreeze (解冻资金) │ │ 如果 Step 3 失败: │ → 直接标记 FAILED │ ``` --- ## 九、补偿与回滚 ### 9.1 补偿策略 | 失败步骤 | 需要补偿的操作 | 补偿方式 | |---------|--------------|---------| | 冻结资金失败 | 无 | 直接标记 FAILED | | 锁定树失败 | 解冻买方资金 | wallet.unfreeze | | 所有权变更失败 | 解锁树 + 解冻资金 | planting.unlock + wallet.unfreeze | | 算力调整超时 | 重试 3 次后人工介入 | 算力为最终一致性,不阻塞流程 | | 统计更新超时 | 重试 3 次后人工介入 | 统计为最终一致性,不阻塞流程 | | 资金结算失败 | 反向变更所有权 + 解锁树 + 解冻资金 | 完整回滚 | ### 9.2 最终一致性策略 对于 Step 6(算力)和 Step 7(统计),采用**最终一致性**: 1. transfer-service 设置超时(5 分钟) 2. 超时后自动重试发送事件(最多 3 次) 3. 3 次重试后标记为 `CONTRIBUTION_PENDING` 或 `STATS_PENDING` 4. 定时任务扫描超时未完成的转让,发送告警 5. 管理员可通过 Admin API 手动触发重试或强制完成 ### 9.3 幂等性保证 所有事件处理器通过 `ProcessedEvent` 表保证幂等性: ```typescript const eventId = `transfer-${transferOrderNo}-${step}`; const exists = await processedEventRepo.findByEventId(eventId); if (exists) return; // 已处理,跳过 ``` --- ## 十、Admin Web 管理 ### 10.1 转让管理页面 ``` /admin/transfers - 转让列表 /admin/transfers/:id - 转让详情 ``` #### 列表页功能 - 筛选条件:状态、日期范围、卖方/买方账号、转让编号 - 列表字段:转让编号、卖方、买方、棵数、金额、状态、创建时间 - 操作按钮:查看详情、强制取消(PENDING/SELLER_CONFIRMED 阶段) #### 详情页功能 - 基本信息:转让双方、树信息、价格 - Saga 进度:每个步骤的状态和时间戳 - 算力流水:关联的 ContributionRecord 列表 - 团队统计变更:受影响的用户列表 - 操作日志:所有状态变更记录 ### 10.2 Sidebar 入口 在 admin-web 的 Sidebar 中新增入口: ``` 认种管理 ├── 认种订单 ├── 预种计划 └── 转让管理 ← 新增 ``` --- ## 十一、Mobile App 用户界面 ### 11.1 卖方入口 在持仓页面(PlantingPosition)的每棵树/每个订单旁新增「转让」按钮: ``` 我的持仓(10棵树) ├── 订单 PLT250115... (5棵, 广东广州) │ ├── 状态:挖矿中 │ └── [转让] ├── 订单 PLT250120... (3棵, 广东深圳) │ ├── 状态:挖矿中 │ └── [转让] └── 订单 PLT250201... (2棵, 北京朝阳) ├── 状态:挖矿中 └── [转让] ``` ### 11.2 转让发起页 ``` ┌──────────────────────────────┐ │ 发起转让 │ │ │ │ 转让订单:PLT250115... │ │ 棵数:5 棵(可调整) │ │ 树位置:广东广州 │ │ 原始认种日期:2025-01-15 │ │ 算力到期日:2027-01-16 │ │ │ │ 转让价格:______ USDT │ │ 平台手续费:2% │ │ 您将收到:______ USDT │ │ │ │ 买方账号:D____________ │ │ │ │ [确认转让] │ └──────────────────────────────┘ ``` ### 11.3 转让记录页 ``` 我的转让 ├── [标签] 转出 | 转入 | 全部 ├── TRF260218... 转出 5棵 → D25121400002 │ 状态:已完成 | 收到 15000 USDT │ 2026-02-18 14:30 ├── TRF260210... 转入 3棵 ← D25011500001 │ 状态:已完成 | 支付 9500 USDT │ 2026-02-10 09:15 └── ... ``` --- ## 十二、实施阶段计划 ### Phase 1:基础框架(transfer-service + planting-service) **目标**:完成转让订单的创建、锁定、所有权变更、资金结算的核心流程。 **交付内容**: 1. transfer-service 新微服务搭建 - 项目脚手架(NestJS + Prisma + Kafka) - TransferOrder 数据模型 + Migration - Saga 状态机核心逻辑 - REST API(用户端 + 管理端) - Outbox 事件发布 2. planting-service 纯新增 - PlantingTransferRecord 数据表 + Migration - PlantingOrder.transferLockedCount 新增字段 - 3 个事件处理器(lock / execute / rollback) - Outbox 事件发布(ownership.removed / ownership.added) 3. wallet-service 对接 - 复用 freeze / unfreeze / internal-transfer 接口 - 新增 LedgerEntryType: `TRANSFER_FREEZE` / `TRANSFER_PAYMENT` 4. Docker 配置 - docker-compose.yml 新增 transfer-service - Kafka topic 创建脚本 **验收标准**: - 完成转让全流程(不含算力和统计) - 资金正确冻结、结算、回滚 - 持仓数量正确变更 --- ### Phase 2:算力调整(contribution-service) **目标**:完成算力的事务流水型增量调整。 **交付内容**: 1. contribution-service 纯新增 - 新增 sourceType 枚举值(10 个 TRANSFER_OUT/IN 类型,含系统省市) - ContributionRecord.transferOrderNo 新增字段 + Migration - ownership-removed.handler.ts(卖方算力扣减) - ownership-added.handler.ts(买方算力分配) - transfer-adjustment.service.ts(编排服务) - transfer-contribution-calculator.service.ts(计算服务) 2. 解锁状态联动 - 卖方 hasAdopted / unlockedLevelDepth / unlockedBonusTiers 重算 - 买方 hasAdopted / unlockedLevelDepth / unlockedBonusTiers 更新 - 买方推荐人 directReferralAdoptedCount 更新 + bonus 补发 3. 确认事件发布 - `contribution.transfer.adjusted` 事件 **验收标准**: - 卖方 effectiveContribution 正确减少 - 买方 effectiveContribution 正确增加 - 省市系统账户正确调整(卖方省市减少,买方省市增加) - 全网算力总量守恒(卖方减少 = 买方增加 + UnallocatedContribution) - 历史 ContributionRecord 零修改 - 次日快照正确反映新算力 --- ### Phase 3:团队统计(referral-service) **目标**:完成团队认种统计的增减和龙虎榜重算。 **交付内容**: 1. referral-service 纯新增 - planting-transferred.handler.ts - TeamStatistics.removePersonalPlanting() 新增方法 - TeamStatisticsRepository.batchUpdateTeamCountsForTransfer() 新增方法 - 龙虎榜 max 重算逻辑修正(负数 delta 时全量扫描) 2. 确认事件发布 - `referral.transfer.stats-updated` 事件 **验收标准**: - 卖方链路 totalTeamPlantingCount 正确减少 - 买方链路 totalTeamPlantingCount 正确增加 - 龙虎榜排名正确更新 - selfPlantingCount 正确反映当前持仓 --- ### Phase 4:前端(Admin Web + Mobile App) **目标**:完成管理端和用户端的完整交互界面。 **交付内容**: 1. Admin Web(Next.js 14) - 转让管理列表页 - 转让详情页(含 Saga 进度可视化) - Sidebar 入口 - 统计看板 2. Mobile App(Flutter) - 持仓页转让入口 - 转让发起页 - 转让记录页(转出/转入) - 转让状态实时更新 **验收标准**: - 完整的转让用户流程 - 管理端可查看所有转让记录和状态 - 异常状态有明确提示 --- ### Phase 5:测试与上线 **交付内容**: 1. 单元测试 - 算力计算正确性(卖方扣减 / 买方分配 / 解锁联动) - Saga 状态机全路径覆盖 - 补偿逻辑验证 2. 集成测试 - 完整转让流程 E2E - 补偿回滚 E2E - 并发转让冲突测试 - 部分转让 + 多次转让场景 3. 压力测试 - 大量并发转让请求 - Kafka 事件积压恢复 4. 上线 - 数据库 Migration 执行 - 服务部署 - 灰度放量 --- ## 十三、算力流水示例 ### 场景:A 转让 2 棵树给 B **前提条件**: - A 有 5 棵树(订单 PLT001),原始 contributionPerTree = 22617 - A 的上级链:R1 → R2 → R3(3 级,都已解锁) - B 之前无树,B 的上级链:S1 → S2(2 级,S1 已解锁 5 级,S2 未解锁) - 转让 2 棵 #### 卖方侧新增流水(全部为负数) | # | accountSequence | sourceType | amount | remark | |---|----------------|------------|--------|--------| | 1 | A | TRANSFER_OUT_PERSONAL | -31,663.80 | 个人 22617×2×0.70 | | 2 | R1 | TRANSFER_OUT_TEAM_LEVEL (L1) | -226.17 | R1 第1级 22617×2×0.005 | | 3 | R2 | TRANSFER_OUT_TEAM_LEVEL (L2) | -226.17 | R2 第2级 | | 4 | R3 | TRANSFER_OUT_TEAM_LEVEL (L3) | -226.17 | R3 第3级 | | 5 | A | TRANSFER_OUT_BONUS (T1) | -1,130.85 | 加成T1 22617×2×0.025 | | 6 | A | TRANSFER_OUT_BONUS (T2) | -1,130.85 | 加成T2(如已解锁) | | 7 | SystemAccount(PROVINCE,440000) | TRANSFER_OUT_SYSTEM_PROVINCE | -452.34 | 省公司 22617×2×0.01 | | 8 | SystemAccount(CITY,440100) | TRANSFER_OUT_SYSTEM_CITY | -904.68 | 市公司 22617×2×0.02 | #### 买方侧新增流水(全部为正数) 假设买方 B 在北京朝阳(110000/110105),与卖方不同省市: | # | accountSequence | sourceType | amount | remark | |---|----------------|------------|--------|--------| | 9 | B | TRANSFER_IN_PERSONAL | +31,663.80 | 个人 | | 10 | S1 | TRANSFER_IN_TEAM_LEVEL (L1) | +226.17 | S1 第1级(已解锁) | | 11 | S1 | TRANSFER_IN_TEAM_LEVEL (L2) | +226.17 | S1 第2级(已解锁) | | 12 | S1 | TRANSFER_IN_TEAM_LEVEL (L3) | +226.17 | S1 第3级(已解锁) | | 13 | S1 | TRANSFER_IN_TEAM_LEVEL (L4) | +226.17 | S1 第4级(已解锁) | | 14 | S1 | TRANSFER_IN_TEAM_LEVEL (L5) | +226.17 | S1 第5级(已解锁) | | 15 | → UnallocatedContribution | LEVEL_OVERFLOW (L6) | 226.17 | S2 第6级(未解锁) | | 16 | → UnallocatedContribution | LEVEL_OVERFLOW (L7) | 226.17 | S2 第7级(未解锁) | | ... | ... | ... | ... | L8-L15 类似 | | 17 | B | TRANSFER_IN_BONUS (T1) | +1,130.85 | 加成T1(买方获树后解锁) | | 18 | → UnallocatedContribution | BONUS_TIER_2_PENDING | 1,130.85 | T2(买方未达条件) | | 19 | → UnallocatedContribution | BONUS_TIER_3_PENDING | 1,130.85 | T3(买方未达条件) | | 20 | SystemAccount(PROVINCE,110000) | TRANSFER_IN_SYSTEM_PROVINCE | +452.34 | 买方省公司(北京) | | 21 | SystemAccount(CITY,110105) | TRANSFER_IN_SYSTEM_CITY | +904.68 | 买方市公司(朝阳) | #### 解锁状态联动 | 用户 | 变更前 | 变更后 | 说明 | |------|--------|--------|------| | A | hasAdopted=true | hasAdopted=true | A 还剩 3 棵 | | B | hasAdopted=false | hasAdopted=true | 获得树后解锁 | | B 的推荐人 | directAdoptedCount=N | directAdoptedCount=N+1 | 可能触发升档补发 | --- ## 十四、风险评估与应对 | 风险 | 影响 | 应对措施 | |------|------|---------| | Saga 步骤失败后补偿不完整 | 资金或树处于不一致状态 | 定时任务扫描异常状态 + Admin 手动介入 | | 并发转让同一订单 | 超卖 | PlantingOrder.transferLockedCount 乐观锁 + 数据库约束 | | contribution 事件丢失 | 算力不一致 | Outbox + ProcessedEvent 双保障 + 定时对账 | | referral 统计出现负数 | 龙虎榜异常 | Math.max(0, ...) 兜底 + 告警 | | 全网算力总量不守恒 | 挖矿分配异常 | 每日对账脚本:∑增加 = ∑减少 + ∑UnallocatedContribution | | 大量转让导致 Kafka 积压 | 处理延迟 | 独立 Consumer Group + 限流 | | 卖方转让后 selfPlantingCount=0 影响推荐人解锁 | 推荐人未来新认种的分配比例降低 | 这是正确的业务行为,无需应对 | --- ## 十五、对账与监控 ### 15.1 每日对账任务 ```sql -- 算力守恒检查 -- 对于每一笔转让,验证:|卖方扣减| = |买方增加| + |UnallocatedContribution| SELECT t.transfer_order_no, SUM(CASE WHEN cr.source_type LIKE 'TRANSFER_OUT_%' THEN ABS(cr.amount) ELSE 0 END) AS total_out, SUM(CASE WHEN cr.source_type LIKE 'TRANSFER_IN_%' THEN cr.amount ELSE 0 END) AS total_in, SUM(CASE WHEN uc.transfer_order_no IS NOT NULL THEN uc.amount ELSE 0 END) AS total_unallocated FROM transfer_orders t LEFT JOIN contribution_records cr ON cr.transfer_order_no = t.transfer_order_no LEFT JOIN unallocated_contributions uc ON uc.transfer_order_no = t.transfer_order_no WHERE t.status = 'COMPLETED' GROUP BY t.transfer_order_no HAVING ABS(total_out - total_in - total_unallocated) > 0.01; -- 结果应为空(无差异) ``` ### 15.2 监控告警 - Saga 步骤超时(> 5 分钟未完成) - 补偿失败(ROLLING_BACK 状态超过 10 分钟) - 算力守恒偏差 > 0.01 - 团队统计出现负数 - 每日转让量异常波动 --- ## 十六、积分股转让(2.0 系统附加功能) ### 16.1 业务规则 | 规则 | 说明 | |------|------| | **正常情况** | 2.0 系统**不支持**用户间积分股(Share)转让 | | **触发条件** | 树转让完成(TransferOrder.status = COMPLETED)后自动开启 | | **参与方限制** | 仅限该笔树转让的买方和卖方双向互转 | | **有效期** | 树转让完成后 24 小时内 | | **到期处理** | 24 小时后窗口自动关闭,不可再发起 Share 转让 | | **转让方向** | 双向:卖方→买方、买方→卖方 均可 | | **转让次数** | 窗口期内不限次数 | | **转让数量** | 不超过发起方的可用 Share 余额 | ### 16.2 数据模型(mining-wallet-service 新增) #### ShareTransferWindow(积分股转让窗口) ```prisma model ShareTransferWindow { id String @id @default(uuid()) transferOrderNo String @unique @map("transfer_order_no") @db.VarChar(50) // 关联的树转让订单号 partyAAccountSequence String @map("party_a_account_sequence") @db.VarChar(20) // 卖方 partyBAccountSequence String @map("party_b_account_sequence") @db.VarChar(20) // 买方 status String @default("ACTIVE") @map("status") @db.VarChar(20) // ACTIVE / EXPIRED / CLOSED openedAt DateTime @default(now()) @map("opened_at") expiresAt DateTime @map("expires_at") // openedAt + 24h closedAt DateTime? @map("closed_at") createdAt DateTime @default(now()) @map("created_at") @@index([partyAAccountSequence]) @@index([partyBAccountSequence]) @@index([status, expiresAt]) @@map("share_transfer_windows") } ``` #### ShareTransferRecord(积分股转让记录) ```prisma model ShareTransferRecord { id String @id @default(uuid()) windowId String @map("window_id") // 关联窗口 transferOrderNo String @map("transfer_order_no") @db.VarChar(50) // 关联的树转让订单号 fromAccountSequence String @map("from_account_sequence") @db.VarChar(20) toAccountSequence String @map("to_account_sequence") @db.VarChar(20) amount Decimal @map("amount") @db.Decimal(30, 8) // Share 数量 status String @default("COMPLETED") @map("status") @db.VarChar(20) createdAt DateTime @default(now()) @map("created_at") @@index([windowId]) @@index([transferOrderNo]) @@index([fromAccountSequence]) @@index([toAccountSequence]) @@map("share_transfer_records") } ``` ### 16.3 流程 ``` 树转让完成 (TransferOrder.status = COMPLETED) ↓ transfer-service 发布 Outbox 事件: → transfer.share-window.open payload: { transferOrderNo, sellerAccountSeq, buyerAccountSeq } ↓ mining-wallet-service 消费事件: → 创建 ShareTransferWindow (status=ACTIVE, expiresAt=now+24h) ↓ 24小时内,双方通过 API 发起 Share 转让: POST /share-transfers { transferOrderNo: "TRF260218A3B7C9D1", toAccountSequence: "D25121400002", amount: 1000.00 } ↓ mining-wallet-service 处理: 1. 查找 ACTIVE 状态的 ShareTransferWindow 2. 验证发起方和接收方都在窗口的 partyA/partyB 中 3. 验证 expiresAt > now() 4. 调用已有的 transferBetweenUsers() 执行 Share 转账 5. 创建 ShareTransferRecord 记录 6. 记录 UserWalletTransaction (transactionType=TRANSFER_OUT/TRANSFER_IN) ↓ 24小时到期: → 定时任务扫描 expiresAt < now() 且 status=ACTIVE 的窗口 → 更新 status = EXPIRED ``` ### 16.4 API 设计(mining-wallet-service 新增) ``` POST /share-transfers - 发起积分股转让 GET /share-transfers/window/:orderNo - 查询窗口状态和剩余时间 GET /share-transfers/records - 查询我的积分股转让记录 ``` #### POST /share-transfers 请求体 ```json { "transferOrderNo": "TRF260218A3B7C9D1", "toAccountSequence": "D25121400002", "amount": 1000.00000000 } ``` #### POST /share-transfers 响应 ```json { "success": true, "data": { "recordId": "uuid", "fromAccountSequence": "D25011500001", "toAccountSequence": "D25121400002", "amount": "1000.00000000", "windowExpiresAt": "2026-02-19T14:30:00Z", "createdAt": "2026-02-18T15:00:00Z" } } ``` #### GET /share-transfers/window/:orderNo 响应 ```json { "success": true, "data": { "transferOrderNo": "TRF260218A3B7C9D1", "status": "ACTIVE", "partyA": "D25011500001", "partyB": "D25121400002", "openedAt": "2026-02-18T14:30:00Z", "expiresAt": "2026-02-19T14:30:00Z", "remainingSeconds": 72000, "records": [ { "from": "D25011500001", "to": "D25121400002", "amount": "1000.00", "at": "..." } ] } } ``` ### 16.5 mining-wallet-service 新增文件 | 文件 | 说明 | |------|------| | `src/application/services/share-transfer.service.ts` | 积分股转让业务服务 | | `src/application/event-handlers/share-window-opened.handler.ts` | 消费树转让完成事件,创建窗口 | | `src/application/schedulers/share-window-expiry.scheduler.ts` | 定时任务:过期窗口关闭 | | `src/api/controllers/share-transfer.controller.ts` | REST API | ### 16.6 Mobile App 界面 树转让完成后,在转让详情页底部显示积分股转让入口: ``` ┌──────────────────────────────┐ │ 转让已完成 ✓ │ │ │ │ ┌────────────────────────┐ │ │ │ 积分股转让窗口 │ │ │ │ 剩余时间:18:45:30 │ │ │ │ │ │ │ │ 您的 Share:12,345.67 │ │ │ │ 对方 Share:8,901.23 │ │ │ │ │ │ │ │ 转让数量:________ │ │ │ │ │ │ │ │ [转给对方] │ │ │ └────────────────────────┘ │ │ │ │ 转让记录: │ │ • 您 → 对方 1,000 Share │ │ • 对方 → 您 500 Share │ └──────────────────────────────┘ ``` ### 16.7 实施归属 积分股转让功能归入 **Phase 2(算力调整)** 阶段,因为: - 依赖树转让完成事件 - 在 mining-wallet-service(2.0)中实现 - 复用已有的 `transferBetweenUsers()` 方法 - 只需新增窗口管理和验证逻辑 --- ## 附录 A:配置项 | 配置项 | 默认值 | 说明 | |--------|--------|------| | TRANSFER_PLATFORM_FEE_RATE | 0.02 | 平台手续费率 2% | | TRANSFER_MIN_PRICE_PER_TREE | 0 | 每棵树最低转让价 | | TRANSFER_MAX_PRICE_PER_TREE | 99999 | 每棵树最高转让价 | | TRANSFER_SAGA_STEP_TIMEOUT_MS | 300000 | Saga 单步超时 5 分钟 | | TRANSFER_SAGA_MAX_RETRIES | 3 | Saga 步骤最大重试次数 | | TRANSFER_ENABLED | true | 转让功能总开关 | | TRANSFER_DAILY_LIMIT_PER_USER | 10 | 每用户每日转让次数上限 | | TRANSFER_COOLDOWN_DAYS | 0 | 认种后 N 天内不可转让 | | SHARE_TRANSFER_WINDOW_HOURS | 24 | 积分股转让窗口有效期(小时) | | SHARE_TRANSFER_ENABLED | true | 积分股转让功能开关 | ## 附录 B:新增 Kafka Topic 列表 | Topic | 生产者 | 消费者 | |-------|--------|--------| | `transfer.trees.lock` | transfer-service | planting-service | | `transfer.trees.lock.ack` | planting-service | transfer-service | | `transfer.ownership.execute` | transfer-service | planting-service | | `transfer.trees.unlock` | transfer-service | planting-service | | `transfer.trees.unlock.ack` | planting-service | transfer-service | | `planting.transfer.completed` | planting-service | transfer-service | | `planting.transfer.rolledback` | planting-service | transfer-service | | `planting.ownership.removed` | planting-service | contribution-service, referral-service | | `planting.ownership.added` | planting-service | contribution-service, referral-service | | `contribution.transfer.adjusted` | contribution-service | transfer-service | | `referral.transfer.stats-updated` | referral-service | transfer-service | | `transfer.share-window.open` | transfer-service | mining-wallet-service | ## 附录 C:新增数据库表汇总 | 服务 | 表名 | 说明 | |------|------|------| | transfer-service | transfer_orders | 转让订单(Saga 聚合根) | | transfer-service | transfer_status_logs | 状态变更日志 | | transfer-service | outbox_events | Outbox 事件 | | transfer-service | processed_events | 幂等性检查 | | planting-service | planting_transfer_records | 转让记录 | | mining-wallet-service | share_transfer_windows | 积分股转让窗口(24小时有效期) | | mining-wallet-service | share_transfer_records | 积分股转让记录 | ## 附录 D:现有表新增字段汇总 | 服务 | 表名 | 新增字段 | 类型 | 说明 | |------|------|---------|------|------| | planting-service | planting_orders | transfer_locked_count | Int DEFAULT 0 | 被转让锁定的棵数 | | contribution-service | contribution_records | transfer_order_no | VARCHAR(50) NULL | 关联转让订单号 | | contribution-service | unallocated_contributions | transfer_order_no | VARCHAR(50) NULL | 关联转让订单号 |