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

63 KiB
Raw Blame History

树转让功能详细实施方案

版本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 聚合根)

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状态变更日志 - 审计)

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转让记录

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 新增列):

// 新增字段(可空,不影响现有数据)
transferLockedCount    Int       @default(0) @map("transfer_locked_count")  // 被锁定的转让棵数

可转让棵数 = treeCount - transferLockedCount - 已完成转让的棵数

已完成转让的棵数通过查询 PlantingTransferRecordstatus = TRANSFERRED聚合得到。

3.3 新增事件消费处理器

transfer-lock.handler.ts锁定树

  • 消费 Topictransfer.trees.lock
  • 处理逻辑
    1. 验证卖方持有该订单且有足够可转让棵数
    2. 创建 PlantingTransferRecordstatus = LOCKED
    3. 更新 PlantingOrder.transferLockedCount += treeCount
    4. 发布确认事件到 transfer.trees.lock.ack

transfer-execute.handler.ts执行所有权变更

  • 消费 Topictransfer.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回滚锁定

  • 消费 Topictransfer.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

  • 消费 Topicplanting.ownership.removed + planting.ownership.added
  • Consumer Groupreferral-service-transfer

4.2 处理 planting.ownership.removed卖方减少

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买方增加

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()(聚合根新增方法)

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 时:

// 关键差异:负数 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 新增字段

// 新增可空字段(不影响现有数据)
transferOrderNo    String? @map("transfer_order_no") @db.VarChar(50)  // 关联转让订单号

5.4 卖方算力扣减流水planting.ownership.removed 事件处理)

5.4.1 个人算力扣减

// 创建一条负数流水
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 级上线,每级创建一条负数流水:

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 卖方加成扣减

对卖方自身已生效的加成档位,创建负数流水:

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 卖方解锁状态更新

// 重新计算卖方的 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 买方个人算力

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 买方上线团队层级分配

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 买方加成分配

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 买方解锁状态更新

// 更新买方 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% 需随所有权变更调整

// 卖方省市系统账户:创建负数对冲流水
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: "树转让冻结"
}

对应 LedgerEntryentryType = 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

{
  "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

{
  "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_PENDINGSTATS_PENDING
  4. 定时任务扫描超时未完成的转让,发送告警
  5. 管理员可通过 Admin API 手动触发重试或强制完成

9.3 幂等性保证

所有事件处理器通过 ProcessedEvent 表保证幂等性:

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 每日对账任务

-- 算力守恒检查
-- 对于每一笔转让,验证:|卖方扣减| = |买方增加| + |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积分股转让窗口

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积分股转让记录

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 请求体

{
  "transferOrderNo": "TRF260218A3B7C9D1",
  "toAccountSequence": "D25121400002",
  "amount": 1000.00000000
}

POST /share-transfers 响应

{
  "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 响应

{
  "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 关联转让订单号