1720 lines
63 KiB
Markdown
1720 lines
63 KiB
Markdown
# 树转让功能详细实施方案
|
||
|
||
> 版本: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 -= treeCount(increment 负数)
|
||
// 重新扫描所有直推的 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 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 | 关联转让订单号 |
|