rwadurian/docs/tree-transfer-implementatio...

1720 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 树转让功能详细实施方案
> 版本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<void> {
// 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 -= treeCountincrement 负数)
// 重新扫描所有直推的 teamPlantingCount 找新 max关键修正
// effectivePlantingCountForRanking = totalTeamPlantingCount - newMax
}
```
### 4.3 处理 planting.ownership.added买方增加
```typescript
async handleOwnershipAdded(event: PlantingOwnershipAddedEvent): Promise<void> {
// 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 WebNext.js 14
- 转让管理列表页
- 转让详情页(含 Saga 进度可视化)
- Sidebar 入口
- 统计看板
2. Mobile AppFlutter
- 持仓页转让入口
- 转让发起页
- 转让记录页(转出/转入)
- 转让状态实时更新
**验收标准**
- 完整的转让用户流程
- 管理端可查看所有转让记录和状态
- 异常状态有明确提示
---
### Phase 5测试与上线
**交付内容**
1. 单元测试
- 算力计算正确性(卖方扣减 / 买方分配 / 解锁联动)
- Saga 状态机全路径覆盖
- 补偿逻辑验证
2. 集成测试
- 完整转让流程 E2E
- 补偿回滚 E2E
- 并发转让冲突测试
- 部分转让 + 多次转让场景
3. 压力测试
- 大量并发转让请求
- Kafka 事件积压恢复
4. 上线
- 数据库 Migration 执行
- 服务部署
- 灰度放量
---
## 十三、算力流水示例
### 场景A 转让 2 棵树给 B
**前提条件**
- A 有 5 棵树(订单 PLT001原始 contributionPerTree = 22617
- A 的上级链R1 → R2 → R33 级,都已解锁)
- B 之前无树B 的上级链S1 → S22 级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 │ │
│ │ │ │
│ │ 您的 Share12,345.67 │ │
│ │ 对方 Share8,901.23 │ │
│ │ │ │
│ │ 转让数量________ │ │
│ │ │ │
│ │ [转给对方] │ │
│ └────────────────────────┘ │
│ │
│ 转让记录: │
│ • 您 → 对方 1,000 Share │
│ • 对方 → 您 500 Share │
└──────────────────────────────┘
```
### 16.7 实施归属
积分股转让功能归入 **Phase 2算力调整** 阶段,因为:
- 依赖树转让完成事件
- 在 mining-wallet-service2.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 | 关联转让订单号 |