63 KiB
树转让功能详细实施方案
版本: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 - 已完成转让的棵数
已完成转让的棵数通过查询 PlantingTransferRecord 表(status = TRANSFERRED)聚合得到。
3.3 新增事件消费处理器
transfer-lock.handler.ts(锁定树)
- 消费 Topic:
transfer.trees.lock - 处理逻辑:
- 验证卖方持有该订单且有足够可转让棵数
- 创建
PlantingTransferRecord(status = LOCKED) - 更新
PlantingOrder.transferLockedCount += treeCount - 发布确认事件到
transfer.trees.lock.ack
transfer-execute.handler.ts(执行所有权变更)
- 消费 Topic:
transfer.ownership.execute - 处理逻辑:
- 查找对应的
PlantingTransferRecord - 更新
PlantingTransferRecord.status = TRANSFERRED - 更新卖方
PlantingPosition:effectiveTreeCount -= treeCounttotalTreeCount -= treeCount
- 更新卖方
PositionDistribution(对应省市的持仓数 -= treeCount) - 更新/创建买方
PlantingPosition:effectiveTreeCount += treeCounttotalTreeCount += treeCount
- 更新/创建买方
PositionDistribution(对应省市的持仓数 += treeCount) - 发布 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 - 处理逻辑:
- 查找对应的
PlantingTransferRecord - 更新
PlantingTransferRecord.status = ROLLED_BACK - 更新
PlantingOrder.transferLockedCount -= treeCount - 发布确认事件到
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(卖方减少)
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(买方增加)
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 挖矿快照自动适配
挖矿分配同样无需特殊处理:
- 转让完成后,受影响用户的
ContributionAccount.effectiveContribution已实时更新 - 每日定时任务
SnapshotService.createDailySnapshot()读取最新的effectiveContribution - 为每个用户生成新的
contributionRatio = effectiveContribution / networkTotal 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
{
"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(统计),采用最终一致性:
- transfer-service 设置超时(5 分钟)
- 超时后自动重试发送事件(最多 3 次)
- 3 次重试后标记为
CONTRIBUTION_PENDING或STATS_PENDING - 定时任务扫描超时未完成的转让,发送告警
- 管理员可通过 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)
目标:完成转让订单的创建、锁定、所有权变更、资金结算的核心流程。
交付内容:
-
transfer-service 新微服务搭建
- 项目脚手架(NestJS + Prisma + Kafka)
- TransferOrder 数据模型 + Migration
- Saga 状态机核心逻辑
- REST API(用户端 + 管理端)
- Outbox 事件发布
-
planting-service 纯新增
- PlantingTransferRecord 数据表 + Migration
- PlantingOrder.transferLockedCount 新增字段
- 3 个事件处理器(lock / execute / rollback)
- Outbox 事件发布(ownership.removed / ownership.added)
-
wallet-service 对接
- 复用 freeze / unfreeze / internal-transfer 接口
- 新增 LedgerEntryType:
TRANSFER_FREEZE/TRANSFER_PAYMENT
-
Docker 配置
- docker-compose.yml 新增 transfer-service
- Kafka topic 创建脚本
验收标准:
- 完成转让全流程(不含算力和统计)
- 资金正确冻结、结算、回滚
- 持仓数量正确变更
Phase 2:算力调整(contribution-service)
目标:完成算力的事务流水型增量调整。
交付内容:
-
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(计算服务)
-
解锁状态联动
- 卖方 hasAdopted / unlockedLevelDepth / unlockedBonusTiers 重算
- 买方 hasAdopted / unlockedLevelDepth / unlockedBonusTiers 更新
- 买方推荐人 directReferralAdoptedCount 更新 + bonus 补发
-
确认事件发布
contribution.transfer.adjusted事件
验收标准:
- 卖方 effectiveContribution 正确减少
- 买方 effectiveContribution 正确增加
- 省市系统账户正确调整(卖方省市减少,买方省市增加)
- 全网算力总量守恒(卖方减少 = 买方增加 + UnallocatedContribution)
- 历史 ContributionRecord 零修改
- 次日快照正确反映新算力
Phase 3:团队统计(referral-service)
目标:完成团队认种统计的增减和龙虎榜重算。
交付内容:
-
referral-service 纯新增
- planting-transferred.handler.ts
- TeamStatistics.removePersonalPlanting() 新增方法
- TeamStatisticsRepository.batchUpdateTeamCountsForTransfer() 新增方法
- 龙虎榜 max 重算逻辑修正(负数 delta 时全量扫描)
-
确认事件发布
referral.transfer.stats-updated事件
验收标准:
- 卖方链路 totalTeamPlantingCount 正确减少
- 买方链路 totalTeamPlantingCount 正确增加
- 龙虎榜排名正确更新
- selfPlantingCount 正确反映当前持仓
Phase 4:前端(Admin Web + Mobile App)
目标:完成管理端和用户端的完整交互界面。
交付内容:
-
Admin Web(Next.js 14)
- 转让管理列表页
- 转让详情页(含 Saga 进度可视化)
- Sidebar 入口
- 统计看板
-
Mobile App(Flutter)
- 持仓页转让入口
- 转让发起页
- 转让记录页(转出/转入)
- 转让状态实时更新
验收标准:
- 完整的转让用户流程
- 管理端可查看所有转让记录和状态
- 异常状态有明确提示
Phase 5:测试与上线
交付内容:
-
单元测试
- 算力计算正确性(卖方扣减 / 买方分配 / 解锁联动)
- Saga 状态机全路径覆盖
- 补偿逻辑验证
-
集成测试
- 完整转让流程 E2E
- 补偿回滚 E2E
- 并发转让冲突测试
- 部分转让 + 多次转让场景
-
压力测试
- 大量并发转让请求
- Kafka 事件积压恢复
-
上线
- 数据库 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 每日对账任务
-- 算力守恒检查
-- 对于每一笔转让,验证:|卖方扣减| = |买方增加| + |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 │ │
│ │ │ │
│ │ 您的 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 | 关联转让订单号 |