feat(transfer): 树转让功能全量实现(纯新增,零侵入)

实现已认种果树所有权在用户间转让的完整功能。采用方案一:
独立 transfer-service 微服务 + Saga 编排器模式。

=== 架构设计 ===
- Saga 编排器 8 步正向流程:卖方确认 → 冻结资金 → 锁定树 →
  变更所有权 → 调整算力 → 更新统计 → 结算资金 → 完成
- 补偿回滚:任一步骤失败自动反向补偿(解冻资金 → 解锁树)
- 13 种状态:PENDING → SELLER_CONFIRMED → PAYMENT_FROZEN →
  TREES_LOCKED → OWNERSHIP_TRANSFERRED → CONTRIBUTION_ADJUSTED →
  STATS_UPDATED → PAYMENT_SETTLED → COMPLETED / CANCELLED /
  FAILED / ROLLING_BACK / ROLLED_BACK

=== Phase 1-2: transfer-service(独立微服务) ===
新建文件:
- Prisma Schema:transfer_orders + transfer_status_logs + outbox_events
- Domain:TransferOrder 聚合根 + TransferFeeService(5% 手续费)
- Application:TransferApplicationService + SagaOrchestratorService
- Infrastructure:Kafka 事件消费/生产 + Outbox Pattern
- API:TransferController(用户端)+ AdminTransferController(管理端)
- External Clients:wallet/planting/identity-service HTTP 客户端
- Docker + 环境配置

=== Phase 3: 现有微服务扩展(纯追加) ===
planting-service:
- Prisma schema 追加 transferLockId 可空字段
- InternalTransferController:锁定/解锁/执行 3 个新端点
- Kafka handlers:transfer-lock/execute/rollback 事件处理
- main.ts 追加 Kafka consumer group 配置

referral-service:
- PlantingTransferredHandler:处理转让后团队统计更新
- TeamStatisticsAggregate 追加 handleTransfer() 方法
- TeamStatisticsRepository 追加 adjustForTransfer() 方法
- ProvinceCityDistribution 追加 transferTrees() 方法

contribution-service:
- TransferOwnershipHandler:处理所有权变更事件
- TransferAdjustmentService:算力调整(879 行核心逻辑)
- Prisma schema 追加 transferOrderId 可空字段
- ContributionAccount 追加 applyTransferAdjustment() 方法

=== Phase 4A: wallet-service(3 个新内部端点) ===
新建文件:
- FreezeForTransferDto / UnfreezeForTransferDto / SettleTransferDto
- FreezeForTransferCommand / UnfreezeForTransferCommand / SettleTransferPaymentCommand
- InternalTransferWalletController(POST freeze/unfreeze/settle-transfer)

修改文件:
- wallet-application.service.ts 追加 3 组方法(+437 行):
  freezeForTransfer / unfreezeForTransfer / settleTransferPayment
  (乐观锁 + 3 次重试 + Prisma $transaction + 幂等检查)
- 结算操作:单事务内更新 3 个钱包(买方扣减 + 卖方入账 + 手续费归集)

=== Phase 4B: admin-web(转让管理页面) ===
新建文件:
- transferService.ts:API 调用服务 + 完整类型定义
- useTransfers.ts:React Query hooks(list/detail/stats/forceCancel)
- /transfers/page.tsx:列表页(统计卡片 + 搜索筛选 + 分页 + 13 种状态 badge)
- /transfers/[transferOrderNo]/page.tsx:详情页(Saga 时间线 + 状态日志 + 强制取消)
- transfers.module.scss:完整样式

修改文件:
- endpoints.ts 追加 TRANSFERS 端点配置
- Sidebar.tsx 追加「转让管理」菜单项
- hooks/index.ts 追加 useTransfers 导出

=== Phase 4C: mobile-app(转让 UI) ===
新建文件:
- transfer_service.dart:Flutter API 服务 + Model(TransferOrder/Detail/StatusLog)
- transfer_list_page.dart:转让记录列表(全部/转出/转入 Tab + 下拉刷新)
- transfer_detail_page.dart:转让详情(Saga 时间线 + 确认/取消操作)
- transfer_initiate_page.dart:发起转让表单(手续费自动计算)

修改文件:
- injection_container.dart 追加 transferServiceProvider
- route_paths.dart + route_names.dart 追加 3 个路由
- app_router.dart 追加 3 个 GoRoute
- profile_page.dart 追加「发起转让」+「转让记录」按钮行

=== 基础设施 ===
- docker-compose.yml 追加 transfer-service 容器配置
- deploy.sh 追加 transfer-service 部署
- init-databases.sh 追加 transfer_db 数据库初始化

=== 纯新增原则 ===
所有变更均为追加式修改,不修改任何现有业务逻辑:
- 新增 nullable 字段(不影响现有数据)
- 新增 enum 值(不影响现有枚举使用)
- 新增 providers/controllers(不影响现有依赖注入)
- 新增页面/路由(不影响现有页面行为)

回滚方式:删除 transfer-service 目录 + 移除各服务中带 [2026-02-19] 标记的代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-19 03:44:02 -08:00
parent 765a4f41d3
commit b3a3652f21
111 changed files with 21569 additions and 5 deletions

View File

@ -207,6 +207,9 @@ model ContributionRecord {
isExpired Boolean @default(false) @map("is_expired")
expiredAt DateTime? @map("expired_at")
// ========== 转让关联(纯新增,可空字段)==========
transferOrderNo String? @map("transfer_order_no") @db.VarChar(50) // 关联转让订单号(仅转让产生的记录有值)
// ========== 备注 ==========
remark String? @map("remark") @db.VarChar(500) // 备注说明
@ -220,6 +223,7 @@ model ContributionRecord {
@@index([status])
@@index([expireDate])
@@index([isExpired])
@@index([transferOrderNo])
@@map("contribution_records")
}
@ -279,6 +283,9 @@ model UnallocatedContribution {
amount Decimal @map("amount") @db.Decimal(30, 10)
reason String? @db.VarChar(200) // 未分配原因
// ========== 转让关联(纯新增,可空字段)==========
transferOrderNo String? @map("transfer_order_no") @db.VarChar(50) // 关联转让订单号
// ========== 分配状态 ==========
status String @default("PENDING") @map("status") @db.VarChar(20) // PENDING / ALLOCATED_TO_USER / ALLOCATED_TO_HQ
allocatedAt DateTime? @map("allocated_at")
@ -294,6 +301,7 @@ model UnallocatedContribution {
@@index([wouldBeAccountSequence])
@@index([unallocType])
@@index([status])
@@index([transferOrderNo])
@@map("unallocated_contributions")
}
@ -335,6 +343,9 @@ model SystemContributionRecord {
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
amount Decimal @map("amount") @db.Decimal(30, 10)
// 转让关联(纯新增,可空字段)
transferOrderNo String? @map("transfer_order_no") @db.VarChar(50)
effectiveDate DateTime @map("effective_date") @db.Date
expireDate DateTime? @map("expire_date") @db.Date
isExpired Boolean @default(false) @map("is_expired")
@ -348,6 +359,7 @@ model SystemContributionRecord {
@@index([sourceAdoptionId])
@@index([deletedAt])
@@index([sourceType])
@@index([transferOrderNo])
@@map("system_contribution_records")
}

View File

@ -7,6 +7,8 @@ import { UserSyncedHandler } from './event-handlers/user-synced.handler';
import { ReferralSyncedHandler } from './event-handlers/referral-synced.handler';
import { AdoptionSyncedHandler } from './event-handlers/adoption-synced.handler';
import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
// [2026-02-19] 纯新增:转让所有权事件处理器
import { TransferOwnershipHandler } from './event-handlers/transfer-ownership.handler';
// Services
import { ContributionCalculationService } from './services/contribution-calculation.service';
@ -14,6 +16,8 @@ import { ContributionDistributionPublisherService } from './services/contributio
import { ContributionRateService } from './services/contribution-rate.service';
import { BonusClaimService } from './services/bonus-claim.service';
import { SnapshotService } from './services/snapshot.service';
// [2026-02-19] 纯新增:转让算力调整服务
import { TransferAdjustmentService } from './services/transfer-adjustment.service';
// Queries
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
@ -36,6 +40,7 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
ReferralSyncedHandler,
AdoptionSyncedHandler,
CDCEventDispatcher,
TransferOwnershipHandler, // [2026-02-19] 纯新增:转让所有权事件处理器
// Services
ContributionCalculationService,
@ -43,6 +48,7 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
ContributionRateService,
BonusClaimService,
SnapshotService,
TransferAdjustmentService, // [2026-02-19] 纯新增:转让算力调整服务
// Queries
GetContributionAccountQuery,

View File

@ -0,0 +1,217 @@
/**
*
* planting.ownership.removed + planting.ownership.added
* TransferAdjustmentService 88%
*
* kafkajs CDCConsumerService
* ProcessedEvent sourceService + eventId
*
* application.module.ts
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import {
TransferAdjustmentService,
OwnershipRemovedEvent,
OwnershipAddedEvent,
} from '../services/transfer-adjustment.service';
@Injectable()
export class TransferOwnershipHandler implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(TransferOwnershipHandler.name);
private kafka: Kafka;
private removedConsumer: Consumer;
private addedConsumer: Consumer;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
private readonly transferAdjustmentService: TransferAdjustmentService,
) {
const brokers = this.configService
.get<string>('KAFKA_BROKERS', 'localhost:9092')
.split(',');
this.kafka = new Kafka({
clientId: 'contribution-service-transfer',
brokers,
});
this.removedConsumer = this.kafka.consumer({
groupId: 'contribution-service-transfer-removed',
});
this.addedConsumer = this.kafka.consumer({
groupId: 'contribution-service-transfer-added',
});
}
async onModuleInit(): Promise<void> {
try {
// 订阅卖方减持事件
await this.removedConsumer.connect();
await this.removedConsumer.subscribe({
topic: 'planting.ownership.removed',
fromBeginning: false,
});
await this.removedConsumer.run({
eachMessage: async (payload) => {
await this.handleOwnershipRemoved(payload);
},
});
// 订阅买方增持事件
await this.addedConsumer.connect();
await this.addedConsumer.subscribe({
topic: 'planting.ownership.added',
fromBeginning: false,
});
await this.addedConsumer.run({
eachMessage: async (payload) => {
await this.handleOwnershipAdded(payload);
},
});
this.logger.log(
'Subscribed to planting.ownership.removed + planting.ownership.added',
);
} catch (error) {
this.logger.error('Failed to initialize transfer ownership consumers', error);
}
}
async onModuleDestroy(): Promise<void> {
try {
await this.removedConsumer.disconnect();
await this.addedConsumer.disconnect();
} catch (error) {
this.logger.error('Failed to disconnect transfer consumers', error);
}
}
/**
*
*/
private async handleOwnershipRemoved(payload: EachMessagePayload): Promise<void> {
const { message } = payload;
if (!message.value) return;
let event: OwnershipRemovedEvent;
try {
const raw = JSON.parse(message.value.toString());
// 支持两种消息格式:直接 payload 或嵌套在 value 字段中
event = raw.value ? raw.value : raw;
} catch (error) {
this.logger.error('[TRANSFER-REMOVED] Failed to parse message', error);
return;
}
const processedEventId = `removed:${event.transferOrderNo}`;
this.logger.log(
`[TRANSFER-REMOVED] Processing: ${event.transferOrderNo}, ` +
`seller=${event.sellerAccountSequence}, trees=${event.treeCount}`,
);
try {
// 幂等性检查
const existing = await this.prisma.processedEvent.findFirst({
where: {
sourceService: 'planting-service',
eventId: processedEventId,
},
});
if (existing) {
this.logger.log(`[TRANSFER-REMOVED] Already processed: ${processedEventId}`);
return;
}
// 执行卖方算力扣减
await this.transferAdjustmentService.processOwnershipRemoved(event);
// 记录已处理
await this.prisma.processedEvent.create({
data: {
sourceService: 'planting-service',
eventId: processedEventId,
eventType: 'PlantingOwnershipRemoved',
},
});
this.logger.log(
`[TRANSFER-REMOVED] ✓ Processed: ${event.transferOrderNo}, seller=${event.sellerAccountSequence}`,
);
} catch (error) {
this.logger.error(
`[TRANSFER-REMOVED] ✗ Failed for ${event.transferOrderNo}:`,
error,
);
throw error; // 让 kafkajs 重试
}
}
/**
*
*/
private async handleOwnershipAdded(payload: EachMessagePayload): Promise<void> {
const { message } = payload;
if (!message.value) return;
let event: OwnershipAddedEvent;
try {
const raw = JSON.parse(message.value.toString());
event = raw.value ? raw.value : raw;
} catch (error) {
this.logger.error('[TRANSFER-ADDED] Failed to parse message', error);
return;
}
const processedEventId = `added:${event.transferOrderNo}`;
this.logger.log(
`[TRANSFER-ADDED] Processing: ${event.transferOrderNo}, ` +
`buyer=${event.buyerAccountSequence}, trees=${event.treeCount}`,
);
try {
// 幂等性检查
const existing = await this.prisma.processedEvent.findFirst({
where: {
sourceService: 'planting-service',
eventId: processedEventId,
},
});
if (existing) {
this.logger.log(`[TRANSFER-ADDED] Already processed: ${processedEventId}`);
return;
}
// 执行买方算力新增
await this.transferAdjustmentService.processOwnershipAdded(event);
// 记录已处理
await this.prisma.processedEvent.create({
data: {
sourceService: 'planting-service',
eventId: processedEventId,
eventType: 'PlantingOwnershipAdded',
},
});
this.logger.log(
`[TRANSFER-ADDED] ✓ Processed: ${event.transferOrderNo}, buyer=${event.buyerAccountSequence}`,
);
} catch (error) {
this.logger.error(
`[TRANSFER-ADDED] ✗ Failed for ${event.transferOrderNo}:`,
error,
);
throw error; // 让 kafkajs 重试
}
}
}

View File

@ -0,0 +1,879 @@
/**
*
* planting.ownership.removed planting.ownership.added
* 88% 70% + 7.5% + 7.5% + 1% + 2%/
*
*
* - 使 Prisma ContributionAmount
* -
* - 12%
*
* application.module.ts
*/
import { Injectable, Logger } from '@nestjs/common';
import Decimal from 'decimal.js';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
import { SystemAccountRepository } from '../../infrastructure/persistence/repositories/system-account.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
import { BonusClaimService } from './bonus-claim.service';
/** 转让事件通用字段 */
interface TransferEventBase {
transferOrderNo: string;
sourceOrderNo: string;
sourceOrderId: string; // bigint → string
treeCount: number;
contributionPerTree: string;
selectedProvince: string;
selectedCity: string;
originalAdoptionDate: string;
originalExpireDate: string;
}
/** 卖方减持事件 */
export interface OwnershipRemovedEvent extends TransferEventBase {
sellerUserId: string;
sellerAccountSequence: string;
}
/** 买方增持事件 */
export interface OwnershipAddedEvent extends TransferEventBase {
buyerUserId: string;
buyerAccountSequence: string;
}
// 分配比例常量
const RATE_PERSONAL = new Decimal('0.70');
const RATE_LEVEL_PER = new Decimal('0.005');
const RATE_BONUS_PER = new Decimal('0.025');
const RATE_PROVINCE = new Decimal('0.01');
const RATE_CITY = new Decimal('0.02');
@Injectable()
export class TransferAdjustmentService {
private readonly logger = new Logger(TransferAdjustmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly contributionAccountRepository: ContributionAccountRepository,
private readonly syncedDataRepository: SyncedDataRepository,
private readonly systemAccountRepository: SystemAccountRepository,
private readonly outboxRepository: OutboxRepository,
private readonly unitOfWork: UnitOfWork,
private readonly bonusClaimService: BonusClaimService,
) {}
/**
* planting.ownership.removed
* 线
*/
async processOwnershipRemoved(event: OwnershipRemovedEvent): Promise<void> {
const sourceAdoptionId = BigInt(event.sourceOrderId);
const baseContribution = new Decimal(event.contributionPerTree);
const totalContribution = baseContribution.times(event.treeCount);
const transferDate = new Date();
const expireDate = new Date(event.originalExpireDate);
this.logger.log(
`[TRANSFER-OUT] Starting: ${event.transferOrderNo}, seller=${event.sellerAccountSequence}, ` +
`trees=${event.treeCount}, contribution/tree=${event.contributionPerTree}`,
);
await this.unitOfWork.executeInTransaction(async () => {
// 1. 卖方个人算力扣减70%
const personalAmount = totalContribution.times(RATE_PERSONAL);
await this.createTransferRecord({
accountSequence: event.sellerAccountSequence,
sourceType: 'TRANSFER_OUT_PERSONAL',
sourceAdoptionId,
sourceAccountSequence: event.sellerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_PERSONAL,
amount: personalAmount.negated(), // 负数
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转出个人算力`,
});
await this.decrementAccountEffective(event.sellerAccountSequence, personalAmount);
await this.decrementPersonalContribution(event.sellerAccountSequence, personalAmount);
// 2. 卖方上线团队层级扣减每级0.5%最多15级
const sellerReferral = await this.syncedDataRepository.findSyncedReferralByAccountSequence(
event.sellerAccountSequence,
);
if (sellerReferral?.referrerAccountSequence) {
const ancestorChain = await this.syncedDataRepository.findAncestorChain(
sellerReferral.referrerAccountSequence,
15,
);
for (let level = 1; level <= 15; level++) {
const ancestor = ancestorChain[level - 1];
if (!ancestor) break;
const levelAmount = totalContribution.times(RATE_LEVEL_PER);
// 查询该级别是否有已分配的记录
const originalRecord = await this.prisma.contributionRecord.findFirst({
where: {
accountSequence: ancestor.accountSequence,
sourceAdoptionId,
sourceType: 'TEAM_LEVEL',
levelDepth: level,
},
});
if (originalRecord) {
// 有已分配记录 → 创建负数对冲流水
await this.createTransferRecord({
accountSequence: ancestor.accountSequence,
sourceType: 'TRANSFER_OUT_TEAM_LEVEL',
sourceAdoptionId,
sourceAccountSequence: event.sellerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_LEVEL_PER,
levelDepth: level,
amount: levelAmount.negated(),
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转出L${level}团队算力`,
});
await this.decrementAccountEffective(ancestor.accountSequence, levelAmount);
await this.decrementLevelPending(ancestor.accountSequence, level, levelAmount);
}
// 如果在 UnallocatedContribution 中(上线未解锁),标记失效
await this.invalidateUnallocatedForTransfer(
sourceAdoptionId,
event.sellerAccountSequence,
level,
event.transferOrderNo,
);
}
}
// 3. 卖方加成扣减每档2.5%最多3档
for (let tier = 1; tier <= 3; tier++) {
const originalBonus = await this.prisma.contributionRecord.findFirst({
where: {
accountSequence: event.sellerAccountSequence,
sourceAdoptionId,
sourceType: 'TEAM_BONUS',
bonusTier: tier,
},
});
if (originalBonus) {
const bonusAmount = totalContribution.times(RATE_BONUS_PER);
await this.createTransferRecord({
accountSequence: event.sellerAccountSequence,
sourceType: 'TRANSFER_OUT_BONUS',
sourceAdoptionId,
sourceAccountSequence: event.sellerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_BONUS_PER,
bonusTier: tier,
amount: bonusAmount.negated(),
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转出加成T${tier}`,
});
await this.decrementAccountEffective(event.sellerAccountSequence, bonusAmount);
await this.decrementBonusPending(event.sellerAccountSequence, tier, bonusAmount);
}
// 标记未分配的加成算力失效
await this.invalidateUnallocatedBonusForTransfer(
sourceAdoptionId,
event.sellerAccountSequence,
tier,
event.transferOrderNo,
);
}
// 4. 卖方省公司系统账户扣减1%
const provinceAmount = totalContribution.times(RATE_PROVINCE);
await this.adjustSystemAccount(
'PROVINCE',
event.selectedProvince,
sourceAdoptionId,
event.sellerAccountSequence,
'TRANSFER_OUT_SYSTEM_PROVINCE',
RATE_PROVINCE,
provinceAmount.negated(),
transferDate,
expireDate,
event.transferOrderNo,
);
// 5. 卖方市公司系统账户扣减2%
const cityAmount = totalContribution.times(RATE_CITY);
await this.adjustSystemAccount(
'CITY',
event.selectedCity,
sourceAdoptionId,
event.sellerAccountSequence,
'TRANSFER_OUT_SYSTEM_CITY',
RATE_CITY,
cityAmount.negated(),
transferDate,
expireDate,
event.transferOrderNo,
);
// 6. 更新卖方解锁状态(如果卖方不再持有任何树)
await this.updateSellerUnlockStatus(event.sellerAccountSequence);
});
this.logger.log(
`[TRANSFER-OUT] ✓ Completed: ${event.transferOrderNo}, seller=${event.sellerAccountSequence}`,
);
}
/**
* planting.ownership.added
* 线
*/
async processOwnershipAdded(event: OwnershipAddedEvent): Promise<void> {
const sourceAdoptionId = BigInt(event.sourceOrderId);
const baseContribution = new Decimal(event.contributionPerTree);
const totalContribution = baseContribution.times(event.treeCount);
const transferDate = new Date();
const expireDate = new Date(event.originalExpireDate);
this.logger.log(
`[TRANSFER-IN] Starting: ${event.transferOrderNo}, buyer=${event.buyerAccountSequence}, ` +
`trees=${event.treeCount}, contribution/tree=${event.contributionPerTree}`,
);
await this.unitOfWork.executeInTransaction(async () => {
// 确保买方有算力账户
let buyerAccount = await this.contributionAccountRepository.findByAccountSequence(
event.buyerAccountSequence,
);
if (!buyerAccount) {
const { ContributionAccountAggregate } = await import(
'../../domain/aggregates/contribution-account.aggregate'
);
buyerAccount = ContributionAccountAggregate.create(event.buyerAccountSequence);
await this.contributionAccountRepository.save(buyerAccount);
}
// 1. 买方个人算力新增70%
const personalAmount = totalContribution.times(RATE_PERSONAL);
await this.createTransferRecord({
accountSequence: event.buyerAccountSequence,
sourceType: 'TRANSFER_IN_PERSONAL',
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_PERSONAL,
amount: personalAmount, // 正数
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转入个人算力`,
});
await this.incrementAccountEffective(event.buyerAccountSequence, personalAmount);
await this.incrementPersonalContribution(event.buyerAccountSequence, personalAmount);
// 2. 买方上线团队层级分配每级0.5%最多15级
const buyerReferral = await this.syncedDataRepository.findSyncedReferralByAccountSequence(
event.buyerAccountSequence,
);
if (buyerReferral?.referrerAccountSequence) {
const ancestorChain = await this.syncedDataRepository.findAncestorChain(
buyerReferral.referrerAccountSequence,
15,
);
const ancestorAccountSeqs = ancestorChain.map((a) => a.accountSequence);
const ancestorAccounts = await this.contributionAccountRepository.findByAccountSequences(
ancestorAccountSeqs,
);
for (let level = 1; level <= 15; level++) {
const ancestor = ancestorChain[level - 1];
const levelAmount = totalContribution.times(RATE_LEVEL_PER);
if (!ancestor) {
// 无上线 → UnallocatedContribution归总部
await this.createUnallocatedContribution({
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
unallocType: 'LEVEL_NO_ANCESTOR',
levelDepth: level,
amount: levelAmount,
reason: `转让转入:第${level}级无上线`,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
});
continue;
}
const ancestorAccount = ancestorAccounts.get(ancestor.accountSequence);
const unlockedLevelDepth = ancestorAccount?.unlockedLevelDepth ?? 0;
if (unlockedLevelDepth >= level) {
// 上线已解锁 → 分配给上线
await this.createTransferRecord({
accountSequence: ancestor.accountSequence,
sourceType: 'TRANSFER_IN_TEAM_LEVEL',
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_LEVEL_PER,
levelDepth: level,
amount: levelAmount,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转入L${level}团队算力`,
});
await this.incrementAccountEffective(ancestor.accountSequence, levelAmount);
await this.incrementLevelPending(ancestor.accountSequence, level, levelAmount);
} else {
// 上线未解锁 → UnallocatedContribution
await this.createUnallocatedContribution({
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
unallocType: 'LEVEL_OVERFLOW',
wouldBeAccountSequence: ancestor.accountSequence,
levelDepth: level,
amount: levelAmount,
reason: `转让转入:上线${ancestor.accountSequence}未解锁第${level}`,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
});
}
}
} else {
// 买方无推荐链15级全部归总部
for (let level = 1; level <= 15; level++) {
const levelAmount = totalContribution.times(RATE_LEVEL_PER);
await this.createUnallocatedContribution({
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
unallocType: 'LEVEL_NO_ANCESTOR',
levelDepth: level,
amount: levelAmount,
reason: `转让转入:第${level}级无上线(买方无推荐关系)`,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
});
}
}
// 3. 买方加成分配每档2.5%最多3档
// 买方获得树后 hasAdopted = true
const effectiveHasAdopted = buyerAccount.hasAdopted || true;
const directReferralAdoptedCount = buyerAccount.directReferralAdoptedCount;
// 计算买方解锁的加成档位
let unlockedBonusTiers = 0;
if (effectiveHasAdopted) unlockedBonusTiers = 1;
if (directReferralAdoptedCount >= 2) unlockedBonusTiers = 2;
if (directReferralAdoptedCount >= 4) unlockedBonusTiers = 3;
for (let tier = 1; tier <= 3; tier++) {
const bonusAmount = totalContribution.times(RATE_BONUS_PER);
if (unlockedBonusTiers >= tier) {
await this.createTransferRecord({
accountSequence: event.buyerAccountSequence,
sourceType: 'TRANSFER_IN_BONUS',
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
treeCount: event.treeCount,
baseContribution,
distributionRate: RATE_BONUS_PER,
bonusTier: tier,
amount: bonusAmount,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
remark: `转让转入加成T${tier}`,
});
await this.incrementAccountEffective(event.buyerAccountSequence, bonusAmount);
await this.incrementBonusPending(event.buyerAccountSequence, tier, bonusAmount);
} else {
await this.createUnallocatedContribution({
sourceAdoptionId,
sourceAccountSequence: event.buyerAccountSequence,
unallocType: `BONUS_TIER_${tier}`,
wouldBeAccountSequence: event.buyerAccountSequence,
amount: bonusAmount,
reason: `转让转入:买方${event.buyerAccountSequence}未解锁第${tier}档加成`,
transferOrderNo: event.transferOrderNo,
effectiveDate: transferDate,
expireDate,
});
}
}
// 4. 买方省公司系统账户增加1%
const provinceAmount = totalContribution.times(RATE_PROVINCE);
await this.adjustSystemAccount(
'PROVINCE',
event.selectedProvince,
sourceAdoptionId,
event.buyerAccountSequence,
'TRANSFER_IN_SYSTEM_PROVINCE',
RATE_PROVINCE,
provinceAmount, // 正数
transferDate,
expireDate,
event.transferOrderNo,
);
// 5. 买方市公司系统账户增加2%
const cityAmount = totalContribution.times(RATE_CITY);
await this.adjustSystemAccount(
'CITY',
event.selectedCity,
sourceAdoptionId,
event.buyerAccountSequence,
'TRANSFER_IN_SYSTEM_CITY',
RATE_CITY,
cityAmount, // 正数
transferDate,
expireDate,
event.transferOrderNo,
);
// 6. 更新买方解锁状态
await this.updateBuyerUnlockStatus(event.buyerAccountSequence);
// 7. 发布算力调整完成确认事件(通知 Saga
await this.publishTransferAdjustedEvent(event.transferOrderNo);
});
this.logger.log(
`[TRANSFER-IN] ✓ Completed: ${event.transferOrderNo}, buyer=${event.buyerAccountSequence}`,
);
}
// ============================
// 私有辅助方法
// ============================
/**
* Prisma
*/
private async createTransferRecord(params: {
accountSequence: string;
sourceType: string;
sourceAdoptionId: bigint;
sourceAccountSequence: string;
treeCount: number;
baseContribution: Decimal;
distributionRate: Decimal;
levelDepth?: number;
bonusTier?: number;
amount: Decimal;
transferOrderNo: string;
effectiveDate: Date;
expireDate: Date;
remark?: string;
}): Promise<void> {
await this.unitOfWork.getClient().contributionRecord.create({
data: {
accountSequence: params.accountSequence,
sourceType: params.sourceType,
sourceAdoptionId: params.sourceAdoptionId,
sourceAccountSequence: params.sourceAccountSequence,
treeCount: params.treeCount,
baseContribution: params.baseContribution,
distributionRate: params.distributionRate,
levelDepth: params.levelDepth ?? null,
bonusTier: params.bonusTier ?? null,
amount: params.amount,
transferOrderNo: params.transferOrderNo,
status: 'EFFECTIVE',
effectiveDate: params.effectiveDate,
expireDate: params.expireDate,
remark: params.remark ?? null,
},
});
}
/**
*
*/
private async createUnallocatedContribution(params: {
sourceAdoptionId: bigint;
sourceAccountSequence: string;
unallocType: string;
wouldBeAccountSequence?: string;
levelDepth?: number;
amount: Decimal;
reason: string;
transferOrderNo: string;
effectiveDate: Date;
expireDate: Date;
}): Promise<void> {
await this.unitOfWork.getClient().unallocatedContribution.create({
data: {
sourceAdoptionId: params.sourceAdoptionId,
sourceAccountSequence: params.sourceAccountSequence,
unallocType: params.unallocType,
wouldBeAccountSequence: params.wouldBeAccountSequence ?? null,
levelDepth: params.levelDepth ?? null,
amount: params.amount,
reason: params.reason,
transferOrderNo: params.transferOrderNo,
status: 'PENDING',
effectiveDate: params.effectiveDate,
expireDate: params.expireDate,
},
});
// 汇总到总部系统账户
await this.systemAccountRepository.addContribution(
'HEADQUARTERS',
null,
new ContributionAmount(params.amount),
);
}
/**
*
*/
private async invalidateUnallocatedForTransfer(
sourceAdoptionId: bigint,
sourceAccountSequence: string,
levelDepth: number,
transferOrderNo: string,
): Promise<void> {
await this.unitOfWork.getClient().unallocatedContribution.updateMany({
where: {
sourceAdoptionId,
sourceAccountSequence,
levelDepth,
status: 'PENDING',
unallocType: { in: ['LEVEL_OVERFLOW', 'LEVEL_NO_ANCESTOR'] },
},
data: {
status: 'ALLOCATED_TO_HQ',
allocatedAt: new Date(),
},
});
}
/**
*
*/
private async invalidateUnallocatedBonusForTransfer(
sourceAdoptionId: bigint,
sourceAccountSequence: string,
bonusTier: number,
transferOrderNo: string,
): Promise<void> {
await this.unitOfWork.getClient().unallocatedContribution.updateMany({
where: {
sourceAdoptionId,
sourceAccountSequence,
unallocType: `BONUS_TIER_${bonusTier}`,
status: 'PENDING',
},
data: {
status: 'ALLOCATED_TO_HQ',
allocatedAt: new Date(),
},
});
}
/**
* /
*/
private async adjustSystemAccount(
accountType: 'PROVINCE' | 'CITY',
regionCode: string,
sourceAdoptionId: bigint,
sourceAccountSequence: string,
sourceType: string,
distributionRate: Decimal,
amount: Decimal, // 可正可负
effectiveDate: Date,
expireDate: Date,
transferOrderNo: string,
): Promise<void> {
// 确保系统账户存在
await this.systemAccountRepository.ensureSystemAccountsExist();
const systemAccount = await this.systemAccountRepository.findByTypeAndRegion(
accountType,
regionCode,
);
if (!systemAccount) {
// 动态创建省市系统账户
await this.systemAccountRepository.addContribution(
accountType,
regionCode,
new ContributionAmount(0),
);
}
// 使用 Prisma 直接更新(支持负数 increment
const sa = await this.unitOfWork.getClient().systemAccount.findFirst({
where: { accountType, regionCode },
});
if (sa) {
await this.unitOfWork.getClient().systemAccount.update({
where: { id: sa.id },
data: {
contributionBalance: { increment: amount },
},
});
// 创建系统账户明细记录
await this.unitOfWork.getClient().systemContributionRecord.create({
data: {
systemAccountId: sa.id,
sourceAdoptionId,
sourceAccountSequence,
sourceType,
distributionRate: distributionRate.toNumber(),
amount,
transferOrderNo,
effectiveDate,
expireDate,
},
});
}
}
// ============================
// ContributionAccount 原子更新方法
// ============================
private async incrementAccountEffective(accountSequence: string, amount: Decimal): Promise<void> {
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
effectiveContribution: { increment: amount },
updatedAt: new Date(),
},
});
}
private async decrementAccountEffective(accountSequence: string, amount: Decimal): Promise<void> {
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
effectiveContribution: { decrement: amount },
updatedAt: new Date(),
},
});
}
private async incrementPersonalContribution(accountSequence: string, amount: Decimal): Promise<void> {
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
personalContribution: { increment: amount },
updatedAt: new Date(),
},
});
}
private async decrementPersonalContribution(accountSequence: string, amount: Decimal): Promise<void> {
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
personalContribution: { decrement: amount },
updatedAt: new Date(),
},
});
}
private async incrementLevelPending(accountSequence: string, level: number, amount: Decimal): Promise<void> {
const levelField = `level${level}Pending`;
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
[levelField]: { increment: amount },
totalLevelPending: { increment: amount },
totalPending: { increment: amount },
updatedAt: new Date(),
},
});
}
private async decrementLevelPending(accountSequence: string, level: number, amount: Decimal): Promise<void> {
const levelField = `level${level}Pending`;
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
[levelField]: { decrement: amount },
totalLevelPending: { decrement: amount },
totalPending: { decrement: amount },
updatedAt: new Date(),
},
});
}
private async incrementBonusPending(accountSequence: string, tier: number, amount: Decimal): Promise<void> {
const tierField = `bonusTier${tier}Pending`;
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
[tierField]: { increment: amount },
totalBonusPending: { increment: amount },
totalPending: { increment: amount },
updatedAt: new Date(),
},
});
}
private async decrementBonusPending(accountSequence: string, tier: number, amount: Decimal): Promise<void> {
const tierField = `bonusTier${tier}Pending`;
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence },
data: {
[tierField]: { decrement: amount },
totalBonusPending: { decrement: amount },
totalPending: { decrement: amount },
updatedAt: new Date(),
},
});
}
// ============================
// 解锁状态更新
// ============================
/**
*
* hasAdopted
*/
private async updateSellerUnlockStatus(sellerAccountSequence: string): Promise<void> {
// 检查卖方是否还有其他已同步的认种记录
const adoptions = await this.syncedDataRepository.findAdoptionsByAccountSequence(
sellerAccountSequence,
);
// 过滤掉 status 不是 MINING_ENABLED 的
const activeAdoptions = adoptions.filter((a) => a.status === 'MINING_ENABLED');
if (activeAdoptions.length === 0) {
// 卖方不再持有任何活跃树 → 重置解锁状态
const account = await this.contributionAccountRepository.findByAccountSequence(
sellerAccountSequence,
);
if (account && account.hasAdopted) {
// 注意:不追回历史分配,只更新状态影响未来分配
await this.unitOfWork.getClient().contributionAccount.update({
where: { accountSequence: sellerAccountSequence },
data: {
hasAdopted: false,
unlockedLevelDepth: 0,
// unlockedBonusTiers 的重新计算T1 取决于 hasAdopted
unlockedBonusTiers: account.directReferralAdoptedCount >= 4 ? 3 :
account.directReferralAdoptedCount >= 2 ? 2 : 0,
updatedAt: new Date(),
},
});
this.logger.log(
`[TRANSFER-OUT] Seller ${sellerAccountSequence} no longer holds trees, reset unlock status`,
);
}
}
}
/**
*
* hasAdopted
*/
private async updateBuyerUnlockStatus(buyerAccountSequence: string): Promise<void> {
const account = await this.contributionAccountRepository.findByAccountSequence(
buyerAccountSequence,
);
if (!account) return;
if (!account.hasAdopted) {
// 首次获得树 → 解锁 L1-5 + T1
account.markAsAdopted();
await this.contributionAccountRepository.save(account);
this.logger.log(
`[TRANSFER-IN] Buyer ${buyerAccountSequence} marked as adopted, unlocked L1-5 + T1`,
);
}
// 检查买方的推荐人的 directReferralAdoptedCount 是否需要更新
const buyerReferral = await this.syncedDataRepository.findSyncedReferralByAccountSequence(
buyerAccountSequence,
);
if (buyerReferral?.referrerAccountSequence) {
const newCount = await this.syncedDataRepository.getDirectReferralAdoptedCount(
buyerReferral.referrerAccountSequence,
);
const referrerAccount = await this.contributionAccountRepository.findByAccountSequence(
buyerReferral.referrerAccountSequence,
);
if (referrerAccount && newCount > referrerAccount.directReferralAdoptedCount) {
const previousCount = referrerAccount.directReferralAdoptedCount;
// 更新直推认种计数
for (let i = previousCount; i < newCount; i++) {
referrerAccount.incrementDirectReferralAdoptedCount();
}
await this.contributionAccountRepository.save(referrerAccount);
// 触发奖励补发检查
await this.bonusClaimService.checkAndClaimBonus(
buyerReferral.referrerAccountSequence,
previousCount,
newCount,
);
this.logger.log(
`[TRANSFER-IN] Updated referrer ${buyerReferral.referrerAccountSequence} ` +
`directReferralAdoptedCount: ${previousCount}${newCount}`,
);
}
}
}
/**
* Saga
*/
private async publishTransferAdjustedEvent(transferOrderNo: string): Promise<void> {
await this.outboxRepository.save({
aggregateType: 'TransferAdjustment',
aggregateId: transferOrderNo,
eventType: 'ContributionTransferAdjusted',
topic: 'contribution.transfer.adjusted', // 显式指定 topic
key: transferOrderNo,
payload: {
transferOrderNo,
consumerService: 'contribution-service',
success: true,
confirmedAt: new Date().toISOString(),
},
});
this.logger.log(
`[TRANSFER] Published contribution.transfer.adjusted for ${transferOrderNo}`,
);
}
}

View File

@ -8,6 +8,16 @@ export enum ContributionSourceType {
PERSONAL = 'PERSONAL', // 来自自己认种
TEAM_LEVEL = 'TEAM_LEVEL', // 来自团队层级 (1-15级)
TEAM_BONUS = 'TEAM_BONUS', // 来自团队加成奖励 (3档)
// ========== 转让相关(纯新增)==========
// 卖方转出(负数金额)
TRANSFER_OUT_PERSONAL = 'TRANSFER_OUT_PERSONAL', // 卖方个人算力转出
TRANSFER_OUT_TEAM_LEVEL = 'TRANSFER_OUT_TEAM_LEVEL', // 卖方上线团队层级算力转出
TRANSFER_OUT_BONUS = 'TRANSFER_OUT_BONUS', // 卖方个人加成算力转出
// 买方转入(正数金额)
TRANSFER_IN_PERSONAL = 'TRANSFER_IN_PERSONAL', // 买方个人算力转入
TRANSFER_IN_TEAM_LEVEL = 'TRANSFER_IN_TEAM_LEVEL', // 买方上线团队层级算力转入
TRANSFER_IN_BONUS = 'TRANSFER_IN_BONUS', // 买方个人加成算力转入
}
/**

View File

@ -390,6 +390,7 @@ migrate() {
"admin-service"
"presence-service"
"blockchain-service"
"transfer-service"
)
for svc in "${services[@]}"; do

View File

@ -20,7 +20,7 @@ services:
TZ: Asia/Shanghai
POSTGRES_USER: ${POSTGRES_USER:-rwa_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rwa_secure_password}
POSTGRES_MULTIPLE_DATABASES: rwa_identity,rwa_wallet,rwa_mpc,rwa_backup,rwa_planting,rwa_referral,rwa_reward,rwa_leaderboard,rwa_reporting,rwa_authorization,rwa_admin,rwa_presence,rwa_blockchain
POSTGRES_MULTIPLE_DATABASES: rwa_identity,rwa_wallet,rwa_mpc,rwa_backup,rwa_planting,rwa_referral,rwa_reward,rwa_leaderboard,rwa_reporting,rwa_authorization,rwa_admin,rwa_presence,rwa_blockchain,rwa_transfer
ports:
# 安全加固: 仅绑定 127.0.0.1, 禁止公网直连数据库
- "127.0.0.1:5432:5432"
@ -782,6 +782,54 @@ services:
networks:
- rwa-network
# ===========================================================================
# Transfer Service (树转让服务)
# ===========================================================================
transfer-service:
build:
context: ./transfer-service
dockerfile: Dockerfile
container_name: rwa-transfer-service
ports:
- "3013:3013"
environment:
- TZ=Asia/Shanghai
- NODE_ENV=production
- APP_PORT=3013
- DATABASE_URL=postgresql://${POSTGRES_USER:-rwa_user}:${POSTGRES_PASSWORD:-rwa_secure_password}@postgres:5432/rwa_transfer?schema=public
- JWT_SECRET=${JWT_SECRET}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=17
- KAFKA_BROKERS=kafka:29092
- KAFKA_CLIENT_ID=transfer-service
- KAFKA_GROUP_ID=transfer-service-group
- WALLET_SERVICE_URL=http://wallet-service:3001
- IDENTITY_SERVICE_URL=http://identity-service:3000
- PLANTING_SERVICE_URL=http://planting-service:3003
- REFERRAL_SERVICE_URL=http://referral-service:3004
- CONTRIBUTION_SERVICE_URL=http://contribution-service:3020
- OUTBOX_POLL_INTERVAL_MS=1000
- OUTBOX_BATCH_SIZE=100
- OUTBOX_CONFIRMATION_TIMEOUT_MINUTES=5
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
kafka:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3013/api/v1/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 60s
restart: unless-stopped
networks:
- rwa-network
# ===========================================================================
# Volumes
# ===========================================================================

View File

@ -20,6 +20,9 @@ model PlantingOrder {
treeCount Int @map("tree_count")
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
// 转让锁定(纯新增,不影响现有逻辑)
transferLockedCount Int @default(0) @map("transfer_locked_count")
// 省市选择 (不可修改)
selectedProvince String? @map("selected_province") @db.VarChar(10)
selectedCity String? @map("selected_city") @db.VarChar(10)
@ -535,3 +538,47 @@ model PrePlantingRewardEntry {
@@index([createdAt])
@@map("pre_planting_reward_entries")
}
// ============================================
// 转让记录表(纯新增,树转让功能)
// 记录每笔树转让在 planting-service 中的状态
// LOCKED → TRANSFERRED → ROLLED_BACK
// ============================================
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)
// ========== 买方LOCK 阶段为空EXECUTE 阶段填入)==========
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)
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")
}

View File

@ -9,6 +9,8 @@ import {
} from './controllers/contract-signing.controller';
// [2026-02-05] 新增:合同管理内部 API
import { ContractAdminController } from './controllers/contract-admin.controller';
// [纯新增] 转让内部 API
import { InternalTransferController } from './controllers/internal-transfer.controller';
import { ApplicationModule } from '../application/application.module';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ -23,6 +25,8 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
ContractSigningConfigController,
// [2026-02-05] 新增:合同管理内部 API
ContractAdminController,
// [纯新增] 转让内部 API
InternalTransferController,
],
providers: [JwtAuthGuard],
})

View File

@ -0,0 +1,152 @@
/**
* API
* transfer-service
* JWT
*
* api.module.ts
*/
import {
Controller,
Get,
Param,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@ApiTags('内部接口-转让')
@Controller('internal')
export class InternalTransferController {
private readonly logger = new Logger(InternalTransferController.name);
constructor(private readonly prisma: PrismaService) {}
/**
*
* transfer-service
*/
@Get('planting-orders/:orderNo')
@ApiOperation({ summary: '查询认种订单(内部)' })
async getPlantingOrder(
@Param('orderNo') orderNo: string,
): Promise<{
id: string;
orderNo: string;
userId: string;
accountSequence: string;
treeCount: number;
status: string;
selectedProvince: string;
selectedCity: string;
transferLockedCount: number;
transferredCount: number;
availableForTransfer: number;
miningEnabledAt: string | null;
createdAt: string;
}> {
const order = await this.prisma.plantingOrder.findUnique({
where: { orderNo },
});
if (!order) {
throw new NotFoundException(`认种订单 ${orderNo} 不存在`);
}
// 计算已完成转让的棵数
const transferredAgg =
await this.prisma.plantingTransferRecord.aggregate({
where: { sourceOrderNo: orderNo, status: 'TRANSFERRED' },
_sum: { treeCount: true },
});
const transferredCount = transferredAgg._sum.treeCount || 0;
const availableForTransfer =
order.treeCount -
(order.transferLockedCount || 0) -
transferredCount;
return {
id: order.id.toString(),
orderNo: order.orderNo,
userId: order.userId.toString(),
accountSequence: order.accountSequence,
treeCount: order.treeCount,
status: order.status,
selectedProvince: order.selectedProvince || '',
selectedCity: order.selectedCity || '',
transferLockedCount: order.transferLockedCount || 0,
transferredCount,
availableForTransfer: Math.max(0, availableForTransfer),
miningEnabledAt: order.miningEnabledAt?.toISOString() || null,
createdAt: order.createdAt.toISOString(),
};
}
/**
*
* transfer-service
*/
@Get('planting-positions/:userId')
@ApiOperation({ summary: '查询用户持仓(内部)' })
async getUserPosition(
@Param('userId') userId: string,
): Promise<{
userId: string;
totalTreeCount: number;
effectiveTreeCount: number;
}> {
const position = await this.prisma.plantingPosition.findUnique({
where: { userId: BigInt(userId) },
});
if (!position) {
throw new NotFoundException(`用户 ${userId} 持仓不存在`);
}
return {
userId: position.userId.toString(),
totalTreeCount: position.totalTreeCount,
effectiveTreeCount: position.effectiveTreeCount,
};
}
/**
*
* transfer-service
*/
@Get('planting-orders/:orderNo/contribution')
@ApiOperation({ summary: '查询认种订单算力信息(内部)' })
async getContributionPerTree(
@Param('orderNo') orderNo: string,
): Promise<{
contributionPerTree: string;
adoptionDate: string;
expireDate: string;
}> {
const order = await this.prisma.plantingOrder.findUnique({
where: { orderNo },
});
if (!order) {
throw new NotFoundException(`认种订单 ${orderNo} 不存在`);
}
// 每棵树的基础算力值 = 总金额 / 棵数
const contributionPerTree = order.totalAmount.div(order.treeCount);
// 认种生效日期(优先使用支付时间)
const adoptionDate =
order.paidAt || order.miningEnabledAt || order.createdAt;
// 到期日期(认种合同期 5 年)
const expireDate = new Date(adoptionDate);
expireDate.setFullYear(expireDate.getFullYear() + 5);
return {
contributionPerTree: contributionPerTree.toString(),
adoptionDate: adoptionDate.toISOString(),
expireDate: expireDate.toISOString(),
};
}
}

View File

@ -17,6 +17,10 @@ import { OutboxPublisherService } from './kafka/outbox-publisher.service';
import { EventAckController } from './kafka/event-ack.controller';
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
import { KycVerifiedEventConsumer } from './kafka/kyc-verified-event.consumer';
// [纯新增] 转让事件处理器
import { TransferLockHandler } from './kafka/transfer-lock.handler';
import { TransferExecuteHandler } from './kafka/transfer-execute.handler';
import { TransferRollbackHandler } from './kafka/transfer-rollback.handler';
import { PdfGeneratorService } from './pdf/pdf-generator.service';
import { MinioStorageService } from './storage/minio-storage.service';
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
@ -36,7 +40,15 @@ import { ContractSigningService } from '../application/services/contract-signing
}),
KafkaModule,
],
controllers: [EventAckController, ContractSigningEventConsumer, KycVerifiedEventConsumer],
controllers: [
EventAckController,
ContractSigningEventConsumer,
KycVerifiedEventConsumer,
// [纯新增] 转让事件处理器
TransferLockHandler,
TransferExecuteHandler,
TransferRollbackHandler,
],
providers: [
PrismaService,
{

View File

@ -0,0 +1,241 @@
/**
*
* transfer.ownership.execute
* Outbox
*
* infrastructure.module.ts
*/
import { Controller, Logger } from '@nestjs/common';
import { EventPattern, Payload, Ctx, KafkaContext } from '@nestjs/microservices';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../persistence/prisma/prisma.service';
interface TransferExecutePayload {
transferOrderNo: string;
sellerUserId: string;
sellerAccountSequence: string;
buyerUserId: string;
buyerAccountSequence: string;
sourceOrderNo: string;
treeCount: number;
contributionPerTree: string;
selectedProvince: string;
selectedCity: string;
originalAdoptionDate: string;
originalExpireDate: string;
}
@Controller()
export class TransferExecuteHandler {
private readonly logger = new Logger(TransferExecuteHandler.name);
constructor(private readonly prisma: PrismaService) {}
@EventPattern('transfer.ownership.execute')
async handle(
@Payload() message: TransferExecutePayload,
@Ctx() context: KafkaContext,
): Promise<void> {
const {
transferOrderNo,
sellerUserId,
sellerAccountSequence,
buyerUserId,
buyerAccountSequence,
sourceOrderNo,
treeCount,
contributionPerTree,
selectedProvince,
selectedCity,
originalAdoptionDate,
originalExpireDate,
} = message;
this.logger.log(
`[TRANSFER-EXECUTE] Received execute request: ${transferOrderNo}, ` +
`seller=${sellerUserId} → buyer=${buyerUserId}, trees=${treeCount}`,
);
try {
// 幂等性检查:如果已经转让完成,跳过
const existing = await this.prisma.plantingTransferRecord.findUnique({
where: { transferOrderNo },
});
if (existing && existing.status === 'TRANSFERRED') {
this.logger.log(
`[TRANSFER-EXECUTE] Already transferred: ${transferOrderNo}`,
);
return;
}
if (!existing || existing.status !== 'LOCKED') {
this.logger.error(
`[TRANSFER-EXECUTE] Invalid state: ${transferOrderNo}, status=${existing?.status}`,
);
return;
}
const now = new Date();
const sellerUserIdBigInt = BigInt(sellerUserId);
const buyerUserIdBigInt = BigInt(buyerUserId);
// 事务:所有权变更 + Outbox 事件
await this.prisma.executeTransaction(async (tx) => {
// 1. 更新 PlantingTransferRecord → TRANSFERRED
await tx.plantingTransferRecord.update({
where: { transferOrderNo },
data: {
toUserId: buyerUserIdBigInt,
toAccountSequence: buyerAccountSequence,
contributionPerTree: new Prisma.Decimal(contributionPerTree),
status: 'TRANSFERRED',
transferredAt: now,
},
});
// 2. 更新 PlantingOrder.transferLockedCount锁定已消费
await tx.plantingOrder.update({
where: { id: existing.sourceOrderId },
data: {
transferLockedCount: { decrement: treeCount },
},
});
// 3. 更新卖方 PlantingPosition减持
await tx.plantingPosition.update({
where: { userId: sellerUserIdBigInt },
data: {
effectiveTreeCount: { decrement: treeCount },
totalTreeCount: { decrement: treeCount },
},
});
// 4. 更新卖方 PositionDistribution减持
await tx.positionDistribution.update({
where: {
userId_provinceCode_cityCode: {
userId: sellerUserIdBigInt,
provinceCode: selectedProvince,
cityCode: selectedCity,
},
},
data: {
treeCount: { decrement: treeCount },
},
});
// 5. Upsert 买方 PlantingPosition增持
await tx.plantingPosition.upsert({
where: { userId: buyerUserIdBigInt },
create: {
userId: buyerUserIdBigInt,
effectiveTreeCount: treeCount,
totalTreeCount: treeCount,
pendingTreeCount: 0,
},
update: {
effectiveTreeCount: { increment: treeCount },
totalTreeCount: { increment: treeCount },
},
});
// 6. Upsert 买方 PositionDistribution增持
await tx.positionDistribution.upsert({
where: {
userId_provinceCode_cityCode: {
userId: buyerUserIdBigInt,
provinceCode: selectedProvince,
cityCode: selectedCity,
},
},
create: {
userId: buyerUserIdBigInt,
provinceCode: selectedProvince,
cityCode: selectedCity,
treeCount: treeCount,
},
update: {
treeCount: { increment: treeCount },
},
});
// 7. 创建 3 个 Outbox 事件B 方案,由 OutboxPublisherService 轮询发布)
const commonPayload = {
transferOrderNo,
sourceOrderNo,
sourceOrderId: existing.sourceOrderId.toString(), // bigint → string供 contribution-service 查找原始认种记录
treeCount,
contributionPerTree,
selectedProvince,
selectedCity,
originalAdoptionDate,
originalExpireDate,
};
await tx.outboxEvent.createMany({
data: [
// 事件 1通知 transfer-service 所有权变更完成
{
eventType: 'PlantingTransferCompleted',
topic: 'planting.transfer.completed',
key: transferOrderNo,
payload: {
...commonPayload,
sellerUserId,
sellerAccountSequence,
buyerUserId,
buyerAccountSequence,
status: 'TRANSFERRED',
transferredAt: now.toISOString(),
} as unknown as Prisma.JsonObject,
aggregateId: transferOrderNo,
aggregateType: 'PlantingTransfer',
status: 'PENDING',
},
// 事件 2通知 contribution + referral 卖方减持
{
eventType: 'PlantingOwnershipRemoved',
topic: 'planting.ownership.removed',
key: sellerUserId,
payload: {
...commonPayload,
sellerUserId,
sellerAccountSequence,
} as unknown as Prisma.JsonObject,
aggregateId: transferOrderNo,
aggregateType: 'PlantingTransfer',
status: 'PENDING',
},
// 事件 3通知 contribution + referral 买方增持
{
eventType: 'PlantingOwnershipAdded',
topic: 'planting.ownership.added',
key: buyerUserId,
payload: {
...commonPayload,
buyerUserId,
buyerAccountSequence,
} as unknown as Prisma.JsonObject,
aggregateId: transferOrderNo,
aggregateType: 'PlantingTransfer',
status: 'PENDING',
},
],
});
});
this.logger.log(
`[TRANSFER-EXECUTE] ✓ Ownership transferred: ${transferOrderNo}, ` +
`seller=${sellerUserId} → buyer=${buyerUserId}, ${treeCount} trees`,
);
} catch (error) {
this.logger.error(
`[TRANSFER-EXECUTE] ✗ Failed to execute transfer ${transferOrderNo}:`,
error,
);
// 不抛出异常 - 消息会被 Kafka 重新投递(通过 transfer-service Saga 重试机制)
}
}
}

View File

@ -0,0 +1,183 @@
/**
*
* transfer.trees.lock
*
*
* infrastructure.module.ts
*/
import { Controller, Logger, Inject } from '@nestjs/common';
import { EventPattern, Payload, Ctx, KafkaContext } from '@nestjs/microservices';
import { ClientKafka } from '@nestjs/microservices';
import { PrismaService } from '../persistence/prisma/prisma.service';
interface TransferLockPayload {
transferOrderNo: string;
sellerUserId: string;
sourceOrderNo: string;
treeCount: number;
sellerAccountSequence: string;
}
@Controller()
export class TransferLockHandler {
private readonly logger = new Logger(TransferLockHandler.name);
constructor(
private readonly prisma: PrismaService,
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
) {}
@EventPattern('transfer.trees.lock')
async handle(
@Payload() message: TransferLockPayload,
@Ctx() context: KafkaContext,
): Promise<void> {
const {
transferOrderNo,
sellerUserId,
sourceOrderNo,
treeCount,
sellerAccountSequence,
} = message;
this.logger.log(
`[TRANSFER-LOCK] Received lock request: ${transferOrderNo}, orderNo=${sourceOrderNo}, trees=${treeCount}`,
);
try {
// 幂等性检查:如果已经锁定,直接返回成功
const existing = await this.prisma.plantingTransferRecord.findUnique({
where: { transferOrderNo },
});
if (existing) {
this.logger.log(
`[TRANSFER-LOCK] Already locked: ${transferOrderNo}, status=${existing.status}`,
);
this.publishAck(transferOrderNo, true);
return;
}
// 验证订单存在且属于卖方
const order = await this.prisma.plantingOrder.findUnique({
where: { orderNo: sourceOrderNo },
});
if (!order) {
this.logger.error(`[TRANSFER-LOCK] Order not found: ${sourceOrderNo}`);
this.publishAck(transferOrderNo, false, '认种订单不存在');
return;
}
if (order.userId !== BigInt(sellerUserId)) {
this.logger.error(
`[TRANSFER-LOCK] Order ${sourceOrderNo} does not belong to seller ${sellerUserId}`,
);
this.publishAck(transferOrderNo, false, '订单不属于卖方');
return;
}
if (order.status !== 'MINING_ENABLED') {
this.logger.error(
`[TRANSFER-LOCK] Order ${sourceOrderNo} status is ${order.status}, not MINING_ENABLED`,
);
this.publishAck(transferOrderNo, false, '订单状态不允许转让');
return;
}
// 计算可转让棵数
const transferredAgg = await this.prisma.plantingTransferRecord.aggregate({
where: { sourceOrderNo, status: 'TRANSFERRED' },
_sum: { treeCount: true },
});
const transferredCount = transferredAgg._sum.treeCount || 0;
const available =
order.treeCount - (order.transferLockedCount || 0) - transferredCount;
if (available < treeCount) {
this.logger.error(
`[TRANSFER-LOCK] Insufficient trees: available=${available}, requested=${treeCount}`,
);
this.publishAck(
transferOrderNo,
false,
`可转让棵数不足,可用${available}`,
);
return;
}
// 计算算力值和日期
const contributionPerTree = order.totalAmount.div(order.treeCount);
const adoptionDate = order.paidAt || order.miningEnabledAt || order.createdAt;
const expireDate = new Date(adoptionDate);
expireDate.setFullYear(expireDate.getFullYear() + 5);
// 事务:创建锁定记录 + 更新锁定计数
await this.prisma.executeTransaction(async (tx) => {
await tx.plantingTransferRecord.create({
data: {
transferOrderNo,
sourceOrderNo,
sourceOrderId: order.id,
fromUserId: BigInt(sellerUserId),
fromAccountSequence: sellerAccountSequence,
treeCount,
selectedProvince: order.selectedProvince || '',
selectedCity: order.selectedCity || '',
contributionPerTree,
originalAdoptionDate: adoptionDate,
originalExpireDate: expireDate,
status: 'LOCKED',
lockedAt: new Date(),
},
});
await tx.plantingOrder.update({
where: { id: order.id },
data: {
transferLockedCount: { increment: treeCount },
},
});
});
this.logger.log(
`[TRANSFER-LOCK] ✓ Trees locked: ${transferOrderNo}, ${treeCount} trees from order ${sourceOrderNo}`,
);
this.publishAck(transferOrderNo, true);
} catch (error) {
this.logger.error(
`[TRANSFER-LOCK] ✗ Failed to lock trees for ${transferOrderNo}:`,
error,
);
this.publishAck(
transferOrderNo,
false,
`锁定失败: ${(error as Error).message}`,
);
}
}
private publishAck(
transferOrderNo: string,
success: boolean,
error?: string,
): void {
const message = {
key: transferOrderNo,
value: JSON.stringify({
transferOrderNo,
success,
error,
confirmedAt: new Date().toISOString(),
}),
};
this.kafkaClient.emit('transfer.trees.lock.ack', message);
this.logger.log(
`[TRANSFER-LOCK] ACK sent: ${transferOrderNo}, success=${success}`,
);
}
}

View File

@ -0,0 +1,131 @@
/**
*
* transfer.trees.unlock
*
*
* infrastructure.module.ts
*/
import { Controller, Logger, Inject } from '@nestjs/common';
import { EventPattern, Payload, Ctx, KafkaContext } from '@nestjs/microservices';
import { ClientKafka } from '@nestjs/microservices';
import { PrismaService } from '../persistence/prisma/prisma.service';
interface TransferUnlockPayload {
transferOrderNo: string;
sourceOrderNo: string;
treeCount: number;
}
@Controller()
export class TransferRollbackHandler {
private readonly logger = new Logger(TransferRollbackHandler.name);
constructor(
private readonly prisma: PrismaService,
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
) {}
@EventPattern('transfer.trees.unlock')
async handle(
@Payload() message: TransferUnlockPayload,
@Ctx() context: KafkaContext,
): Promise<void> {
const { transferOrderNo, sourceOrderNo, treeCount } = message;
this.logger.log(
`[TRANSFER-ROLLBACK] Received unlock request: ${transferOrderNo}, orderNo=${sourceOrderNo}`,
);
try {
// 幂等性检查
const existing = await this.prisma.plantingTransferRecord.findUnique({
where: { transferOrderNo },
});
if (!existing) {
this.logger.warn(
`[TRANSFER-ROLLBACK] Record not found: ${transferOrderNo}, sending success ACK`,
);
this.publishAck(transferOrderNo, true);
return;
}
if (existing.status === 'ROLLED_BACK') {
this.logger.log(
`[TRANSFER-ROLLBACK] Already rolled back: ${transferOrderNo}`,
);
this.publishAck(transferOrderNo, true);
return;
}
if (existing.status !== 'LOCKED') {
this.logger.error(
`[TRANSFER-ROLLBACK] Cannot rollback: ${transferOrderNo}, status=${existing.status}`,
);
this.publishAck(
transferOrderNo,
false,
`状态不允许回滚: ${existing.status}`,
);
return;
}
// 事务:更新记录状态 + 恢复锁定计数
await this.prisma.executeTransaction(async (tx) => {
await tx.plantingTransferRecord.update({
where: { transferOrderNo },
data: {
status: 'ROLLED_BACK',
rolledBackAt: new Date(),
},
});
await tx.plantingOrder.update({
where: { id: existing.sourceOrderId },
data: {
transferLockedCount: { decrement: existing.treeCount },
},
});
});
this.logger.log(
`[TRANSFER-ROLLBACK] ✓ Trees unlocked: ${transferOrderNo}, ${existing.treeCount} trees`,
);
this.publishAck(transferOrderNo, true);
} catch (error) {
this.logger.error(
`[TRANSFER-ROLLBACK] ✗ Failed to rollback ${transferOrderNo}:`,
error,
);
this.publishAck(
transferOrderNo,
false,
`回滚失败: ${(error as Error).message}`,
);
}
}
private publishAck(
transferOrderNo: string,
success: boolean,
error?: string,
): void {
const message = {
key: transferOrderNo,
value: JSON.stringify({
transferOrderNo,
success,
error,
confirmedAt: new Date().toISOString(),
}),
};
this.kafkaClient.emit('transfer.trees.unlock.ack', message);
this.logger.log(
`[TRANSFER-ROLLBACK] ACK sent: ${transferOrderNo}, success=${success}`,
);
}
}

View File

@ -90,9 +90,23 @@ async function bootstrap() {
},
});
// 微服务 4: 用于监听 transfer-service 的转让事件(纯新增)
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'planting-service-transfer',
brokers: kafkaBrokers,
},
consumer: {
groupId: `${kafkaGroupId}-transfer`,
},
},
});
// 启动所有 Kafka 微服务
await app.startAllMicroservices();
logger.log('Kafka microservices started (ACK + Contract Signing + Identity Events)');
logger.log('Kafka microservices started (ACK + Contract Signing + Identity Events + Transfer)');
const port = process.env.APP_PORT || 3003;
await app.listen(port);

View File

@ -1,3 +1,5 @@
export * from './user-registered.handler';
export * from './planting-created.handler';
export * from './contract-signing.handler';
// [纯新增] 转让事件处理器
export * from './planting-transferred.handler';

View File

@ -0,0 +1,316 @@
/**
*
* planting.ownership.removed + planting.ownership.added
* /
*
* event-handlers/index.ts application.module.ts
*/
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
IReferralRelationshipRepository,
REFERRAL_RELATIONSHIP_REPOSITORY,
ITeamStatisticsRepository,
TEAM_STATISTICS_REPOSITORY,
} from '../../domain';
import { KafkaService } from '../../infrastructure/messaging/kafka.service';
import { EventAckPublisher } from '../../infrastructure/kafka/event-ack.publisher';
import { PrismaService } from '../../infrastructure/database/prisma.service';
interface PlantingOwnershipRemovedEvent {
transferOrderNo: string;
sourceOrderNo: string;
sellerUserId: string;
sellerAccountSequence: string;
treeCount: number;
contributionPerTree: string;
selectedProvince: string;
selectedCity: string;
originalAdoptionDate: string;
originalExpireDate: string;
_outbox?: {
id: string;
aggregateId: string;
eventType: string;
ackTopic: string;
};
}
interface PlantingOwnershipAddedEvent {
transferOrderNo: string;
sourceOrderNo: string;
buyerUserId: string;
buyerAccountSequence: string;
treeCount: number;
contributionPerTree: string;
selectedProvince: string;
selectedCity: string;
originalAdoptionDate: string;
originalExpireDate: string;
_outbox?: {
id: string;
aggregateId: string;
eventType: string;
ackTopic: string;
};
}
@Injectable()
export class PlantingTransferredHandler implements OnModuleInit {
private readonly logger = new Logger(PlantingTransferredHandler.name);
constructor(
private readonly kafkaService: KafkaService,
private readonly eventAckPublisher: EventAckPublisher,
private readonly prisma: PrismaService,
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
private readonly referralRepo: IReferralRelationshipRepository,
@Inject(TEAM_STATISTICS_REPOSITORY)
private readonly teamStatsRepo: ITeamStatisticsRepository,
) {}
async onModuleInit() {
// 订阅卖方减持事件
await this.kafkaService.subscribe(
'referral-service-transfer-removed',
['planting.ownership.removed'],
this.handleOwnershipRemoved.bind(this),
);
// 订阅买方增持事件
await this.kafkaService.subscribe(
'referral-service-transfer-added',
['planting.ownership.added'],
this.handleOwnershipAdded.bind(this),
);
this.logger.log('Subscribed to planting.ownership.removed + planting.ownership.added');
}
/**
*
* 1. selfPlantingCount -= treeCount
* 2. delta
*/
private async handleOwnershipRemoved(
topic: string,
message: Record<string, unknown>,
): Promise<void> {
const event = message as unknown as PlantingOwnershipRemovedEvent;
const outboxInfo = event._outbox;
const eventId = outboxInfo?.aggregateId || event.transferOrderNo;
this.logger.log(
`[TRANSFER-REMOVED] Processing: transferOrderNo=${event.transferOrderNo}, ` +
`seller=${event.sellerAccountSequence}, trees=${event.treeCount}`,
);
try {
// 幂等性检查
const processedEventId = `removed:${eventId}`;
const existing = await this.prisma.processedEvent.findUnique({
where: { eventId: processedEventId },
});
if (existing) {
this.logger.log(`[TRANSFER-REMOVED] Already processed: ${processedEventId}`);
if (outboxInfo) {
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
}
return;
}
// 1. 查找卖方推荐关系
const relationship = await this.referralRepo.findByAccountSequence(
event.sellerAccountSequence,
);
if (!relationship) {
this.logger.warn(
`[TRANSFER-REMOVED] No referral relationship for ${event.sellerAccountSequence}`,
);
await this.markProcessedAndAck(processedEventId, 'PlantingOwnershipRemoved', outboxInfo, eventId);
return;
}
const userId = relationship.userId;
// 2. 更新卖方个人认种统计
const sellerStats = await this.teamStatsRepo.findByUserId(userId);
if (sellerStats) {
sellerStats.removePersonalPlanting(
event.treeCount,
event.selectedProvince,
event.selectedCity,
);
await this.teamStatsRepo.save(sellerStats);
sellerStats.clearDomainEvents();
}
// 3. 更新卖方所有上级的团队统计(负数 delta
const ancestors = relationship.getAllAncestorIds();
if (ancestors.length > 0) {
const updates = ancestors.map((ancestorId, i) => ({
userId: ancestorId,
countDelta: -event.treeCount, // 关键:负数
provinceCode: event.selectedProvince,
cityCode: event.selectedCity,
fromDirectReferralId: i === 0 ? userId : ancestors[0],
}));
await this.teamStatsRepo.batchUpdateTeamCountsForTransfer(updates);
}
// 4. 记录已处理 + 发送 ACK
await this.markProcessedAndAck(processedEventId, 'PlantingOwnershipRemoved', outboxInfo, eventId);
this.logger.log(
`[TRANSFER-REMOVED] ✓ Processed: seller=${event.sellerAccountSequence}, ` +
`trees=${event.treeCount}, ancestors=${ancestors.length}`,
);
} catch (error) {
this.logger.error(
`[TRANSFER-REMOVED] ✗ Failed for ${event.transferOrderNo}:`,
error,
);
if (outboxInfo) {
const msg = error instanceof Error ? error.message : String(error);
await this.eventAckPublisher.sendFailure(eventId, outboxInfo.eventType, msg);
}
throw error;
}
}
/**
*
* 1. selfPlantingCount += treeCount
* 2. delta
* 3. Saga
*/
private async handleOwnershipAdded(
topic: string,
message: Record<string, unknown>,
): Promise<void> {
const event = message as unknown as PlantingOwnershipAddedEvent;
const outboxInfo = event._outbox;
const eventId = outboxInfo?.aggregateId || event.transferOrderNo;
this.logger.log(
`[TRANSFER-ADDED] Processing: transferOrderNo=${event.transferOrderNo}, ` +
`buyer=${event.buyerAccountSequence}, trees=${event.treeCount}`,
);
try {
// 幂等性检查
const processedEventId = `added:${eventId}`;
const existing = await this.prisma.processedEvent.findUnique({
where: { eventId: processedEventId },
});
if (existing) {
this.logger.log(`[TRANSFER-ADDED] Already processed: ${processedEventId}`);
if (outboxInfo) {
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
}
return;
}
// 1. 查找买方推荐关系
const relationship = await this.referralRepo.findByAccountSequence(
event.buyerAccountSequence,
);
if (!relationship) {
this.logger.warn(
`[TRANSFER-ADDED] No referral relationship for ${event.buyerAccountSequence}`,
);
await this.markProcessedAndAck(processedEventId, 'PlantingOwnershipAdded', outboxInfo, eventId);
return;
}
const userId = relationship.userId;
// 2. 更新买方个人认种统计
const buyerStats = await this.teamStatsRepo.findByUserId(userId);
if (buyerStats) {
buyerStats.addPersonalPlanting(
event.treeCount,
event.selectedProvince,
event.selectedCity,
);
await this.teamStatsRepo.save(buyerStats);
buyerStats.clearDomainEvents();
}
// 3. 更新买方所有上级的团队统计(正数 delta复用现有方法
const ancestors = relationship.getAllAncestorIds();
if (ancestors.length > 0) {
const updates = ancestors.map((ancestorId, i) => ({
userId: ancestorId,
countDelta: event.treeCount, // 正数
provinceCode: event.selectedProvince,
cityCode: event.selectedCity,
fromDirectReferralId: i === 0 ? userId : ancestors[0],
}));
await this.teamStatsRepo.batchUpdateTeamCounts(updates);
}
// 4. 记录已处理 + 发送 ACK
await this.markProcessedAndAck(processedEventId, 'PlantingOwnershipAdded', outboxInfo, eventId);
// 5. 两方都处理完后,发布确认事件通知 transfer-service Saga 推进
await this.publishTransferStatsUpdated(event.transferOrderNo);
this.logger.log(
`[TRANSFER-ADDED] ✓ Processed: buyer=${event.buyerAccountSequence}, ` +
`trees=${event.treeCount}, ancestors=${ancestors.length}`,
);
} catch (error) {
this.logger.error(
`[TRANSFER-ADDED] ✗ Failed for ${event.transferOrderNo}:`,
error,
);
if (outboxInfo) {
const msg = error instanceof Error ? error.message : String(error);
await this.eventAckPublisher.sendFailure(eventId, outboxInfo.eventType, msg);
}
throw error;
}
}
/**
* Saga
*/
private async publishTransferStatsUpdated(transferOrderNo: string): Promise<void> {
await this.kafkaService.publish({
topic: 'referral.transfer.stats-updated',
key: transferOrderNo,
value: {
transferOrderNo,
consumerService: 'referral-service',
success: true,
confirmedAt: new Date().toISOString(),
},
});
this.logger.log(
`[TRANSFER] Published referral.transfer.stats-updated for ${transferOrderNo}`,
);
}
/**
* ACK
*/
private async markProcessedAndAck(
processedEventId: string,
eventType: string,
outboxInfo: PlantingOwnershipRemovedEvent['_outbox'],
eventId: string,
): Promise<void> {
await this.prisma.processedEvent.create({
data: { eventId: processedEventId, eventType },
});
if (outboxInfo) {
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
}
}
}

View File

@ -224,6 +224,33 @@ export class TeamStatistics {
);
}
/**
*
* [] addPersonalPlanting
* teamPlantingCount
*/
removePersonalPlanting(count: number, provinceCode: string, cityCode: string): void {
this._personalPlantingCount = Math.max(0, this._personalPlantingCount - count);
this._provinceCityDistribution = this._provinceCityDistribution.subtract(
provinceCode,
cityCode,
count,
);
this._lastCalculatedAt = new Date();
this._updatedAt = new Date();
this._domainEvents.push(
new TeamStatisticsUpdatedEvent(
this._userId.value,
this._accountSequence,
this._teamPlantingCount,
this._directReferralCount,
this._leaderboardScore.score,
'transfer_removed',
),
);
}
/**
*
*/

View File

@ -10,7 +10,7 @@ export class TeamStatisticsUpdatedEvent extends DomainEvent {
public readonly totalTeamCount: number,
public readonly directReferralCount: number,
public readonly leaderboardScore: number,
public readonly updateReason: 'planting_added' | 'planting_removed' | 'member_joined' | 'recalculation',
public readonly updateReason: 'planting_added' | 'planting_removed' | 'member_joined' | 'recalculation' | 'transfer_removed' | 'transfer_added',
) {
super();
}

View File

@ -55,6 +55,20 @@ export interface ITeamStatisticsRepository {
}>,
): Promise<void>;
/**
* [] delta
* batchUpdateTeamCounts delta 线
*/
batchUpdateTeamCountsForTransfer(
updates: Array<{
userId: bigint;
countDelta: number;
provinceCode: string;
cityCode: string;
fromDirectReferralId?: bigint;
}>,
): Promise<void>;
/**
*
*/

View File

@ -48,6 +48,25 @@ export class ProvinceCityDistribution {
return new ProvinceCityDistribution(newDist);
}
/**
* ()
* []
*/
subtract(provinceCode: string, cityCode: string, count: number): ProvinceCityDistribution {
const newDist = new Map(this.distribution);
if (!newDist.has(provinceCode)) {
return new ProvinceCityDistribution(newDist);
}
const cityMap = new Map(newDist.get(provinceCode)!);
const currentCount = cityMap.get(cityCode) ?? 0;
cityMap.set(cityCode, Math.max(0, currentCount - count));
newDist.set(provinceCode, cityMap);
return new ProvinceCityDistribution(newDist);
}
/**
*
*/

View File

@ -254,6 +254,99 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
});
}
/**
* []
* batchUpdateTeamCounts
* delta teamPlantingCount 线
* "大区"
*/
async batchUpdateTeamCountsForTransfer(
updates: Array<{
userId: bigint;
countDelta: number;
provinceCode: string;
cityCode: string;
fromDirectReferralId?: bigint;
}>,
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
for (const update of updates) {
const current = await tx.teamStatistics.findUnique({
where: { userId: update.userId },
});
if (!current) continue;
// 更新省市分布
const distribution = (current.provinceCityDistribution as Record<
string,
Record<string, number>
>) ?? {};
if (!distribution[update.provinceCode]) {
distribution[update.provinceCode] = {};
}
const currentCityCount = distribution[update.provinceCode][update.cityCode] ?? 0;
distribution[update.provinceCode][update.cityCode] = Math.max(
0,
currentCityCount + update.countDelta,
);
// 更新直推团队统计
if (update.fromDirectReferralId) {
await tx.directReferral.upsert({
where: {
uk_referrer_referral: {
referrerId: update.userId,
referralId: update.fromDirectReferralId,
},
},
update: {
teamPlantingCount: { increment: update.countDelta },
},
create: {
referrerId: update.userId,
referralId: update.fromDirectReferralId,
referralSequence: update.fromDirectReferralId.toString(),
teamPlantingCount: Math.max(0, update.countDelta),
},
});
}
// 关键差异:负数 delta 时重新扫描所有直推找最大支线
const allDirectReferrals = await tx.directReferral.findMany({
where: { referrerId: update.userId },
select: { teamPlantingCount: true },
});
const newMaxDirectTeamCount =
allDirectReferrals.length > 0
? Math.max(0, ...allDirectReferrals.map((r) => r.teamPlantingCount))
: 0;
// 计算新总量和龙虎榜分值
const newTotalTeamPlantingCount = Math.max(
0,
current.totalTeamPlantingCount + update.countDelta,
);
const newEffectiveScore = Math.max(
0,
newTotalTeamPlantingCount - newMaxDirectTeamCount,
);
await tx.teamStatistics.update({
where: { userId: update.userId },
data: {
totalTeamPlantingCount: newTotalTeamPlantingCount,
effectivePlantingCountForRanking: newEffectiveScore,
maxSingleTeamPlantingCount: newMaxDirectTeamCount,
provinceCityDistribution: distribution,
lastCalcAt: new Date(),
updatedAt: new Date(),
},
});
}
});
}
async create(userId: bigint, accountSequence: string): Promise<TeamStatistics> {
const created = await this.prisma.teamStatistics.create({
data: {

View File

@ -7,6 +7,8 @@ import {
UserRegisteredHandler,
PlantingCreatedHandler,
ContractSigningHandler,
// [纯新增] 转让事件处理器
PlantingTransferredHandler,
} from '../application';
@Module({
@ -17,6 +19,8 @@ import {
UserRegisteredHandler,
PlantingCreatedHandler,
ContractSigningHandler,
// [纯新增] 转让事件处理器
PlantingTransferredHandler,
],
exports: [ReferralService, TeamStatisticsService],
})

View File

@ -12,7 +12,7 @@ EOSQL
}
# Create all required databases
for db in rwa_identity rwa_wallet rwa_mpc rwa_backup rwa_planting rwa_referral rwa_reward rwa_leaderboard rwa_reporting rwa_authorization rwa_admin rwa_presence rwa_blockchain; do
for db in rwa_identity rwa_wallet rwa_mpc rwa_backup rwa_planting rwa_referral rwa_reward rwa_leaderboard rwa_reporting rwa_authorization rwa_admin rwa_presence rwa_blockchain rwa_transfer; do
create_database "$db"
done

View File

@ -0,0 +1,34 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwa_transfer?schema=public"
# App
NODE_ENV=development
APP_PORT=3013
# JWT
JWT_SECRET="transfer-service-dev-jwt-secret"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=17
# External Services
WALLET_SERVICE_URL=http://localhost:3001
IDENTITY_SERVICE_URL=http://localhost:3000
PLANTING_SERVICE_URL=http://localhost:3003
REFERRAL_SERVICE_URL=http://localhost:3004
CONTRIBUTION_SERVICE_URL=http://localhost:3020
# Kafka Configuration
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=transfer-service
KAFKA_GROUP_ID=transfer-service-group
# Outbox Configuration
OUTBOX_POLL_INTERVAL_MS=1000
OUTBOX_BATCH_SIZE=100
OUTBOX_CONFIRMATION_TIMEOUT_MINUTES=5
OUTBOX_CLEANUP_INTERVAL_MS=3600000
OUTBOX_RETENTION_DAYS=7

View File

@ -0,0 +1,30 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
# OS
.DS_Store
# Tests
/coverage
# IDE
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Environment
.env
.env.production
# Prisma
prisma/migrations/migration_lock.toml

View File

@ -0,0 +1,62 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy prisma schema and generate client (dummy DATABASE_URL for build time only)
COPY prisma ./prisma/
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
# Copy source code
COPY . .
# Build
RUN npm run build
# Production stage - use Debian slim for OpenSSL compatibility
FROM node:20-slim AS production
WORKDIR /app
# Install OpenSSL and curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package*.json ./
# Install production dependencies
RUN npm ci --only=production
# Copy prisma schema and generate client (dummy DATABASE_URL for build time only)
COPY prisma ./prisma/
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
# Copy built application
COPY --from=builder /app/dist ./dist
# Create startup script that runs migrations before starting the app
RUN echo '#!/bin/sh\n\
set -e\n\
echo "Running database migrations..."\n\
npx prisma migrate deploy\n\
echo "Starting application..."\n\
exec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
# Expose port
EXPOSE 3013
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3013/api/v1/health || exit 1
# Start service with migration
CMD ["/app/start.sh"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
{
"name": "transfer-service",
"version": "1.0.0",
"description": "RWA Durian Queen Tree Transfer Service",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"jsonwebtoken": "^9.0.0",
"kafkajs": "^2.2.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^2.0.12",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.7.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -0,0 +1,127 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// 转让订单表 (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")
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)
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")
statusLogs TransferStatusLog[]
@@index([sellerUserId])
@@index([buyerUserId])
@@index([sourceOrderNo])
@@index([status])
@@index([createdAt])
@@map("transfer_orders")
}
// ============================================
// 状态变更日志表 (审计)
// ============================================
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)
operatorId String? @map("operator_id") @db.VarChar(50)
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
transferOrder TransferOrder @relation(fields: [transferOrderNo], references: [transferOrderNo])
@@index([transferOrderNo])
@@map("transfer_status_logs")
}
// ============================================
// Outbox 事件表 (标准 Outbox Pattern)
// ============================================
model OutboxEvent {
id BigInt @id @default(autoincrement())
eventType String @map("event_type") @db.VarChar(100)
topic String @db.VarChar(200)
key String @db.VarChar(200)
payload Json
aggregateId String @map("aggregate_id") @db.VarChar(100)
aggregateType String @map("aggregate_type") @db.VarChar(100)
status String @default("PENDING") @db.VarChar(20)
retryCount Int @default(0) @map("retry_count")
maxRetries Int @default(5) @map("max_retries")
lastError String? @map("last_error") @db.VarChar(1000)
publishedAt DateTime? @map("published_at")
nextRetryAt DateTime? @map("next_retry_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([status, createdAt])
@@index([status, nextRetryAt])
@@map("outbox_events")
}
// ============================================
// 已处理事件表 (幂等性保证)
// ============================================
model ProcessedEvent {
id BigInt @id @default(autoincrement())
eventId String @unique @map("event_id") @db.VarChar(200)
eventType String @map("event_type") @db.VarChar(100)
processedAt DateTime @default(now()) @map("processed_at")
@@map("processed_events")
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TransferController } from './controllers/transfer.controller';
import { AdminTransferController } from './controllers/admin-transfer.controller';
import { HealthController } from './controllers/health.controller';
import { ApplicationModule } from '../application/application.module';
@Module({
imports: [ApplicationModule],
controllers: [
TransferController,
AdminTransferController,
HealthController,
],
})
export class ApiModule {}

View File

@ -0,0 +1,114 @@
import {
Controller,
Get,
Post,
Param,
Query,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { TransferApplicationService } from '../../application/services/transfer-application.service';
import { TransferOrderResponse } from '../dto/response/transfer-order.response';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@ApiTags('管理端')
@Controller('admin/transfers')
export class AdminTransferController {
constructor(
private readonly transferService: TransferApplicationService,
private readonly prisma: PrismaService,
) {}
@Get()
@ApiOperation({ summary: '转让列表(管理端)' })
async listTransfers(
@Query('status') status?: string,
@Query('limit') limit: number = 20,
@Query('offset') offset: number = 0,
): Promise<{ total: number; items: any[] }> {
const where = status ? { status } : {};
const [total, items] = await Promise.all([
this.prisma.transferOrder.count({ where }),
this.prisma.transferOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
}),
]);
return {
total,
items: items.map((item) => ({
...item,
id: item.id.toString(),
sellerUserId: item.sellerUserId.toString(),
buyerUserId: item.buyerUserId.toString(),
sourceAdoptionId: item.sourceAdoptionId.toString(),
contributionPerTree: item.contributionPerTree.toString(),
transferPrice: item.transferPrice.toString(),
platformFeeAmount: item.platformFeeAmount.toString(),
sellerReceiveAmount: item.sellerReceiveAmount.toString(),
})),
};
}
@Get('stats')
@ApiOperation({ summary: '转让统计' })
async getStats(): Promise<any> {
const [total, pending, completed, cancelled, failed] = await Promise.all([
this.prisma.transferOrder.count(),
this.prisma.transferOrder.count({ where: { status: 'PENDING' } }),
this.prisma.transferOrder.count({ where: { status: 'COMPLETED' } }),
this.prisma.transferOrder.count({ where: { status: 'CANCELLED' } }),
this.prisma.transferOrder.count({ where: { status: { in: ['FAILED', 'ROLLED_BACK'] } } }),
]);
return { total, pending, completed, cancelled, failed };
}
@Get(':transferOrderNo')
@ApiOperation({ summary: '转让详情(管理端)' })
async getTransferDetail(
@Param('transferOrderNo') transferOrderNo: string,
): Promise<any> {
const order = await this.prisma.transferOrder.findUnique({
where: { transferOrderNo },
include: { statusLogs: { orderBy: { createdAt: 'asc' } } },
});
if (!order) {
throw new NotFoundException(`转让订单 ${transferOrderNo} 不存在`);
}
return {
...order,
id: order.id.toString(),
sellerUserId: order.sellerUserId.toString(),
buyerUserId: order.buyerUserId.toString(),
sourceAdoptionId: order.sourceAdoptionId.toString(),
contributionPerTree: order.contributionPerTree.toString(),
transferPrice: order.transferPrice.toString(),
platformFeeAmount: order.platformFeeAmount.toString(),
sellerReceiveAmount: order.sellerReceiveAmount.toString(),
statusLogs: order.statusLogs.map((log) => ({
...log,
id: log.id.toString(),
})),
};
}
@Post(':transferOrderNo/force-cancel')
@ApiOperation({ summary: '强制取消转让(管理端)' })
async forceCancel(
@Param('transferOrderNo') transferOrderNo: string,
): Promise<{ success: boolean }> {
// 管理员强制取消用系统用户ID
await this.transferService.cancelTransfer(
transferOrderNo,
BigInt(0), // 系统操作,需在 cancelTransfer 中增加管理员权限判断
);
return { success: true };
}
}

View File

@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@ApiTags('健康检查')
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: '健康检查' })
async check(): Promise<{ status: string; service: string; timestamp: string }> {
// 检查数据库连接
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
service: 'transfer-service',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,94 @@
import {
Controller,
Post,
Get,
Param,
Body,
Query,
UseGuards,
Request,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { TransferApplicationService } from '../../application/services/transfer-application.service';
import { CreateTransferDto } from '../dto/request/create-transfer.dto';
import { QueryTransfersDto } from '../dto/request/query-transfers.dto';
import { TransferOrderResponse } from '../dto/response/transfer-order.response';
@ApiTags('转让订单')
@ApiBearerAuth()
@Controller('transfers')
@UseGuards(JwtAuthGuard)
export class TransferController {
constructor(
private readonly transferService: TransferApplicationService,
) {}
@Post()
@ApiOperation({ summary: '发起转让(买方向卖方购买)' })
async createTransfer(
@Body() dto: CreateTransferDto,
@Request() req: any,
): Promise<{ transferOrderNo: string }> {
return this.transferService.createTransferOrder({
buyerUserId: BigInt(req.user.id),
buyerAccountSequence: req.user.accountSequence,
sourceOrderNo: dto.sourceOrderNo,
treeCount: dto.treeCount,
transferPrice: dto.transferPrice,
});
}
@Post(':transferOrderNo/seller-confirm')
@ApiOperation({ summary: '卖方确认转让' })
async sellerConfirm(
@Param('transferOrderNo') transferOrderNo: string,
@Request() req: any,
): Promise<{ success: boolean }> {
await this.transferService.sellerConfirm(
transferOrderNo,
BigInt(req.user.id),
);
return { success: true };
}
@Post(':transferOrderNo/cancel')
@ApiOperation({ summary: '取消转让' })
async cancelTransfer(
@Param('transferOrderNo') transferOrderNo: string,
@Request() req: any,
): Promise<{ success: boolean }> {
await this.transferService.cancelTransfer(
transferOrderNo,
BigInt(req.user.id),
);
return { success: true };
}
@Get()
@ApiOperation({ summary: '查询我的转让记录' })
async getMyTransfers(
@Query() query: QueryTransfersDto,
@Request() req: any,
): Promise<TransferOrderResponse[]> {
const orders = await this.transferService.getMyTransfers(
BigInt(req.user.id),
query.role || 'all',
{ limit: query.limit, offset: query.offset },
);
return orders.map(TransferOrderResponse.fromDomain);
}
@Get(':transferOrderNo')
@ApiOperation({ summary: '查询转让详情' })
async getTransferOrder(
@Param('transferOrderNo') transferOrderNo: string,
): Promise<TransferOrderResponse> {
const order = await this.transferService.getTransferOrder(transferOrderNo);
if (!order) {
throw new NotFoundException(`转让订单 ${transferOrderNo} 不存在`);
}
return TransferOrderResponse.fromDomain(order);
}
}

View File

@ -0,0 +1,19 @@
import { IsString, IsNumber, IsNotEmpty, Min, IsDecimal } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTransferDto {
@ApiProperty({ description: '原认种订单号', example: 'PLT260101A1B2C3D4' })
@IsString()
@IsNotEmpty()
sourceOrderNo: string;
@ApiProperty({ description: '转让棵数', example: 1 })
@IsNumber()
@Min(1)
treeCount: number;
@ApiProperty({ description: '转让总价 (USDT)', example: '15000.00000000' })
@IsString()
@IsNotEmpty()
transferPrice: string;
}

View File

@ -0,0 +1,21 @@
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryTransfersDto {
@ApiPropertyOptional({ description: '角色筛选', enum: ['buyer', 'seller', 'all'], default: 'all' })
@IsOptional()
@IsString()
role?: 'buyer' | 'seller' | 'all';
@ApiPropertyOptional({ description: '每页条数', default: 20 })
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
@ApiPropertyOptional({ description: '偏移量', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
offset?: number;
}

View File

@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransferOrder } from '../../../domain/aggregates/transfer-order.aggregate';
export class TransferOrderResponse {
@ApiProperty() transferOrderNo: string;
@ApiProperty() sellerAccountSequence: string;
@ApiProperty() buyerAccountSequence: string;
@ApiProperty() sourceOrderNo: string;
@ApiProperty() treeCount: number;
@ApiProperty() transferPrice: string;
@ApiProperty() platformFeeAmount: string;
@ApiProperty() sellerReceiveAmount: string;
@ApiProperty() status: string;
@ApiProperty() selectedProvince: string;
@ApiProperty() selectedCity: string;
@ApiProperty() createdAt: string;
@ApiProperty({ required: false }) sellerConfirmedAt?: string;
@ApiProperty({ required: false }) completedAt?: string;
@ApiProperty({ required: false }) cancelledAt?: string;
static fromDomain(order: TransferOrder): TransferOrderResponse {
const response = new TransferOrderResponse();
response.transferOrderNo = order.transferOrderNo;
response.sellerAccountSequence = order.sellerAccountSequence;
response.buyerAccountSequence = order.buyerAccountSequence;
response.sourceOrderNo = order.sourceOrderNo;
response.treeCount = order.treeCount;
response.transferPrice = order.transferPrice;
response.platformFeeAmount = order.platformFeeAmount;
response.sellerReceiveAmount = order.sellerReceiveAmount;
response.status = order.status;
response.selectedProvince = order.selectedProvince;
response.selectedCity = order.selectedCity;
response.createdAt = order.createdAt.toISOString();
response.sellerConfirmedAt = order.sellerConfirmedAt?.toISOString();
response.completedAt = order.completedAt?.toISOString();
response.cancelledAt = order.cancelledAt?.toISOString();
return response;
}
}

View File

@ -0,0 +1,52 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export interface JwtPayload {
sub: string;
userId: string;
accountSequence: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('Missing authentication token');
}
try {
const secret = this.configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET not configured');
}
const payload = jwt.verify(token, secret) as JwtPayload;
request.user = {
id: payload.userId || payload.sub,
accountSequence: payload.accountSequence,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid authentication token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1 @@
export * from './api.module';

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { DomainModule } from './domain/domain.module';
import { ApplicationModule } from './application/application.module';
import { ApiModule } from './api/api.module';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
import configs from './config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.development', '.env'],
load: configs,
}),
InfrastructureModule,
DomainModule,
ApplicationModule,
ApiModule,
],
providers: [
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { DomainModule } from '../domain/domain.module';
import { TransferApplicationService } from './services/transfer-application.service';
import { SagaOrchestratorService } from './services/saga-orchestrator.service';
@Module({
imports: [DomainModule, ScheduleModule.forRoot()],
providers: [
TransferApplicationService,
SagaOrchestratorService,
],
exports: [
TransferApplicationService,
SagaOrchestratorService,
],
})
export class ApplicationModule {}

View File

@ -0,0 +1,3 @@
export * from './application.module';
export * from './services/transfer-application.service';
export * from './services/saga-orchestrator.service';

View File

@ -0,0 +1,312 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import {
ITransferOrderRepository,
TRANSFER_ORDER_REPOSITORY,
} from '../../domain/repositories/transfer-order.repository.interface';
import { TransferOrderStatus } from '../../domain/value-objects/transfer-order-status.enum';
import { SagaStep } from '../../domain/value-objects/saga-step.enum';
import { UnitOfWork, UNIT_OF_WORK } from '../../infrastructure/persistence/unit-of-work';
import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { DomainEvent } from '../../domain/events/domain-event.interface';
/**
* Saga
*
*
*
* SELLER_CONFIRMED PAYMENT_FROZEN
* PAYMENT_FROZEN TREES_LOCKED
* TREES_LOCKED OWNERSHIP_TRANSFERRED
* OWNERSHIP_TRANSFERRED CONTRIBUTION_ADJUSTED
* CONTRIBUTION_ADJUSTED STATS_UPDATED
* STATS_UPDATED PAYMENT_SETTLED COMPLETED
*/
@Injectable()
export class SagaOrchestratorService {
private readonly logger = new Logger(SagaOrchestratorService.name);
constructor(
@Inject(TRANSFER_ORDER_REPOSITORY)
private readonly transferOrderRepo: ITransferOrderRepository,
@Inject(UNIT_OF_WORK)
private readonly unitOfWork: UnitOfWork,
private readonly eventPublisher: EventPublisherService,
private readonly walletClient: WalletServiceClient,
) {}
/**
* Saga
*/
async proceedAfterSellerConfirm(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Proceeding after seller confirm: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.SELLER_CONFIRMED) return;
try {
// 调用 wallet-service 冻结买方资金
await this.walletClient.freezePayment({
accountSequence: order.buyerAccountSequence,
amount: order.transferPrice,
transferOrderNo: order.transferOrderNo,
reason: `树转让冻结 - ${order.transferOrderNo}`,
});
order.markPaymentFrozen();
await this.saveAndPublish(order, 'SYSTEM', '买方资金冻结成功');
// 继续下一步:发送锁定树事件
await this.proceedToLockTrees(order.transferOrderNo);
} catch (error) {
this.logger.error(`[SAGA] Failed to freeze payment for ${transferOrderNo}:`, error);
await this.handleFailure(transferOrderNo, `冻结资金失败: ${(error as Error).message}`);
}
}
/**
* Saga
*/
async proceedToLockTrees(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Proceeding to lock trees: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.PAYMENT_FROZEN) return;
// 发送 Kafka 事件给 planting-service 锁定树
await this.eventPublisher.publish('transfer.trees.lock', transferOrderNo, {
transferOrderNo: order.transferOrderNo,
sellerUserId: order.sellerUserId.toString(),
sourceOrderNo: order.sourceOrderNo,
treeCount: order.treeCount,
sellerAccountSequence: order.sellerAccountSequence,
});
}
/**
*
*/
async handleTreesLockedAck(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Trees locked ack: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.PAYMENT_FROZEN) return;
order.markTreesLocked();
await this.saveAndPublish(order, 'SYSTEM', '卖方树锁定成功');
// 继续:发送所有权变更事件
await this.eventPublisher.publish('transfer.ownership.execute', transferOrderNo, {
transferOrderNo: order.transferOrderNo,
sellerUserId: order.sellerUserId.toString(),
sellerAccountSequence: order.sellerAccountSequence,
buyerUserId: order.buyerUserId.toString(),
buyerAccountSequence: order.buyerAccountSequence,
sourceOrderNo: order.sourceOrderNo,
treeCount: order.treeCount,
contributionPerTree: order.contributionPerTree,
selectedProvince: order.selectedProvince,
selectedCity: order.selectedCity,
originalAdoptionDate: order.originalAdoptionDate.toISOString(),
originalExpireDate: order.originalExpireDate.toISOString(),
});
}
/**
*
*/
async handleOwnershipTransferred(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Ownership transferred: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.TREES_LOCKED) return;
order.markOwnershipTransferred();
await this.saveAndPublish(order, 'SYSTEM', '所有权变更完成');
// planting-service 发出的 planting.ownership.removed / planting.ownership.added
// 会被 contribution-service 和 referral-service 各自消费
// 它们处理完后会发送确认事件回来
}
/**
*
*/
async handleContributionAdjusted(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Contribution adjusted: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.OWNERSHIP_TRANSFERRED) return;
order.markContributionAdjusted();
await this.saveAndPublish(order, 'SYSTEM', '算力调整完成');
}
/**
*
*/
async handleStatsUpdated(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Stats updated: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.CONTRIBUTION_ADJUSTED) return;
order.markStatsUpdated();
await this.saveAndPublish(order, 'SYSTEM', '团队统计更新完成');
// 继续:结算资金
await this.proceedToSettlePayment(order.transferOrderNo);
}
/**
*
*/
async proceedToSettlePayment(transferOrderNo: string): Promise<void> {
this.logger.log(`[SAGA] Proceeding to settle payment: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.STATS_UPDATED) return;
try {
await this.walletClient.settleTransferPayment({
buyerAccountSequence: order.buyerAccountSequence,
sellerAccountSequence: order.sellerAccountSequence,
freezeId: order.transferOrderNo, // 使用订单号作为冻结标识
sellerReceiveAmount: order.sellerReceiveAmount,
platformFeeAmount: order.platformFeeAmount,
transferOrderNo: order.transferOrderNo,
});
order.markPaymentSettled();
await this.saveAndPublish(order, 'SYSTEM', '资金结算完成');
// 完成转让
order.complete();
await this.saveAndPublish(order, 'SYSTEM', '转让完成');
} catch (error) {
this.logger.error(`[SAGA] Failed to settle payment for ${transferOrderNo}:`, error);
await this.handleFailure(transferOrderNo, `资金结算失败: ${(error as Error).message}`);
}
}
/**
*
*/
async handleFailure(transferOrderNo: string, reason: string): Promise<void> {
this.logger.error(`[SAGA] Handling failure for ${transferOrderNo}: ${reason}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order) return;
order.markFailed(reason);
await this.saveAndPublish(order, 'SYSTEM', `转让失败: ${reason}`);
// 如果需要补偿,开始补偿流程
if (order.status === TransferOrderStatus.ROLLING_BACK) {
await this.executeCompensation(order.transferOrderNo);
}
}
/**
*
*/
private async executeCompensation(transferOrderNo: string): Promise<void> {
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.ROLLING_BACK) return;
if (order.sagaStep === SagaStep.COMPENSATE_UNLOCK_TREES) {
// 发送解锁树事件
await this.eventPublisher.publish('transfer.trees.unlock', transferOrderNo, {
transferOrderNo: order.transferOrderNo,
sourceOrderNo: order.sourceOrderNo,
treeCount: order.treeCount,
});
} else if (order.sagaStep === SagaStep.COMPENSATE_UNFREEZE) {
// 解冻资金
try {
await this.walletClient.unfreezePayment({
accountSequence: order.buyerAccountSequence,
freezeId: order.transferOrderNo,
transferOrderNo: order.transferOrderNo,
reason: `转让取消解冻 - ${order.transferOrderNo}`,
});
order.markRolledBack();
await this.saveAndPublish(order, 'SYSTEM', '补偿完成:资金已解冻');
} catch (error) {
this.logger.error(`[SAGA] Failed to unfreeze for ${transferOrderNo}:`, error);
}
}
}
/**
*
*/
async handleTreesUnlockedAck(transferOrderNo: string): Promise<void> {
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order || order.status !== TransferOrderStatus.ROLLING_BACK) return;
order.advanceCompensation();
await this.saveAndPublish(order, 'SYSTEM', '补偿:树已解锁');
// 如果还需要解冻资金
if (order.status === TransferOrderStatus.ROLLING_BACK) {
await this.executeCompensation(order.transferOrderNo);
}
}
/**
*
*/
private async saveAndPublish(
order: any,
operatorType: string,
remark: string,
): Promise<void> {
await this.unitOfWork.executeInTransaction(async (uow) => {
await uow.saveTransferOrder(order);
const events = order.getDomainEvents() as DomainEvent[];
if (events.length > 0) {
const lastEvent = events[events.length - 1];
if (lastEvent.type === 'TransferStatusChanged') {
const data = lastEvent.data as any;
await uow.logStatusChange({
transferOrderNo: order.transferOrderNo,
fromStatus: data.fromStatus,
toStatus: data.toStatus,
fromSagaStep: data.fromSagaStep,
toSagaStep: data.toSagaStep,
operatorType,
remark,
});
}
}
const outboxEvents = events.map((e: DomainEvent) => ({
eventType: e.type,
topic: this.getTopicForEvent(e.type),
key: e.aggregateId,
payload: e.data,
aggregateId: e.aggregateId,
aggregateType: e.aggregateType,
}));
uow.addOutboxEvents(outboxEvents);
await uow.commitOutboxEvents();
});
order.clearDomainEvents();
}
private getTopicForEvent(eventType: string): string {
const topicMap: Record<string, string> = {
TransferOrderCreated: 'transfer.order.created',
TransferStatusChanged: 'transfer.status.changed',
TransferCompleted: 'transfer.order.completed',
};
return topicMap[eventType] || 'transfer.events';
}
}

View File

@ -0,0 +1,268 @@
import { Injectable, Logger, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
import {
TransferOrder,
CreateTransferOrderParams,
} from '../../domain/aggregates/transfer-order.aggregate';
import {
ITransferOrderRepository,
TRANSFER_ORDER_REPOSITORY,
} from '../../domain/repositories/transfer-order.repository.interface';
import { TransferFeeDomainService } from '../../domain/services/transfer-fee.service';
import { TransferOrderStatus, CANCELLABLE_STATUSES } from '../../domain/value-objects/transfer-order-status.enum';
import { UnitOfWork, UNIT_OF_WORK } from '../../infrastructure/persistence/unit-of-work';
import { PlantingServiceClient } from '../../infrastructure/external/planting-service.client';
import { IdentityServiceClient } from '../../infrastructure/external/identity-service.client';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { DomainEvent } from '../../domain/events/domain-event.interface';
@Injectable()
export class TransferApplicationService {
private readonly logger = new Logger(TransferApplicationService.name);
constructor(
@Inject(TRANSFER_ORDER_REPOSITORY)
private readonly transferOrderRepo: ITransferOrderRepository,
@Inject(UNIT_OF_WORK)
private readonly unitOfWork: UnitOfWork,
private readonly transferFeeService: TransferFeeDomainService,
private readonly plantingClient: PlantingServiceClient,
private readonly identityClient: IdentityServiceClient,
private readonly walletClient: WalletServiceClient,
) {}
/**
*
*/
async createTransferOrder(params: {
buyerUserId: bigint;
buyerAccountSequence: string;
sourceOrderNo: string;
treeCount: number;
transferPrice: string;
}): Promise<{ transferOrderNo: string }> {
this.logger.log(`Creating transfer order: buyer=${params.buyerAccountSequence}, source=${params.sourceOrderNo}, trees=${params.treeCount}`);
// 1. 查询原订单信息
const plantingOrder = await this.plantingClient.getPlantingOrder(params.sourceOrderNo);
if (!plantingOrder) {
throw new NotFoundException(`认种订单 ${params.sourceOrderNo} 不存在`);
}
// 2. 验证订单状态
if (plantingOrder.status !== 'MINING_ENABLED') {
throw new BadRequestException(`订单 ${params.sourceOrderNo} 状态不允许转让,当前状态: ${plantingOrder.status}`);
}
// 3. 验证可转让棵数
if (plantingOrder.availableForTransfer < params.treeCount) {
throw new BadRequestException(
`可转让棵数不足,可转让: ${plantingOrder.availableForTransfer},请求: ${params.treeCount}`,
);
}
// 4. 不能自己转给自己
if (params.buyerUserId === BigInt(plantingOrder.userId)) {
throw new BadRequestException('不能转让给自己');
}
// 5. 验证买方实名认证
const buyerKyc = await this.identityClient.isKycVerified(params.buyerUserId.toString());
if (!buyerKyc) {
throw new BadRequestException('买方尚未完成实名认证');
}
// 6. 查询原始算力值
const contributionInfo = await this.plantingClient.getContributionPerTree(params.sourceOrderNo);
// 7. 计算费用
const fees = this.transferFeeService.calculateFees(params.transferPrice);
// 8. 创建聚合根
const createParams: CreateTransferOrderParams = {
sellerUserId: BigInt(plantingOrder.userId),
sellerAccountSequence: plantingOrder.accountSequence,
buyerUserId: params.buyerUserId,
buyerAccountSequence: params.buyerAccountSequence,
sourceOrderNo: params.sourceOrderNo,
sourceAdoptionId: BigInt(plantingOrder.id),
treeCount: params.treeCount,
contributionPerTree: contributionInfo.contributionPerTree,
originalAdoptionDate: new Date(contributionInfo.adoptionDate),
originalExpireDate: new Date(contributionInfo.expireDate),
selectedProvince: plantingOrder.selectedProvince,
selectedCity: plantingOrder.selectedCity,
transferPrice: params.transferPrice,
platformFeeRate: fees.platformFeeRate,
platformFeeAmount: fees.platformFeeAmount,
sellerReceiveAmount: fees.sellerReceiveAmount,
};
const order = TransferOrder.create(createParams);
// 9. 事务保存
await this.unitOfWork.executeInTransaction(async (uow) => {
await uow.saveTransferOrder(order);
// 记录状态日志
await uow.logStatusChange({
transferOrderNo: order.transferOrderNo,
fromStatus: '',
toStatus: TransferOrderStatus.PENDING,
fromSagaStep: '',
toSagaStep: 'INIT',
operatorType: 'USER',
operatorId: params.buyerUserId.toString(),
remark: '买方发起转让',
});
// Outbox 事件
const outboxEvents = order.getDomainEvents().map((e: DomainEvent) => ({
eventType: e.type,
topic: this.getTopicForEvent(e.type),
key: e.aggregateId,
payload: e.data,
aggregateId: e.aggregateId,
aggregateType: e.aggregateType,
}));
uow.addOutboxEvents(outboxEvents);
await uow.commitOutboxEvents();
});
order.clearDomainEvents();
this.logger.log(`Transfer order created: ${order.transferOrderNo}`);
return { transferOrderNo: order.transferOrderNo };
}
/**
*
*/
async sellerConfirm(transferOrderNo: string, sellerUserId: bigint): Promise<void> {
this.logger.log(`Seller confirming transfer: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order) {
throw new NotFoundException(`转让订单 ${transferOrderNo} 不存在`);
}
if (order.sellerUserId !== sellerUserId) {
throw new BadRequestException('仅卖方可以确认');
}
order.sellerConfirm();
await this.unitOfWork.executeInTransaction(async (uow) => {
await uow.saveTransferOrder(order);
await uow.logStatusChange({
transferOrderNo: order.transferOrderNo,
fromStatus: TransferOrderStatus.PENDING,
toStatus: TransferOrderStatus.SELLER_CONFIRMED,
fromSagaStep: 'INIT',
toSagaStep: 'FREEZE_BUYER_PAYMENT',
operatorType: 'USER',
operatorId: sellerUserId.toString(),
remark: '卖方确认转让',
});
const outboxEvents = order.getDomainEvents().map((e: DomainEvent) => ({
eventType: e.type,
topic: this.getTopicForEvent(e.type),
key: e.aggregateId,
payload: e.data,
aggregateId: e.aggregateId,
aggregateType: e.aggregateType,
}));
uow.addOutboxEvents(outboxEvents);
await uow.commitOutboxEvents();
});
order.clearDomainEvents();
}
/**
*
*/
async cancelTransfer(transferOrderNo: string, userId: bigint): Promise<void> {
this.logger.log(`Cancelling transfer: ${transferOrderNo}`);
const order = await this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
if (!order) {
throw new NotFoundException(`转让订单 ${transferOrderNo} 不存在`);
}
// 买方或卖方都可取消
if (order.sellerUserId !== userId && order.buyerUserId !== userId) {
throw new BadRequestException('仅买方或卖方可以取消');
}
order.cancel();
await this.unitOfWork.executeInTransaction(async (uow) => {
await uow.saveTransferOrder(order);
await uow.logStatusChange({
transferOrderNo: order.transferOrderNo,
fromStatus: order.status,
toStatus: TransferOrderStatus.CANCELLED,
fromSagaStep: order.sagaStep,
toSagaStep: order.sagaStep,
operatorType: 'USER',
operatorId: userId.toString(),
remark: '用户取消转让',
});
const outboxEvents = order.getDomainEvents().map((e: DomainEvent) => ({
eventType: e.type,
topic: this.getTopicForEvent(e.type),
key: e.aggregateId,
payload: e.data,
aggregateId: e.aggregateId,
aggregateType: e.aggregateType,
}));
uow.addOutboxEvents(outboxEvents);
await uow.commitOutboxEvents();
});
order.clearDomainEvents();
}
/**
*
*/
async getTransferOrder(transferOrderNo: string): Promise<TransferOrder | null> {
return this.transferOrderRepo.findByTransferOrderNo(transferOrderNo);
}
/**
*
*/
async getMyTransfers(
userId: bigint,
role: 'buyer' | 'seller' | 'all',
options?: { limit?: number; offset?: number },
): Promise<TransferOrder[]> {
if (role === 'buyer') {
return this.transferOrderRepo.findByBuyerUserId(userId, options);
} else if (role === 'seller') {
return this.transferOrderRepo.findBySellerUserId(userId, options);
} else {
const [buyerOrders, sellerOrders] = await Promise.all([
this.transferOrderRepo.findByBuyerUserId(userId, options),
this.transferOrderRepo.findBySellerUserId(userId, options),
]);
return [...buyerOrders, ...sellerOrders].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
}
}
private getTopicForEvent(eventType: string): string {
const topicMap: Record<string, string> = {
TransferOrderCreated: 'transfer.order.created',
TransferStatusChanged: 'transfer.status.changed',
TransferCompleted: 'transfer.order.completed',
};
return topicMap[eventType] || 'transfer.events';
}
}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.APP_PORT || '3013', 10),
serviceName: 'transfer-service',
}));

View File

@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('external', () => ({
walletServiceUrl:
process.env.WALLET_SERVICE_URL || 'http://localhost:3001',
identityServiceUrl:
process.env.IDENTITY_SERVICE_URL || 'http://localhost:3000',
plantingServiceUrl:
process.env.PLANTING_SERVICE_URL || 'http://localhost:3003',
referralServiceUrl:
process.env.REFERRAL_SERVICE_URL || 'http://localhost:3004',
contributionServiceUrl:
process.env.CONTRIBUTION_SERVICE_URL || 'http://localhost:3020',
}));

View File

@ -0,0 +1,5 @@
import appConfig from './app.config';
import jwtConfig from './jwt.config';
import externalConfig from './external.config';
export default [appConfig, jwtConfig, externalConfig];

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'default-secret-change-me',
}));

View File

@ -0,0 +1,608 @@
import { DomainEvent } from '../events/domain-event.interface';
import { TransferOrderCreatedEvent } from '../events/transfer-order-created.event';
import { TransferStatusChangedEvent } from '../events/transfer-status-changed.event';
import { TransferCompletedEvent } from '../events/transfer-completed.event';
import {
TransferOrderStatus,
CANCELLABLE_STATUSES,
TERMINAL_STATUSES,
} from '../value-objects/transfer-order-status.enum';
import { SagaStep } from '../value-objects/saga-step.enum';
import { v4 as uuidv4 } from 'uuid';
export interface TransferOrderData {
id: bigint | null;
transferOrderNo: string;
sellerUserId: bigint;
sellerAccountSequence: string;
buyerUserId: bigint;
buyerAccountSequence: string;
sourceOrderNo: string;
sourceAdoptionId: bigint;
treeCount: number;
contributionPerTree: string;
originalAdoptionDate: Date;
originalExpireDate: Date;
selectedProvince: string;
selectedCity: string;
transferPrice: string;
platformFeeRate: string;
platformFeeAmount: string;
sellerReceiveAmount: string;
status: string;
sagaStep: string;
failReason: string | null;
retryCount: number;
sellerConfirmedAt: Date | null;
paymentFrozenAt: Date | null;
treesLockedAt: Date | null;
ownershipTransferredAt: Date | null;
contributionAdjustedAt: Date | null;
statsUpdatedAt: Date | null;
paymentSettledAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;
rolledBackAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTransferOrderParams {
sellerUserId: bigint;
sellerAccountSequence: string;
buyerUserId: bigint;
buyerAccountSequence: string;
sourceOrderNo: string;
sourceAdoptionId: bigint;
treeCount: number;
contributionPerTree: string;
originalAdoptionDate: Date;
originalExpireDate: Date;
selectedProvince: string;
selectedCity: string;
transferPrice: string;
platformFeeRate: string;
platformFeeAmount: string;
sellerReceiveAmount: string;
}
export class TransferOrder {
private _id: bigint | null;
private readonly _transferOrderNo: string;
private readonly _sellerUserId: bigint;
private readonly _sellerAccountSequence: string;
private readonly _buyerUserId: bigint;
private readonly _buyerAccountSequence: string;
private readonly _sourceOrderNo: string;
private readonly _sourceAdoptionId: bigint;
private readonly _treeCount: number;
private readonly _contributionPerTree: string;
private readonly _originalAdoptionDate: Date;
private readonly _originalExpireDate: Date;
private readonly _selectedProvince: string;
private readonly _selectedCity: string;
private readonly _transferPrice: string;
private readonly _platformFeeRate: string;
private readonly _platformFeeAmount: string;
private readonly _sellerReceiveAmount: string;
private _status: TransferOrderStatus;
private _sagaStep: SagaStep;
private _failReason: string | null;
private _retryCount: number;
private _sellerConfirmedAt: Date | null;
private _paymentFrozenAt: Date | null;
private _treesLockedAt: Date | null;
private _ownershipTransferredAt: Date | null;
private _contributionAdjustedAt: Date | null;
private _statsUpdatedAt: Date | null;
private _paymentSettledAt: Date | null;
private _completedAt: Date | null;
private _cancelledAt: Date | null;
private _rolledBackAt: Date | null;
private _createdAt: Date;
private _updatedAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(params: {
id: bigint | null;
transferOrderNo: string;
sellerUserId: bigint;
sellerAccountSequence: string;
buyerUserId: bigint;
buyerAccountSequence: string;
sourceOrderNo: string;
sourceAdoptionId: bigint;
treeCount: number;
contributionPerTree: string;
originalAdoptionDate: Date;
originalExpireDate: Date;
selectedProvince: string;
selectedCity: string;
transferPrice: string;
platformFeeRate: string;
platformFeeAmount: string;
sellerReceiveAmount: string;
status: TransferOrderStatus;
sagaStep: SagaStep;
failReason: string | null;
retryCount: number;
sellerConfirmedAt: Date | null;
paymentFrozenAt: Date | null;
treesLockedAt: Date | null;
ownershipTransferredAt: Date | null;
contributionAdjustedAt: Date | null;
statsUpdatedAt: Date | null;
paymentSettledAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;
rolledBackAt: Date | null;
createdAt: Date;
updatedAt: Date;
}) {
this._id = params.id;
this._transferOrderNo = params.transferOrderNo;
this._sellerUserId = params.sellerUserId;
this._sellerAccountSequence = params.sellerAccountSequence;
this._buyerUserId = params.buyerUserId;
this._buyerAccountSequence = params.buyerAccountSequence;
this._sourceOrderNo = params.sourceOrderNo;
this._sourceAdoptionId = params.sourceAdoptionId;
this._treeCount = params.treeCount;
this._contributionPerTree = params.contributionPerTree;
this._originalAdoptionDate = params.originalAdoptionDate;
this._originalExpireDate = params.originalExpireDate;
this._selectedProvince = params.selectedProvince;
this._selectedCity = params.selectedCity;
this._transferPrice = params.transferPrice;
this._platformFeeRate = params.platformFeeRate;
this._platformFeeAmount = params.platformFeeAmount;
this._sellerReceiveAmount = params.sellerReceiveAmount;
this._status = params.status;
this._sagaStep = params.sagaStep;
this._failReason = params.failReason;
this._retryCount = params.retryCount;
this._sellerConfirmedAt = params.sellerConfirmedAt;
this._paymentFrozenAt = params.paymentFrozenAt;
this._treesLockedAt = params.treesLockedAt;
this._ownershipTransferredAt = params.ownershipTransferredAt;
this._contributionAdjustedAt = params.contributionAdjustedAt;
this._statsUpdatedAt = params.statsUpdatedAt;
this._paymentSettledAt = params.paymentSettledAt;
this._completedAt = params.completedAt;
this._cancelledAt = params.cancelledAt;
this._rolledBackAt = params.rolledBackAt;
this._createdAt = params.createdAt;
this._updatedAt = params.updatedAt;
}
// ========== 工厂方法 ==========
/**
* TRF + YYMMDD + 8
*/
private static generateTransferOrderNo(): string {
const now = new Date();
const yy = String(now.getFullYear()).slice(2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const random = uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase();
return `TRF${yy}${mm}${dd}${random}`;
}
/**
*
*/
static create(params: CreateTransferOrderParams): TransferOrder {
const now = new Date();
const transferOrderNo = TransferOrder.generateTransferOrderNo();
const order = new TransferOrder({
id: null,
transferOrderNo,
sellerUserId: params.sellerUserId,
sellerAccountSequence: params.sellerAccountSequence,
buyerUserId: params.buyerUserId,
buyerAccountSequence: params.buyerAccountSequence,
sourceOrderNo: params.sourceOrderNo,
sourceAdoptionId: params.sourceAdoptionId,
treeCount: params.treeCount,
contributionPerTree: params.contributionPerTree,
originalAdoptionDate: params.originalAdoptionDate,
originalExpireDate: params.originalExpireDate,
selectedProvince: params.selectedProvince,
selectedCity: params.selectedCity,
transferPrice: params.transferPrice,
platformFeeRate: params.platformFeeRate,
platformFeeAmount: params.platformFeeAmount,
sellerReceiveAmount: params.sellerReceiveAmount,
status: TransferOrderStatus.PENDING,
sagaStep: SagaStep.INIT,
failReason: null,
retryCount: 0,
sellerConfirmedAt: null,
paymentFrozenAt: null,
treesLockedAt: null,
ownershipTransferredAt: null,
contributionAdjustedAt: null,
statsUpdatedAt: null,
paymentSettledAt: null,
completedAt: null,
cancelledAt: null,
rolledBackAt: null,
createdAt: now,
updatedAt: now,
});
order._domainEvents.push(
new TransferOrderCreatedEvent(transferOrderNo, {
transferOrderNo,
sellerUserId: params.sellerUserId.toString(),
sellerAccountSequence: params.sellerAccountSequence,
buyerUserId: params.buyerUserId.toString(),
buyerAccountSequence: params.buyerAccountSequence,
sourceOrderNo: params.sourceOrderNo,
treeCount: params.treeCount,
transferPrice: params.transferPrice,
}),
);
return order;
}
/**
*
*/
static reconstitute(data: TransferOrderData): TransferOrder {
return new TransferOrder({
id: data.id,
transferOrderNo: data.transferOrderNo,
sellerUserId: data.sellerUserId,
sellerAccountSequence: data.sellerAccountSequence,
buyerUserId: data.buyerUserId,
buyerAccountSequence: data.buyerAccountSequence,
sourceOrderNo: data.sourceOrderNo,
sourceAdoptionId: data.sourceAdoptionId,
treeCount: data.treeCount,
contributionPerTree: data.contributionPerTree,
originalAdoptionDate: data.originalAdoptionDate,
originalExpireDate: data.originalExpireDate,
selectedProvince: data.selectedProvince,
selectedCity: data.selectedCity,
transferPrice: data.transferPrice,
platformFeeRate: data.platformFeeRate,
platformFeeAmount: data.platformFeeAmount,
sellerReceiveAmount: data.sellerReceiveAmount,
status: data.status as TransferOrderStatus,
sagaStep: data.sagaStep as SagaStep,
failReason: data.failReason,
retryCount: data.retryCount,
sellerConfirmedAt: data.sellerConfirmedAt,
paymentFrozenAt: data.paymentFrozenAt,
treesLockedAt: data.treesLockedAt,
ownershipTransferredAt: data.ownershipTransferredAt,
contributionAdjustedAt: data.contributionAdjustedAt,
statsUpdatedAt: data.statsUpdatedAt,
paymentSettledAt: data.paymentSettledAt,
completedAt: data.completedAt,
cancelledAt: data.cancelledAt,
rolledBackAt: data.rolledBackAt,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
}
// ========== 业务方法 ==========
/**
*
*/
sellerConfirm(): void {
this.assertStatus(TransferOrderStatus.PENDING, '卖方确认');
const prev = this.captureState();
this._status = TransferOrderStatus.SELLER_CONFIRMED;
this._sagaStep = SagaStep.FREEZE_BUYER_PAYMENT;
this._sellerConfirmedAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markPaymentFrozen(): void {
this.assertStatus(TransferOrderStatus.SELLER_CONFIRMED, '冻结资金');
const prev = this.captureState();
this._status = TransferOrderStatus.PAYMENT_FROZEN;
this._sagaStep = SagaStep.LOCK_SELLER_TREES;
this._paymentFrozenAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markTreesLocked(): void {
this.assertStatus(TransferOrderStatus.PAYMENT_FROZEN, '锁定树');
const prev = this.captureState();
this._status = TransferOrderStatus.TREES_LOCKED;
this._sagaStep = SagaStep.TRANSFER_OWNERSHIP;
this._treesLockedAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markOwnershipTransferred(): void {
this.assertStatus(TransferOrderStatus.TREES_LOCKED, '所有权变更');
const prev = this.captureState();
this._status = TransferOrderStatus.OWNERSHIP_TRANSFERRED;
this._sagaStep = SagaStep.ADJUST_CONTRIBUTION;
this._ownershipTransferredAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markContributionAdjusted(): void {
this.assertStatus(TransferOrderStatus.OWNERSHIP_TRANSFERRED, '算力调整');
const prev = this.captureState();
this._status = TransferOrderStatus.CONTRIBUTION_ADJUSTED;
this._sagaStep = SagaStep.UPDATE_TEAM_STATS;
this._contributionAdjustedAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markStatsUpdated(): void {
this.assertStatus(TransferOrderStatus.CONTRIBUTION_ADJUSTED, '团队统计更新');
const prev = this.captureState();
this._status = TransferOrderStatus.STATS_UPDATED;
this._sagaStep = SagaStep.SETTLE_PAYMENT;
this._statsUpdatedAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markPaymentSettled(): void {
this.assertStatus(TransferOrderStatus.STATS_UPDATED, '资金结算');
const prev = this.captureState();
this._status = TransferOrderStatus.PAYMENT_SETTLED;
this._sagaStep = SagaStep.FINALIZE;
this._paymentSettledAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
complete(): void {
this.assertStatus(TransferOrderStatus.PAYMENT_SETTLED, '完成');
const prev = this.captureState();
this._status = TransferOrderStatus.COMPLETED;
this._sagaStep = SagaStep.FINALIZE;
this._completedAt = new Date();
this.pushStatusChangedEvent(prev);
this._domainEvents.push(
new TransferCompletedEvent(this._transferOrderNo, {
transferOrderNo: this._transferOrderNo,
sellerUserId: this._sellerUserId.toString(),
sellerAccountSequence: this._sellerAccountSequence,
buyerUserId: this._buyerUserId.toString(),
buyerAccountSequence: this._buyerAccountSequence,
sourceOrderNo: this._sourceOrderNo,
treeCount: this._treeCount,
contributionPerTree: this._contributionPerTree,
selectedProvince: this._selectedProvince,
selectedCity: this._selectedCity,
originalAdoptionDate: this._originalAdoptionDate.toISOString(),
originalExpireDate: this._originalExpireDate.toISOString(),
transferPrice: this._transferPrice,
completedAt: this._completedAt!.toISOString(),
}),
);
}
/**
* PENDING / SELLER_CONFIRMED
*/
cancel(): void {
if (!CANCELLABLE_STATUSES.includes(this._status as any)) {
throw new Error(`当前状态 ${this._status} 不允许取消`);
}
const prev = this.captureState();
this._status = TransferOrderStatus.CANCELLED;
this._cancelledAt = new Date();
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markFailed(reason: string): void {
if (TERMINAL_STATUSES.includes(this._status as any)) {
throw new Error(`当前状态 ${this._status} 已是终态,不能标记为失败`);
}
const prev = this.captureState();
this._failReason = reason;
this._status = TransferOrderStatus.ROLLING_BACK;
// 根据当前步骤确定补偿起点
if (this._treesLockedAt) {
this._sagaStep = SagaStep.COMPENSATE_UNLOCK_TREES;
} else if (this._paymentFrozenAt) {
this._sagaStep = SagaStep.COMPENSATE_UNFREEZE;
} else {
this._status = TransferOrderStatus.FAILED;
}
this.pushStatusChangedEvent(prev);
}
/**
*
*/
advanceCompensation(): void {
if (this._status !== TransferOrderStatus.ROLLING_BACK) {
throw new Error(`当前状态 ${this._status} 不在回滚中`);
}
const prev = this.captureState();
if (this._sagaStep === SagaStep.COMPENSATE_UNLOCK_TREES && this._paymentFrozenAt) {
this._sagaStep = SagaStep.COMPENSATE_UNFREEZE;
} else {
this._status = TransferOrderStatus.ROLLED_BACK;
this._rolledBackAt = new Date();
}
this.pushStatusChangedEvent(prev);
}
/**
*
*/
markRolledBack(): void {
const prev = this.captureState();
this._status = TransferOrderStatus.ROLLED_BACK;
this._rolledBackAt = new Date();
this.pushStatusChangedEvent(prev);
}
// ========== Getters ==========
get id(): bigint | null { return this._id; }
get transferOrderNo(): string { return this._transferOrderNo; }
get sellerUserId(): bigint { return this._sellerUserId; }
get sellerAccountSequence(): string { return this._sellerAccountSequence; }
get buyerUserId(): bigint { return this._buyerUserId; }
get buyerAccountSequence(): string { return this._buyerAccountSequence; }
get sourceOrderNo(): string { return this._sourceOrderNo; }
get sourceAdoptionId(): bigint { return this._sourceAdoptionId; }
get treeCount(): number { return this._treeCount; }
get contributionPerTree(): string { return this._contributionPerTree; }
get originalAdoptionDate(): Date { return this._originalAdoptionDate; }
get originalExpireDate(): Date { return this._originalExpireDate; }
get selectedProvince(): string { return this._selectedProvince; }
get selectedCity(): string { return this._selectedCity; }
get transferPrice(): string { return this._transferPrice; }
get platformFeeRate(): string { return this._platformFeeRate; }
get platformFeeAmount(): string { return this._platformFeeAmount; }
get sellerReceiveAmount(): string { return this._sellerReceiveAmount; }
get status(): TransferOrderStatus { return this._status; }
get sagaStep(): SagaStep { return this._sagaStep; }
get failReason(): string | null { return this._failReason; }
get retryCount(): number { return this._retryCount; }
get sellerConfirmedAt(): Date | null { return this._sellerConfirmedAt; }
get paymentFrozenAt(): Date | null { return this._paymentFrozenAt; }
get treesLockedAt(): Date | null { return this._treesLockedAt; }
get ownershipTransferredAt(): Date | null { return this._ownershipTransferredAt; }
get contributionAdjustedAt(): Date | null { return this._contributionAdjustedAt; }
get statsUpdatedAt(): Date | null { return this._statsUpdatedAt; }
get paymentSettledAt(): Date | null { return this._paymentSettledAt; }
get completedAt(): Date | null { return this._completedAt; }
get cancelledAt(): Date | null { return this._cancelledAt; }
get rolledBackAt(): Date | null { return this._rolledBackAt; }
get createdAt(): Date { return this._createdAt; }
// ========== 事件管理 ==========
getDomainEvents(): readonly DomainEvent[] {
return this._domainEvents;
}
clearDomainEvents(): void {
this._domainEvents = [];
}
setId(id: bigint): void {
this._id = id;
}
// ========== 持久化 ==========
toPersistence(): Omit<TransferOrderData, 'updatedAt'> {
return {
id: this._id,
transferOrderNo: this._transferOrderNo,
sellerUserId: this._sellerUserId,
sellerAccountSequence: this._sellerAccountSequence,
buyerUserId: this._buyerUserId,
buyerAccountSequence: this._buyerAccountSequence,
sourceOrderNo: this._sourceOrderNo,
sourceAdoptionId: this._sourceAdoptionId,
treeCount: this._treeCount,
contributionPerTree: this._contributionPerTree,
originalAdoptionDate: this._originalAdoptionDate,
originalExpireDate: this._originalExpireDate,
selectedProvince: this._selectedProvince,
selectedCity: this._selectedCity,
transferPrice: this._transferPrice,
platformFeeRate: this._platformFeeRate,
platformFeeAmount: this._platformFeeAmount,
sellerReceiveAmount: this._sellerReceiveAmount,
status: this._status,
sagaStep: this._sagaStep,
failReason: this._failReason,
retryCount: this._retryCount,
sellerConfirmedAt: this._sellerConfirmedAt,
paymentFrozenAt: this._paymentFrozenAt,
treesLockedAt: this._treesLockedAt,
ownershipTransferredAt: this._ownershipTransferredAt,
contributionAdjustedAt: this._contributionAdjustedAt,
statsUpdatedAt: this._statsUpdatedAt,
paymentSettledAt: this._paymentSettledAt,
completedAt: this._completedAt,
cancelledAt: this._cancelledAt,
rolledBackAt: this._rolledBackAt,
createdAt: this._createdAt,
};
}
// ========== 私有辅助方法 ==========
private assertStatus(expected: TransferOrderStatus, operation: string): void {
if (this._status !== expected) {
throw new Error(`操作"${operation}"要求状态为 ${expected},当前状态为 ${this._status}`);
}
}
private captureState(): { status: string; sagaStep: string } {
return { status: this._status, sagaStep: this._sagaStep };
}
private pushStatusChangedEvent(prev: { status: string; sagaStep: string }): void {
this._domainEvents.push(
new TransferStatusChangedEvent(this._transferOrderNo, {
transferOrderNo: this._transferOrderNo,
fromStatus: prev.status,
toStatus: this._status,
fromSagaStep: prev.sagaStep,
toSagaStep: this._sagaStep,
}),
);
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TransferFeeDomainService } from './services/transfer-fee.service';
@Module({
providers: [TransferFeeDomainService],
exports: [TransferFeeDomainService],
})
export class DomainModule {}

View File

@ -0,0 +1,7 @@
export interface DomainEvent {
type: string;
aggregateId: string;
aggregateType: string;
occurredAt: Date;
data: Record<string, unknown>;
}

View File

@ -0,0 +1,4 @@
export * from './domain-event.interface';
export * from './transfer-order-created.event';
export * from './transfer-status-changed.event';
export * from './transfer-completed.event';

View File

@ -0,0 +1,29 @@
import { DomainEvent } from './domain-event.interface';
export class TransferCompletedEvent implements DomainEvent {
readonly type = 'TransferCompleted';
readonly aggregateType = 'TransferOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
transferOrderNo: string;
sellerUserId: string;
sellerAccountSequence: string;
buyerUserId: string;
buyerAccountSequence: string;
sourceOrderNo: string;
treeCount: number;
contributionPerTree: string;
selectedProvince: string;
selectedCity: string;
originalAdoptionDate: string;
originalExpireDate: string;
transferPrice: string;
completedAt: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,23 @@
import { DomainEvent } from './domain-event.interface';
export class TransferOrderCreatedEvent implements DomainEvent {
readonly type = 'TransferOrderCreated';
readonly aggregateType = 'TransferOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
transferOrderNo: string;
sellerUserId: string;
sellerAccountSequence: string;
buyerUserId: string;
buyerAccountSequence: string;
sourceOrderNo: string;
treeCount: number;
transferPrice: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,20 @@
import { DomainEvent } from './domain-event.interface';
export class TransferStatusChangedEvent implements DomainEvent {
readonly type = 'TransferStatusChanged';
readonly aggregateType = 'TransferOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
transferOrderNo: string;
fromStatus: string;
toStatus: string;
fromSagaStep: string;
toSagaStep: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,5 @@
export * from './aggregates/transfer-order.aggregate';
export * from './events';
export * from './value-objects';
export * from './repositories/transfer-order.repository.interface';
export * from './services/transfer-fee.service';

View File

@ -0,0 +1,13 @@
import { TransferOrder } from '../aggregates/transfer-order.aggregate';
export interface ITransferOrderRepository {
findById(id: bigint): Promise<TransferOrder | null>;
findByTransferOrderNo(transferOrderNo: string): Promise<TransferOrder | null>;
findBySellerUserId(userId: bigint, options?: { limit?: number; offset?: number }): Promise<TransferOrder[]>;
findByBuyerUserId(userId: bigint, options?: { limit?: number; offset?: number }): Promise<TransferOrder[]>;
findBySourceOrderNo(sourceOrderNo: string): Promise<TransferOrder[]>;
findByStatus(status: string, limit?: number): Promise<TransferOrder[]>;
countByUserAsSellerOrBuyer(userId: bigint): Promise<number>;
}
export const TRANSFER_ORDER_REPOSITORY = Symbol('ITransferOrderRepository');

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
/**
*
*/
@Injectable()
export class TransferFeeDomainService {
/**
* 2%
*/
private readonly PLATFORM_FEE_RATE = '0.0200';
/**
*
*/
calculateFees(transferPrice: string): {
platformFeeRate: string;
platformFeeAmount: string;
sellerReceiveAmount: string;
} {
const price = parseFloat(transferPrice);
const feeRate = parseFloat(this.PLATFORM_FEE_RATE);
const feeAmount = Math.round(price * feeRate * 100000000) / 100000000;
const sellerReceive = Math.round((price - feeAmount) * 100000000) / 100000000;
return {
platformFeeRate: this.PLATFORM_FEE_RATE,
platformFeeAmount: feeAmount.toFixed(8),
sellerReceiveAmount: sellerReceive.toFixed(8),
};
}
}

View File

@ -0,0 +1,2 @@
export * from './transfer-order-status.enum';
export * from './saga-step.enum';

View File

@ -0,0 +1,14 @@
export enum SagaStep {
INIT = 'INIT', // 初始化
FREEZE_BUYER_PAYMENT = 'FREEZE_BUYER_PAYMENT', // 冻结买方资金
LOCK_SELLER_TREES = 'LOCK_SELLER_TREES', // 锁定卖方树
TRANSFER_OWNERSHIP = 'TRANSFER_OWNERSHIP', // 变更所有权
ADJUST_CONTRIBUTION = 'ADJUST_CONTRIBUTION', // 调整算力
UPDATE_TEAM_STATS = 'UPDATE_TEAM_STATS', // 更新团队统计
SETTLE_PAYMENT = 'SETTLE_PAYMENT', // 结算资金
FINALIZE = 'FINALIZE', // 完成
// 补偿步骤
COMPENSATE_UNLOCK_TREES = 'COMPENSATE_UNLOCK_TREES', // 补偿:解锁树
COMPENSATE_UNFREEZE = 'COMPENSATE_UNFREEZE', // 补偿:解冻资金
}

View File

@ -0,0 +1,33 @@
export enum TransferOrderStatus {
PENDING = 'PENDING', // 待卖方确认
SELLER_CONFIRMED = 'SELLER_CONFIRMED', // 卖方已确认
PAYMENT_FROZEN = 'PAYMENT_FROZEN', // 买方资金已冻结
TREES_LOCKED = 'TREES_LOCKED', // 卖方树已锁定
OWNERSHIP_TRANSFERRED = 'OWNERSHIP_TRANSFERRED', // 所有权已变更
CONTRIBUTION_ADJUSTED = 'CONTRIBUTION_ADJUSTED', // 算力已调整
STATS_UPDATED = 'STATS_UPDATED', // 团队统计已更新
PAYMENT_SETTLED = 'PAYMENT_SETTLED', // 资金已结算
COMPLETED = 'COMPLETED', // 转让完成
CANCELLED = 'CANCELLED', // 已取消
FAILED = 'FAILED', // 失败
ROLLING_BACK = 'ROLLING_BACK', // 补偿中
ROLLED_BACK = 'ROLLED_BACK', // 已回滚
}
/**
* PENDING SELLER_CONFIRMED
*/
export const CANCELLABLE_STATUSES = [
TransferOrderStatus.PENDING,
TransferOrderStatus.SELLER_CONFIRMED,
] as const;
/**
*
*/
export const TERMINAL_STATUSES = [
TransferOrderStatus.COMPLETED,
TransferOrderStatus.CANCELLED,
TransferOrderStatus.ROLLED_BACK,
] as const;

View File

@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
/**
* Identity Service
*
*/
@Injectable()
export class IdentityServiceClient {
private readonly logger = new Logger(IdentityServiceClient.name);
private readonly baseUrl: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.baseUrl = this.configService.get<string>(
'external.identityServiceUrl',
'http://localhost:3000',
);
}
/**
*
*/
async isKycVerified(userId: string): Promise<boolean> {
const url = `${this.baseUrl}/api/v1/internal/users/${userId}/kyc-status`;
try {
const { data } = await firstValueFrom(this.httpService.get(url));
return data.verified === true;
} catch {
return false;
}
}
/**
*
*/
async getUserInfo(userId: string): Promise<{
userId: number;
accountSequence: string;
kycVerified: boolean;
} | null> {
const url = `${this.baseUrl}/api/v1/internal/users/${userId}`;
try {
const { data } = await firstValueFrom(this.httpService.get(url));
return data;
} catch (error: any) {
if (error?.response?.status === 404) return null;
throw error;
}
}
}

View File

@ -0,0 +1,3 @@
export * from './wallet-service.client';
export * from './planting-service.client';
export * from './identity-service.client';

View File

@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
/**
* Planting Service
*
*/
@Injectable()
export class PlantingServiceClient {
private readonly logger = new Logger(PlantingServiceClient.name);
private readonly baseUrl: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.baseUrl = this.configService.get<string>(
'external.plantingServiceUrl',
'http://localhost:3003',
);
}
/**
*
*/
async getPlantingOrder(orderNo: string): Promise<{
id: number;
orderNo: string;
userId: number;
accountSequence: string;
treeCount: number;
status: string;
selectedProvince: string;
selectedCity: string;
transferLockedCount: number;
transferredCount: number;
availableForTransfer: number;
miningEnabledAt: string;
createdAt: string;
} | null> {
const url = `${this.baseUrl}/api/v1/internal/planting-orders/${orderNo}`;
try {
const { data } = await firstValueFrom(this.httpService.get(url));
return data;
} catch (error: any) {
if (error?.response?.status === 404) return null;
throw error;
}
}
/**
*
*/
async getUserPosition(userId: string): Promise<{
userId: number;
totalTreeCount: number;
effectiveTreeCount: number;
} | null> {
const url = `${this.baseUrl}/api/v1/internal/planting-positions/${userId}`;
try {
const { data } = await firstValueFrom(this.httpService.get(url));
return data;
} catch (error: any) {
if (error?.response?.status === 404) return null;
throw error;
}
}
/**
*
*/
async getContributionPerTree(orderNo: string): Promise<{
contributionPerTree: string;
adoptionDate: string;
expireDate: string;
}> {
const url = `${this.baseUrl}/api/v1/internal/planting-orders/${orderNo}/contribution`;
const { data } = await firstValueFrom(this.httpService.get(url));
return data;
}
}

View File

@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
/**
* Wallet Service
* wallet-service //
*/
@Injectable()
export class WalletServiceClient {
private readonly logger = new Logger(WalletServiceClient.name);
private readonly baseUrl: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.baseUrl = this.configService.get<string>(
'external.walletServiceUrl',
'http://localhost:3001',
);
}
/**
*
*/
async freezePayment(params: {
accountSequence: string;
amount: string;
transferOrderNo: string;
reason: string;
}): Promise<{ freezeId: string }> {
const url = `${this.baseUrl}/api/v1/internal/wallet/freeze`;
this.logger.log(`[WALLET] Freezing ${params.amount} USDT for ${params.accountSequence}`);
const { data } = await firstValueFrom(
this.httpService.post(url, params),
);
return data;
}
/**
*
*/
async unfreezePayment(params: {
accountSequence: string;
freezeId: string;
transferOrderNo: string;
reason: string;
}): Promise<void> {
const url = `${this.baseUrl}/api/v1/internal/wallet/unfreeze`;
this.logger.log(`[WALLET] Unfreezing for ${params.accountSequence}`);
await firstValueFrom(this.httpService.post(url, params));
}
/**
*
* +
*/
async settleTransferPayment(params: {
buyerAccountSequence: string;
sellerAccountSequence: string;
freezeId: string;
sellerReceiveAmount: string;
platformFeeAmount: string;
transferOrderNo: string;
}): Promise<void> {
const url = `${this.baseUrl}/api/v1/internal/wallet/settle-transfer`;
this.logger.log(`[WALLET] Settling transfer ${params.transferOrderNo}`);
await firstValueFrom(this.httpService.post(url, params));
}
/**
*
*/
async getBalance(accountSequence: string): Promise<{ available: string; frozen: string }> {
const url = `${this.baseUrl}/api/v1/internal/wallet/balance/${accountSequence}`;
const { data } = await firstValueFrom(this.httpService.get(url));
return data;
}
}

View File

@ -0,0 +1 @@
export * from './infrastructure.module';

View File

@ -0,0 +1,53 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PrismaService } from './persistence/prisma/prisma.service';
import { TransferOrderRepositoryImpl } from './persistence/repositories/transfer-order.repository.impl';
import { OutboxRepository } from './persistence/repositories/outbox.repository';
import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
import { WalletServiceClient } from './external/wallet-service.client';
import { PlantingServiceClient } from './external/planting-service.client';
import { IdentityServiceClient } from './external/identity-service.client';
import { KafkaModule } from './kafka/kafka.module';
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
import { EventAckController } from './kafka/event-ack.controller';
import { SagaEventConsumer } from './kafka/saga-event.consumer';
import { TRANSFER_ORDER_REPOSITORY } from '../domain/repositories/transfer-order.repository.interface';
@Global()
@Module({
imports: [
HttpModule.register({
timeout: 5000,
maxRedirects: 5,
}),
KafkaModule,
],
controllers: [EventAckController, SagaEventConsumer],
providers: [
PrismaService,
{
provide: TRANSFER_ORDER_REPOSITORY,
useClass: TransferOrderRepositoryImpl,
},
{
provide: UNIT_OF_WORK,
useClass: UnitOfWork,
},
OutboxRepository,
OutboxPublisherService,
WalletServiceClient,
PlantingServiceClient,
IdentityServiceClient,
],
exports: [
PrismaService,
TRANSFER_ORDER_REPOSITORY,
UNIT_OF_WORK,
OutboxRepository,
OutboxPublisherService,
WalletServiceClient,
PlantingServiceClient,
IdentityServiceClient,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,27 @@
import { Controller, Logger } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { OutboxRepository } from '../persistence/repositories/outbox.repository';
/**
*
*
*/
@Controller()
export class EventAckController {
private readonly logger = new Logger(EventAckController.name);
constructor(private readonly outboxRepository: OutboxRepository) {}
@MessagePattern('transfer.events.ack')
async handleEventAck(
@Payload() message: { eventId: string; eventType?: string; status: string },
): Promise<void> {
this.logger.log(`[ACK] Received ack for event ${message.eventId} (${message.eventType || 'any'})`);
if (message.status === 'SUCCESS') {
await this.outboxRepository.markAsConfirmed(message.eventId, message.eventType);
} else {
this.logger.warn(`[ACK] Event ${message.eventId} reported failure: ${message.status}`);
}
}
}

View File

@ -0,0 +1,37 @@
import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
/**
* Kafka
* Kafka Outbox
*/
@Injectable()
export class EventPublisherService implements OnModuleInit {
private readonly logger = new Logger(EventPublisherService.name);
constructor(
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
) {}
async onModuleInit() {
await this.kafkaClient.connect();
this.logger.log('Kafka EventPublisher connected');
}
/**
* topic
*/
async publish(topic: string, key: string, payload: Record<string, unknown>): Promise<void> {
try {
this.kafkaClient.emit(topic, {
key,
value: JSON.stringify(payload),
});
this.logger.debug(`Published event to ${topic} with key ${key}`);
} catch (error) {
this.logger.error(`Failed to publish to ${topic}: ${error}`);
throw error;
}
}
}

View File

@ -0,0 +1,5 @@
export * from './kafka.module';
export * from './event-publisher.service';
export * from './outbox-publisher.service';
export * from './event-ack.controller';
export * from './saga-event.consumer';

View File

@ -0,0 +1,32 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { EventPublisherService } from './event-publisher.service';
@Global()
@Module({
imports: [
ClientsModule.registerAsync([
{
name: 'KAFKA_SERVICE',
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: configService.get<string>('KAFKA_CLIENT_ID', 'transfer-service'),
brokers: configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
},
producer: {
allowAutoTopicCreation: true,
},
},
}),
inject: [ConfigService],
},
]),
],
providers: [EventPublisherService],
exports: [EventPublisherService, ClientsModule],
})
export class KafkaModule {}

View File

@ -0,0 +1,156 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientKafka } from '@nestjs/microservices';
import { OutboxRepository, OutboxEvent } from '../persistence/repositories/outbox.repository';
/**
* Outbox Publisher Service (B方案 - )
*/
@Injectable()
export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(OutboxPublisherService.name);
private isRunning = false;
private pollInterval: NodeJS.Timeout | null = null;
private timeoutCheckInterval: NodeJS.Timeout | null = null;
private cleanupInterval: NodeJS.Timeout | null = null;
private isConnected = false;
private readonly pollIntervalMs: number;
private readonly batchSize: number;
private readonly cleanupIntervalMs: number;
private readonly confirmationTimeoutMinutes: number;
private readonly timeoutCheckIntervalMs: number;
constructor(
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
private readonly outboxRepository: OutboxRepository,
private readonly configService: ConfigService,
) {
this.pollIntervalMs = this.configService.get<number>('OUTBOX_POLL_INTERVAL_MS', 1000);
this.batchSize = this.configService.get<number>('OUTBOX_BATCH_SIZE', 100);
this.cleanupIntervalMs = this.configService.get<number>('OUTBOX_CLEANUP_INTERVAL_MS', 3600000);
this.confirmationTimeoutMinutes = this.configService.get<number>('OUTBOX_CONFIRMATION_TIMEOUT_MINUTES', 5);
this.timeoutCheckIntervalMs = this.configService.get<number>('OUTBOX_TIMEOUT_CHECK_INTERVAL_MS', 60000);
}
async onModuleInit() {
try {
await this.kafkaClient.connect();
this.isConnected = true;
this.logger.log('[OUTBOX] Connected to Kafka');
this.start();
} catch (error) {
this.logger.error('[OUTBOX] Failed to connect to Kafka:', error);
}
}
async onModuleDestroy() {
this.stop();
if (this.isConnected) {
await this.kafkaClient.close();
}
}
start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.pollInterval = setInterval(() => {
this.processOutbox().catch((err) => {
this.logger.error('[OUTBOX] Error processing outbox:', err);
});
}, this.pollIntervalMs);
this.timeoutCheckInterval = setInterval(() => {
this.checkConfirmationTimeouts().catch((err) => {
this.logger.error('[OUTBOX] Error checking timeouts:', err);
});
}, this.timeoutCheckIntervalMs);
this.cleanupInterval = setInterval(() => {
this.cleanup().catch((err) => {
this.logger.error('[OUTBOX] Error cleaning up:', err);
});
}, this.cleanupIntervalMs);
this.logger.log('[OUTBOX] Outbox publisher started (B方案)');
}
stop(): void {
if (!this.isRunning) return;
this.isRunning = false;
if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; }
if (this.timeoutCheckInterval) { clearInterval(this.timeoutCheckInterval); this.timeoutCheckInterval = null; }
if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; }
}
async processOutbox(): Promise<void> {
if (!this.isConnected) return;
try {
const pendingEvents = await this.outboxRepository.findPendingEvents(this.batchSize);
const retryEvents = await this.outboxRepository.findEventsForRetry(Math.floor(this.batchSize / 2));
const allEvents = [...pendingEvents, ...retryEvents];
if (allEvents.length === 0) return;
for (const event of allEvents) {
await this.publishEvent(event);
}
} catch (error) {
this.logger.error('[OUTBOX] Error in processOutbox:', error);
}
}
private async publishEvent(event: OutboxEvent): Promise<void> {
try {
const payload = {
...(event.payload as Record<string, unknown>),
_outbox: {
id: event.id.toString(),
aggregateId: event.aggregateId,
eventType: event.eventType,
},
};
this.kafkaClient.emit(event.topic, {
key: event.key,
value: JSON.stringify(payload),
});
await this.outboxRepository.markAsSent(event.id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.outboxRepository.markAsFailed(event.id, errorMessage);
}
}
private async checkConfirmationTimeouts(): Promise<void> {
if (!this.isConnected) return;
try {
const timedOutEvents = await this.outboxRepository.findSentEventsTimedOut(
this.confirmationTimeoutMinutes,
this.batchSize,
);
for (const event of timedOutEvents) {
await this.outboxRepository.resetSentToPending(event.id);
}
} catch (error) {
this.logger.error('[OUTBOX] Error checking timeouts:', error);
}
}
private async cleanup(): Promise<void> {
const retentionDays = this.configService.get<number>('OUTBOX_RETENTION_DAYS', 7);
await this.outboxRepository.cleanupOldEvents(retentionDays);
}
async getStats() {
const stats = await this.outboxRepository.getStats();
return { isRunning: this.isRunning, isConnected: this.isConnected, ...stats };
}
}

View File

@ -0,0 +1,53 @@
import { Controller, Logger } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
/**
* Saga
* Saga /
* Saga Application SagaOrchestrator
*/
@Controller()
export class SagaEventConsumer {
private readonly logger = new Logger(SagaEventConsumer.name);
/**
* planting-service: 树锁定完成确认
*/
@MessagePattern('transfer.trees.lock.ack')
async handleTreesLockAck(@Payload() message: any): Promise<void> {
this.logger.log(`[SAGA] Trees lock ack received: ${JSON.stringify(message)}`);
// 将在 SagaOrchestrator 注入后委托处理
}
/**
* planting-service: 所有权变更完成确认
*/
@MessagePattern('planting.transfer.completed')
async handleOwnershipTransferCompleted(@Payload() message: any): Promise<void> {
this.logger.log(`[SAGA] Ownership transfer completed: ${JSON.stringify(message)}`);
}
/**
* contribution-service: 算力调整完成确认
*/
@MessagePattern('contribution.transfer.adjusted')
async handleContributionAdjusted(@Payload() message: any): Promise<void> {
this.logger.log(`[SAGA] Contribution adjusted: ${JSON.stringify(message)}`);
}
/**
* referral-service: 团队统计更新完成确认
*/
@MessagePattern('referral.transfer.stats-updated')
async handleStatsUpdated(@Payload() message: any): Promise<void> {
this.logger.log(`[SAGA] Team stats updated: ${JSON.stringify(message)}`);
}
/**
* planting-service: 树解锁完成确认
*/
@MessagePattern('transfer.trees.unlock.ack')
async handleTreesUnlockAck(@Payload() message: any): Promise<void> {
this.logger.log(`[SAGA] Trees unlock ack received: ${JSON.stringify(message)}`);
}
}

View File

@ -0,0 +1,89 @@
import { TransferOrder, TransferOrderData } from '../../../domain/aggregates/transfer-order.aggregate';
/**
* TransferOrder Domain <-> Persistence
*/
export class TransferOrderMapper {
/**
* Prisma Domain
*/
static toDomain(prismaOrder: any): TransferOrder {
const data: TransferOrderData = {
id: prismaOrder.id,
transferOrderNo: prismaOrder.transferOrderNo,
sellerUserId: prismaOrder.sellerUserId,
sellerAccountSequence: prismaOrder.sellerAccountSequence,
buyerUserId: prismaOrder.buyerUserId,
buyerAccountSequence: prismaOrder.buyerAccountSequence,
sourceOrderNo: prismaOrder.sourceOrderNo,
sourceAdoptionId: prismaOrder.sourceAdoptionId,
treeCount: prismaOrder.treeCount,
contributionPerTree: prismaOrder.contributionPerTree.toString(),
originalAdoptionDate: prismaOrder.originalAdoptionDate,
originalExpireDate: prismaOrder.originalExpireDate,
selectedProvince: prismaOrder.selectedProvince,
selectedCity: prismaOrder.selectedCity,
transferPrice: prismaOrder.transferPrice.toString(),
platformFeeRate: prismaOrder.platformFeeRate.toString(),
platformFeeAmount: prismaOrder.platformFeeAmount.toString(),
sellerReceiveAmount: prismaOrder.sellerReceiveAmount.toString(),
status: prismaOrder.status,
sagaStep: prismaOrder.sagaStep,
failReason: prismaOrder.failReason,
retryCount: prismaOrder.retryCount,
sellerConfirmedAt: prismaOrder.sellerConfirmedAt,
paymentFrozenAt: prismaOrder.paymentFrozenAt,
treesLockedAt: prismaOrder.treesLockedAt,
ownershipTransferredAt: prismaOrder.ownershipTransferredAt,
contributionAdjustedAt: prismaOrder.contributionAdjustedAt,
statsUpdatedAt: prismaOrder.statsUpdatedAt,
paymentSettledAt: prismaOrder.paymentSettledAt,
completedAt: prismaOrder.completedAt,
cancelledAt: prismaOrder.cancelledAt,
rolledBackAt: prismaOrder.rolledBackAt,
createdAt: prismaOrder.createdAt,
updatedAt: prismaOrder.updatedAt,
};
return TransferOrder.reconstitute(data);
}
/**
* Domain Prisma
*/
static toPersistence(order: TransferOrder) {
return {
transferOrderNo: order.transferOrderNo,
sellerUserId: order.sellerUserId,
sellerAccountSequence: order.sellerAccountSequence,
buyerUserId: order.buyerUserId,
buyerAccountSequence: order.buyerAccountSequence,
sourceOrderNo: order.sourceOrderNo,
sourceAdoptionId: order.sourceAdoptionId,
treeCount: order.treeCount,
contributionPerTree: order.contributionPerTree,
originalAdoptionDate: order.originalAdoptionDate,
originalExpireDate: order.originalExpireDate,
selectedProvince: order.selectedProvince,
selectedCity: order.selectedCity,
transferPrice: order.transferPrice,
platformFeeRate: order.platformFeeRate,
platformFeeAmount: order.platformFeeAmount,
sellerReceiveAmount: order.sellerReceiveAmount,
status: order.status,
sagaStep: order.sagaStep,
failReason: order.failReason,
retryCount: order.retryCount,
sellerConfirmedAt: order.sellerConfirmedAt,
paymentFrozenAt: order.paymentFrozenAt,
treesLockedAt: order.treesLockedAt,
ownershipTransferredAt: order.ownershipTransferredAt,
contributionAdjustedAt: order.contributionAdjustedAt,
statsUpdatedAt: order.statsUpdatedAt,
paymentSettledAt: order.paymentSettledAt,
completedAt: order.completedAt,
cancelledAt: order.cancelledAt,
rolledBackAt: order.rolledBackAt,
};
}
}

View File

@ -0,0 +1,67 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient, Prisma } from '@prisma/client';
// 定义事务客户端类型
export type TransactionClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super({
log:
process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
/**
*
*/
async executeTransaction<T>(
fn: (tx: TransactionClient) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
return this.$transaction(fn, {
maxWait: options?.maxWait ?? 5000,
timeout: options?.timeout ?? 10000,
isolationLevel: options?.isolationLevel ?? Prisma.TransactionIsolationLevel.ReadCommitted,
});
}
async cleanDatabase() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('cleanDatabase can only be used in test environment');
}
const tablenames = await this.$queryRaw<
Array<{ tablename: string }>
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await this.$executeRawUnsafe(
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`,
);
}
}
}
}

View File

@ -0,0 +1,230 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
export enum OutboxStatus {
PENDING = 'PENDING',
SENT = 'SENT',
CONFIRMED = 'CONFIRMED',
FAILED = 'FAILED',
}
export interface OutboxEventData {
eventType: string;
topic: string;
key: string;
payload: Record<string, unknown>;
aggregateId: string;
aggregateType: string;
}
export interface OutboxEvent extends OutboxEventData {
id: bigint;
status: OutboxStatus;
retryCount: number;
maxRetries: number;
lastError: string | null;
createdAt: Date;
publishedAt: Date | null;
nextRetryAt: Date | null;
}
@Injectable()
export class OutboxRepository {
private readonly logger = new Logger(OutboxRepository.name);
constructor(private readonly prisma: PrismaService) {}
async saveInTransaction(
tx: Prisma.TransactionClient,
events: OutboxEventData[],
): Promise<void> {
if (events.length === 0) return;
await tx.outboxEvent.createMany({
data: events.map((event) => ({
eventType: event.eventType,
topic: event.topic,
key: event.key,
payload: event.payload as Prisma.JsonObject,
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
status: OutboxStatus.PENDING,
})),
});
}
async saveEvents(events: OutboxEventData[]): Promise<void> {
if (events.length === 0) return;
await this.prisma.outboxEvent.createMany({
data: events.map((event) => ({
eventType: event.eventType,
topic: event.topic,
key: event.key,
payload: event.payload as Prisma.JsonObject,
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
status: OutboxStatus.PENDING,
})),
});
}
async findPendingEvents(limit: number = 100): Promise<OutboxEvent[]> {
const events = await this.prisma.outboxEvent.findMany({
where: { status: OutboxStatus.PENDING },
orderBy: { createdAt: 'asc' },
take: limit,
});
return events.map((e) => this.mapToOutboxEvent(e));
}
async findEventsForRetry(limit: number = 50): Promise<OutboxEvent[]> {
const now = new Date();
const events = await this.prisma.outboxEvent.findMany({
where: {
status: OutboxStatus.FAILED,
retryCount: { lt: 5 },
nextRetryAt: { lte: now },
},
orderBy: { nextRetryAt: 'asc' },
take: limit,
});
return events.map((e) => this.mapToOutboxEvent(e));
}
async markAsSent(id: bigint): Promise<void> {
await this.prisma.outboxEvent.update({
where: { id },
data: {
status: OutboxStatus.SENT,
publishedAt: new Date(),
},
});
}
async markAsConfirmed(eventId: string, eventType?: string): Promise<boolean> {
const whereClause: Prisma.OutboxEventWhereInput = {
aggregateId: eventId,
status: OutboxStatus.SENT,
};
if (eventType) {
whereClause.eventType = eventType;
}
const result = await this.prisma.outboxEvent.updateMany({
where: whereClause,
data: { status: OutboxStatus.CONFIRMED },
});
if (result.count > 0) {
this.logger.log(`[OUTBOX] Event ${eventId} (${eventType || 'all types'}) confirmed`);
return true;
}
return false;
}
async markAsConfirmedById(id: bigint): Promise<void> {
await this.prisma.outboxEvent.update({
where: { id },
data: { status: OutboxStatus.CONFIRMED },
});
}
async findSentEventsTimedOut(timeoutMinutes: number = 5, limit: number = 50): Promise<OutboxEvent[]> {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - timeoutMinutes);
const events = await this.prisma.outboxEvent.findMany({
where: {
status: OutboxStatus.SENT,
publishedAt: { lt: cutoffTime },
retryCount: { lt: 5 },
},
orderBy: { publishedAt: 'asc' },
take: limit,
});
return events.map((e) => this.mapToOutboxEvent(e));
}
async resetSentToPending(id: bigint): Promise<void> {
const event = await this.prisma.outboxEvent.findUnique({ where: { id } });
if (!event) return;
await this.prisma.outboxEvent.update({
where: { id },
data: {
status: OutboxStatus.PENDING,
retryCount: event.retryCount + 1,
lastError: 'Consumer confirmation timeout',
},
});
}
async markAsFailed(id: bigint, error: string): Promise<void> {
const event = await this.prisma.outboxEvent.findUnique({ where: { id } });
if (!event) return;
const newRetryCount = event.retryCount + 1;
const delayMinutes = Math.pow(2, newRetryCount - 1);
const nextRetryAt = new Date();
nextRetryAt.setMinutes(nextRetryAt.getMinutes() + delayMinutes);
await this.prisma.outboxEvent.update({
where: { id },
data: {
status: OutboxStatus.FAILED,
retryCount: newRetryCount,
lastError: error,
nextRetryAt: newRetryCount >= event.maxRetries ? null : nextRetryAt,
},
});
}
async cleanupOldEvents(retentionDays: number = 7): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const result = await this.prisma.outboxEvent.deleteMany({
where: {
status: OutboxStatus.CONFIRMED,
publishedAt: { lt: cutoffDate },
},
});
return result.count;
}
async getStats(): Promise<{
pending: number;
sent: number;
confirmed: number;
failed: number;
}> {
const [pending, sent, confirmed, failed] = await Promise.all([
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.PENDING } }),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.SENT } }),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.CONFIRMED } }),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }),
]);
return { pending, sent, confirmed, failed };
}
private mapToOutboxEvent(record: any): OutboxEvent {
return {
id: record.id,
eventType: record.eventType,
topic: record.topic,
key: record.key,
payload: record.payload as Record<string, unknown>,
aggregateId: record.aggregateId,
aggregateType: record.aggregateType,
status: record.status as OutboxStatus,
retryCount: record.retryCount,
maxRetries: record.maxRetries,
lastError: record.lastError,
createdAt: record.createdAt,
publishedAt: record.publishedAt,
nextRetryAt: record.nextRetryAt,
};
}
}

View File

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TransferOrderMapper } from '../mappers/transfer-order.mapper';
import { TransferOrder } from '../../../domain/aggregates/transfer-order.aggregate';
import { ITransferOrderRepository } from '../../../domain/repositories/transfer-order.repository.interface';
@Injectable()
export class TransferOrderRepositoryImpl implements ITransferOrderRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: bigint): Promise<TransferOrder | null> {
const record = await this.prisma.transferOrder.findUnique({
where: { id },
});
return record ? TransferOrderMapper.toDomain(record) : null;
}
async findByTransferOrderNo(transferOrderNo: string): Promise<TransferOrder | null> {
const record = await this.prisma.transferOrder.findUnique({
where: { transferOrderNo },
});
return record ? TransferOrderMapper.toDomain(record) : null;
}
async findBySellerUserId(
userId: bigint,
options?: { limit?: number; offset?: number },
): Promise<TransferOrder[]> {
const records = await this.prisma.transferOrder.findMany({
where: { sellerUserId: userId },
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 20,
skip: options?.offset ?? 0,
});
return records.map(TransferOrderMapper.toDomain);
}
async findByBuyerUserId(
userId: bigint,
options?: { limit?: number; offset?: number },
): Promise<TransferOrder[]> {
const records = await this.prisma.transferOrder.findMany({
where: { buyerUserId: userId },
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 20,
skip: options?.offset ?? 0,
});
return records.map(TransferOrderMapper.toDomain);
}
async findBySourceOrderNo(sourceOrderNo: string): Promise<TransferOrder[]> {
const records = await this.prisma.transferOrder.findMany({
where: { sourceOrderNo },
orderBy: { createdAt: 'desc' },
});
return records.map(TransferOrderMapper.toDomain);
}
async findByStatus(status: string, limit: number = 100): Promise<TransferOrder[]> {
const records = await this.prisma.transferOrder.findMany({
where: { status },
orderBy: { createdAt: 'asc' },
take: limit,
});
return records.map(TransferOrderMapper.toDomain);
}
async countByUserAsSellerOrBuyer(userId: bigint): Promise<number> {
return this.prisma.transferOrder.count({
where: {
OR: [
{ sellerUserId: userId },
{ buyerUserId: userId },
],
},
});
}
}

View File

@ -0,0 +1,162 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService, TransactionClient } from './prisma/prisma.service';
import { TransferOrder } from '../../domain/aggregates/transfer-order.aggregate';
import { TransferOrderMapper } from './mappers/transfer-order.mapper';
import { OutboxEventData } from './repositories/outbox.repository';
/**
* -
*/
@Injectable()
export class UnitOfWork {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async executeInTransaction<T>(
fn: (uow: TransactionalUnitOfWork) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
return this.prisma.executeTransaction(async (tx) => {
const transactionalUow = new TransactionalUnitOfWork(tx);
return fn(transactionalUow);
}, options);
}
}
/**
* -
*
* Outbox Pattern
*/
export class TransactionalUnitOfWork {
private readonly logger = new Logger(TransactionalUnitOfWork.name);
private pendingOutboxEvents: OutboxEventData[] = [];
constructor(private readonly tx: TransactionClient) {}
/**
* Outbox
*/
addOutboxEvent(event: OutboxEventData): void {
this.pendingOutboxEvents.push(event);
}
/**
* Outbox
*/
addOutboxEvents(events: OutboxEventData[]): void {
this.pendingOutboxEvents.push(...events);
}
/**
* Outbox
*/
async commitOutboxEvents(): Promise<void> {
if (this.pendingOutboxEvents.length === 0) return;
await this.tx.outboxEvent.createMany({
data: this.pendingOutboxEvents.map((event) => ({
eventType: event.eventType,
topic: event.topic,
key: event.key,
payload: event.payload as Prisma.JsonObject,
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
status: 'PENDING',
})),
});
this.logger.log(`[OUTBOX] Committed ${this.pendingOutboxEvents.length} outbox events`);
this.pendingOutboxEvents = [];
}
/**
*
*/
async saveTransferOrder(order: TransferOrder): Promise<void> {
const data = TransferOrderMapper.toPersistence(order);
if (order.id) {
await this.tx.transferOrder.update({
where: { id: order.id },
data: {
status: data.status,
sagaStep: data.sagaStep,
failReason: data.failReason,
retryCount: data.retryCount,
sellerConfirmedAt: data.sellerConfirmedAt,
paymentFrozenAt: data.paymentFrozenAt,
treesLockedAt: data.treesLockedAt,
ownershipTransferredAt: data.ownershipTransferredAt,
contributionAdjustedAt: data.contributionAdjustedAt,
statsUpdatedAt: data.statsUpdatedAt,
paymentSettledAt: data.paymentSettledAt,
completedAt: data.completedAt,
cancelledAt: data.cancelledAt,
rolledBackAt: data.rolledBackAt,
},
});
} else {
const created = await this.tx.transferOrder.create({
data: {
transferOrderNo: data.transferOrderNo,
sellerUserId: data.sellerUserId,
sellerAccountSequence: data.sellerAccountSequence,
buyerUserId: data.buyerUserId,
buyerAccountSequence: data.buyerAccountSequence,
sourceOrderNo: data.sourceOrderNo,
sourceAdoptionId: data.sourceAdoptionId,
treeCount: data.treeCount,
contributionPerTree: data.contributionPerTree,
originalAdoptionDate: data.originalAdoptionDate,
originalExpireDate: data.originalExpireDate,
selectedProvince: data.selectedProvince,
selectedCity: data.selectedCity,
transferPrice: data.transferPrice,
platformFeeRate: data.platformFeeRate,
platformFeeAmount: data.platformFeeAmount,
sellerReceiveAmount: data.sellerReceiveAmount,
status: data.status,
sagaStep: data.sagaStep,
},
});
order.setId(created.id);
}
}
/**
*
*/
async logStatusChange(params: {
transferOrderNo: string;
fromStatus: string;
toStatus: string;
fromSagaStep: string;
toSagaStep: string;
operatorType: string;
operatorId?: string;
remark?: string;
}): Promise<void> {
await this.tx.transferStatusLog.create({
data: {
transferOrderNo: params.transferOrderNo,
fromStatus: params.fromStatus,
toStatus: params.toStatus,
fromSagaStep: params.fromSagaStep,
toSagaStep: params.toSagaStep,
operatorType: params.operatorType,
operatorId: params.operatorId,
remark: params.remark,
},
});
}
}
export const UNIT_OF_WORK = Symbol('UnitOfWork');

View File

@ -0,0 +1,88 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
// 全局前缀
app.setGlobalPrefix('api/v1');
// 全局验证管道
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// CORS 配置
app.enableCors({
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
});
// Swagger API 文档
const config = new DocumentBuilder()
.setTitle('Transfer Service API')
.setDescription('RWA 榴莲皇后平台树转让服务 API')
.setVersion('1.0.0')
.addBearerAuth()
.addTag('转让订单', '转让订单相关接口')
.addTag('管理端', '管理端转让相关接口')
.addTag('健康检查', '服务健康检查接口')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// Kafka 微服务配置
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'transfer-service-group';
// 微服务 1: 用于接收 ACK 确认消息
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'transfer-service-ack',
brokers: kafkaBrokers,
},
consumer: {
groupId: `${kafkaGroupId}-ack`,
},
},
});
// 微服务 2: 用于接收各服务的 Saga 步骤确认事件
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'transfer-service-saga-events',
brokers: kafkaBrokers,
},
consumer: {
groupId: `${kafkaGroupId}-saga-events`,
},
},
});
// 启动所有 Kafka 微服务
await app.startAllMicroservices();
logger.log('Kafka microservices started (ACK + Saga Events)');
const port = process.env.APP_PORT || 3013;
await app.listen(port);
logger.log(`Transfer Service is running on port ${port}`);
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,40 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
}
response.status(status).json({
statusCode: status,
message: Array.isArray(message) ? message : [message],
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1 @@
export * from './global-exception.filter';

View File

@ -0,0 +1 @@
export * from './filters';

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "prisma", "**/*spec.ts"]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -11,6 +11,8 @@ import {
} from './controllers';
import { InternalWalletController } from './controllers/internal-wallet.controller';
import { FiatWithdrawalController } from './controllers/fiat-withdrawal.controller';
// [2026-02-19] 纯新增:转让钱包内部 Controller
import { InternalTransferWalletController } from './controllers/internal-transfer-wallet.controller';
import { WalletApplicationService, FiatWithdrawalApplicationService, SystemWithdrawalApplicationService } from '@/application/services';
import { DepositConfirmedHandler, PlantingCreatedHandler, UserAccountCreatedHandler } from '@/application/event-handlers';
import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler';
@ -37,6 +39,7 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
InternalWalletController,
FiatWithdrawalController,
SystemWithdrawalController,
InternalTransferWalletController, // [2026-02-19] 纯新增:转让钱包内部端点
],
providers: [
WalletApplicationService,

View File

@ -0,0 +1,117 @@
/**
* API
* transfer-service Saga HTTP
*
* /api/v1/internal/wallet/*
* InternalWalletController(/api/v1/wallets/*)
*
* api.module.ts
*/
import { Controller, Post, Get, Body, Param, Logger } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { WalletApplicationService } from '@/application/services';
import {
FreezeForTransferCommand,
UnfreezeForTransferCommand,
SettleTransferPaymentCommand,
} from '@/application/commands';
import { Public } from '@/shared/decorators';
import {
FreezeForTransferDto,
UnfreezeForTransferDto,
SettleTransferDto,
} from '../dto/request/transfer-wallet.dto';
@ApiTags('Internal Transfer Wallet API')
@Controller('internal/wallet')
export class InternalTransferWalletController {
private readonly logger = new Logger(InternalTransferWalletController.name);
constructor(
private readonly walletService: WalletApplicationService,
) {}
/**
*
* POST /api/v1/internal/wallet/freeze
*/
@Post('freeze')
@Public()
async freezeForTransfer(@Body() dto: FreezeForTransferDto): Promise<{ freezeId: string }> {
this.logger.log(`[freeze] Request: accountSequence=${dto.accountSequence}, amount=${dto.amount}, transferOrderNo=${dto.transferOrderNo}`);
const command = new FreezeForTransferCommand(
dto.accountSequence,
dto.amount,
dto.transferOrderNo,
dto.reason,
);
const result = await this.walletService.freezeForTransfer(command);
this.logger.log(`[freeze] Success: freezeId=${result.freezeId}`);
return result;
}
/**
* /
* POST /api/v1/internal/wallet/unfreeze
*/
@Post('unfreeze')
@Public()
async unfreezeForTransfer(@Body() dto: UnfreezeForTransferDto): Promise<{ success: boolean }> {
this.logger.log(`[unfreeze] Request: accountSequence=${dto.accountSequence}, freezeId=${dto.freezeId}, transferOrderNo=${dto.transferOrderNo}`);
const command = new UnfreezeForTransferCommand(
dto.accountSequence,
dto.freezeId,
dto.transferOrderNo,
dto.reason,
);
await this.walletService.unfreezeForTransfer(command);
this.logger.log(`[unfreeze] Success: transferOrderNo=${dto.transferOrderNo}`);
return { success: true };
}
/**
*
* POST /api/v1/internal/wallet/settle-transfer
*/
@Post('settle-transfer')
@Public()
async settleTransfer(@Body() dto: SettleTransferDto): Promise<{ success: boolean }> {
this.logger.log(`[settle-transfer] Request: buyer=${dto.buyerAccountSequence}, seller=${dto.sellerAccountSequence}, transferOrderNo=${dto.transferOrderNo}`);
const command = new SettleTransferPaymentCommand(
dto.buyerAccountSequence,
dto.sellerAccountSequence,
dto.freezeId,
dto.sellerReceiveAmount,
dto.platformFeeAmount,
dto.transferOrderNo,
);
await this.walletService.settleTransferPayment(command);
this.logger.log(`[settle-transfer] Success: transferOrderNo=${dto.transferOrderNo}`);
return { success: true };
}
/**
*
* GET /api/v1/internal/wallet/balance/:accountSequence
*/
@Get('balance/:accountSequence')
@Public()
async getBalance(@Param('accountSequence') accountSequence: string): Promise<{
available: string;
frozen: string;
}> {
const query = { userId: accountSequence } as any;
const wallet = await this.walletService.getMyWallet(query);
return {
available: wallet.balances.usdt.available.toString(),
frozen: wallet.balances.usdt.frozen.toString(),
};
}
}

View File

@ -0,0 +1,38 @@
/**
* DTO
* transfer-service HTTP
*
* controller
*/
/**
*
*/
export class FreezeForTransferDto {
accountSequence: string;
amount: string; // 字符串格式保持 Decimal 精度
transferOrderNo: string;
reason: string;
}
/**
* /
*/
export class UnfreezeForTransferDto {
accountSequence: string;
freezeId: string; // = transferOrderNo
transferOrderNo: string;
reason: string;
}
/**
* +
*/
export class SettleTransferDto {
buyerAccountSequence: string;
sellerAccountSequence: string;
freezeId: string;
sellerReceiveAmount: string;
platformFeeAmount: string;
transferOrderNo: string;
}

View File

@ -0,0 +1,12 @@
/**
*
*
*/
export class FreezeForTransferCommand {
constructor(
public readonly accountSequence: string,
public readonly amount: string,
public readonly transferOrderNo: string,
public readonly reason: string,
) {}
}

View File

@ -9,3 +9,7 @@ export * from './settle-rewards.command';
export * from './settle-to-balance.command';
export * from './allocate-funds.command';
export * from './request-withdrawal.command';
// [2026-02-19] 纯新增:树转让钱包命令
export * from './freeze-for-transfer.command';
export * from './unfreeze-for-transfer.command';
export * from './settle-transfer-payment.command';

View File

@ -0,0 +1,14 @@
/**
*
*
*/
export class SettleTransferPaymentCommand {
constructor(
public readonly buyerAccountSequence: string,
public readonly sellerAccountSequence: string,
public readonly freezeId: string,
public readonly sellerReceiveAmount: string,
public readonly platformFeeAmount: string,
public readonly transferOrderNo: string,
) {}
}

View File

@ -0,0 +1,12 @@
/**
*
*
*/
export class UnfreezeForTransferCommand {
constructor(
public readonly accountSequence: string,
public readonly freezeId: string,
public readonly transferOrderNo: string,
public readonly reason: string,
) {}
}

View File

@ -18,6 +18,7 @@ import {
ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem,
RequestWithdrawalCommand, UpdateWithdrawalStatusCommand,
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
FreezeForTransferCommand, UnfreezeForTransferCommand, SettleTransferPaymentCommand,
} from '@/application/commands';
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
@ -3945,4 +3946,440 @@ export class WalletApplicationService {
return results;
}
// ============================================================
// [2026-02-19] 纯新增树转让钱包操作3 个方法)
// 供 transfer-service Saga 编排器通过 HTTP 调用
// 回滚方式:删除以下 3 对方法 + 移除 import
// ============================================================
/**
* USDT available frozen
* transferOrderNo
*/
async freezeForTransfer(command: FreezeForTransferCommand): Promise<{ freezeId: string }> {
const MAX_RETRIES = 3;
let retries = 0;
while (retries < MAX_RETRIES) {
try {
return await this.executeFreezeForTransfer(command);
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[freezeForTransfer] Optimistic lock conflict for ${command.transferOrderNo}, retry ${retries}/${MAX_RETRIES}`);
if (retries >= MAX_RETRIES) throw error;
await this.sleep(50 * retries);
} else {
throw error;
}
}
}
throw new Error('Unexpected: exited retry loop without result');
}
private async executeFreezeForTransfer(command: FreezeForTransferCommand): Promise<{ freezeId: string }> {
const Decimal = (await import('decimal.js')).default;
const amountDecimal = new Decimal(command.amount);
let walletUserId: bigint | null = null;
this.logger.log(`[freezeForTransfer] 开始处理: accountSequence=${command.accountSequence}, amount=${command.amount}, transferOrderNo=${command.transferOrderNo}`);
await this.prisma.$transaction(async (tx) => {
// 幂等性检查
const existingEntry = await tx.ledgerEntry.findFirst({
where: {
refOrderId: command.transferOrderNo,
entryType: LedgerEntryType.FREEZE,
},
});
if (existingEntry) {
this.logger.warn(`[freezeForTransfer] Transfer ${command.transferOrderNo} already frozen (idempotent)`);
return;
}
// 查找钱包
const walletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: command.accountSequence },
});
if (!walletRecord) {
throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`);
}
walletUserId = walletRecord.userId;
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
const currentVersion = walletRecord.version;
// 余额检查
if (currentAvailable.lessThan(amountDecimal)) {
throw new BadRequestException(
`余额不足: 需要 ${command.amount} USDT, 可用 ${currentAvailable.toString()} USDT`,
);
}
const newAvailable = currentAvailable.minus(amountDecimal);
const newFrozen = currentFrozen.plus(amountDecimal);
// 乐观锁更新
const updateResult = await tx.walletAccount.updateMany({
where: { id: walletRecord.id, version: currentVersion },
data: {
usdtAvailable: newAvailable,
usdtFrozen: newFrozen,
version: currentVersion + 1,
updatedAt: new Date(),
},
});
if (updateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
}
// 冻结流水
await tx.ledgerEntry.create({
data: {
accountSequence: walletRecord.accountSequence,
userId: walletRecord.userId,
entryType: LedgerEntryType.FREEZE,
amount: amountDecimal.negated(),
assetType: 'USDT',
balanceAfter: newAvailable,
refOrderId: command.transferOrderNo,
memo: `Transfer freeze: ${command.reason}`,
},
});
this.logger.log(`[freezeForTransfer] 成功冻结 ${command.amount} USDT for ${command.transferOrderNo}`);
});
if (walletUserId) {
await this.walletCacheService.invalidateWallet(walletUserId);
}
return { freezeId: command.transferOrderNo };
}
/**
* /
* transferOrderNo
*/
async unfreezeForTransfer(command: UnfreezeForTransferCommand): Promise<void> {
const MAX_RETRIES = 3;
let retries = 0;
while (retries < MAX_RETRIES) {
try {
await this.executeUnfreezeForTransfer(command);
return;
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[unfreezeForTransfer] Optimistic lock conflict for ${command.transferOrderNo}, retry ${retries}/${MAX_RETRIES}`);
if (retries >= MAX_RETRIES) throw error;
await this.sleep(50 * retries);
} else {
throw error;
}
}
}
throw new Error('Unexpected: exited retry loop without result');
}
private async executeUnfreezeForTransfer(command: UnfreezeForTransferCommand): Promise<void> {
const Decimal = (await import('decimal.js')).default;
let walletUserId: bigint | null = null;
this.logger.log(`[unfreezeForTransfer] 开始处理: accountSequence=${command.accountSequence}, transferOrderNo=${command.transferOrderNo}`);
await this.prisma.$transaction(async (tx) => {
// 幂等性检查
const existingUnfreeze = await tx.ledgerEntry.findFirst({
where: {
refOrderId: command.transferOrderNo,
entryType: LedgerEntryType.UNFREEZE,
},
});
if (existingUnfreeze) {
this.logger.warn(`[unfreezeForTransfer] Transfer ${command.transferOrderNo} already unfrozen (idempotent)`);
return;
}
// 安全检查:已结算则不可解冻
const existingSettle = await tx.ledgerEntry.findFirst({
where: {
refOrderId: command.transferOrderNo,
entryType: LedgerEntryType.TRANSFER_OUT,
},
});
if (existingSettle) {
throw new BadRequestException(`转让 ${command.transferOrderNo} 已结算,无法解冻`);
}
// 查找原始冻结记录
const freezeEntry = await tx.ledgerEntry.findFirst({
where: {
refOrderId: command.transferOrderNo,
entryType: LedgerEntryType.FREEZE,
},
});
if (!freezeEntry) {
this.logger.warn(`[unfreezeForTransfer] Transfer ${command.transferOrderNo} no freeze record, returning success`);
return;
}
const frozenAmount = new Decimal(freezeEntry.amount.toString()).abs();
// 查找钱包
const walletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: command.accountSequence },
});
if (!walletRecord) {
throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`);
}
walletUserId = walletRecord.userId;
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
const currentVersion = walletRecord.version;
const newAvailable = currentAvailable.plus(frozenAmount);
const newFrozen = currentFrozen.minus(frozenAmount);
// 乐观锁更新
const updateResult = await tx.walletAccount.updateMany({
where: { id: walletRecord.id, version: currentVersion },
data: {
usdtAvailable: newAvailable,
usdtFrozen: newFrozen,
version: currentVersion + 1,
updatedAt: new Date(),
},
});
if (updateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
}
// 解冻流水
await tx.ledgerEntry.create({
data: {
accountSequence: walletRecord.accountSequence,
userId: walletRecord.userId,
entryType: LedgerEntryType.UNFREEZE,
amount: frozenAmount,
assetType: 'USDT',
balanceAfter: newAvailable,
refOrderId: command.transferOrderNo,
memo: `Transfer unfreeze: ${command.reason}`,
},
});
this.logger.log(`[unfreezeForTransfer] 成功解冻 ${frozenAmount.toString()} USDT for ${command.transferOrderNo}`);
});
if (walletUserId) {
await this.walletCacheService.invalidateWallet(walletUserId);
}
}
/**
*
* 3 + +
* transferOrderNo
*/
async settleTransferPayment(command: SettleTransferPaymentCommand): Promise<void> {
const MAX_RETRIES = 3;
let retries = 0;
while (retries < MAX_RETRIES) {
try {
await this.executeSettleTransferPayment(command);
return;
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[settleTransferPayment] Optimistic lock conflict for ${command.transferOrderNo}, retry ${retries}/${MAX_RETRIES}`);
if (retries >= MAX_RETRIES) throw error;
await this.sleep(50 * retries);
} else {
throw error;
}
}
}
throw new Error('Unexpected: exited retry loop without result');
}
private async executeSettleTransferPayment(command: SettleTransferPaymentCommand): Promise<void> {
const Decimal = (await import('decimal.js')).default;
const sellerAmount = new Decimal(command.sellerReceiveAmount);
const feeAmount = new Decimal(command.platformFeeAmount);
const totalDeduct = sellerAmount.plus(feeAmount);
const affectedUserIds: bigint[] = [];
this.logger.log(`[settleTransferPayment] 开始结算: transferOrderNo=${command.transferOrderNo}`);
this.logger.log(`[settleTransferPayment] buyer=${command.buyerAccountSequence}, seller=${command.sellerAccountSequence}`);
this.logger.log(`[settleTransferPayment] sellerReceive=${command.sellerReceiveAmount}, fee=${command.platformFeeAmount}`);
await this.prisma.$transaction(async (tx) => {
// 幂等性检查
const existingSettle = await tx.ledgerEntry.findFirst({
where: {
refOrderId: command.transferOrderNo,
entryType: LedgerEntryType.TRANSFER_OUT,
},
});
if (existingSettle) {
this.logger.warn(`[settleTransferPayment] Transfer ${command.transferOrderNo} already settled (idempotent)`);
return;
}
// ===== 1. 买方:从冻结余额扣减 =====
const buyerWallet = await tx.walletAccount.findUnique({
where: { accountSequence: command.buyerAccountSequence },
});
if (!buyerWallet) {
throw new WalletNotFoundError(`buyer accountSequence: ${command.buyerAccountSequence}`);
}
const buyerFrozen = new Decimal(buyerWallet.usdtFrozen.toString());
if (buyerFrozen.lessThan(totalDeduct)) {
throw new BadRequestException(
`买方冻结余额不足: 需要 ${totalDeduct.toString()} USDT, 冻结 ${buyerFrozen.toString()} USDT`,
);
}
const newBuyerFrozen = buyerFrozen.minus(totalDeduct);
const buyerUpdateResult = await tx.walletAccount.updateMany({
where: { id: buyerWallet.id, version: buyerWallet.version },
data: {
usdtFrozen: newBuyerFrozen,
version: buyerWallet.version + 1,
updatedAt: new Date(),
},
});
if (buyerUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for buyer wallet ${buyerWallet.id}`);
}
affectedUserIds.push(buyerWallet.userId);
// 买方扣款流水
await tx.ledgerEntry.create({
data: {
accountSequence: buyerWallet.accountSequence,
userId: buyerWallet.userId,
entryType: LedgerEntryType.TRANSFER_OUT,
amount: totalDeduct.negated(),
assetType: 'USDT',
balanceAfter: new Decimal(buyerWallet.usdtAvailable.toString()),
refOrderId: command.transferOrderNo,
memo: `Tree transfer payment to ${command.sellerAccountSequence}`,
},
});
// ===== 2. 卖方:可用余额增加 =====
const sellerWallet = await tx.walletAccount.findUnique({
where: { accountSequence: command.sellerAccountSequence },
});
if (!sellerWallet) {
throw new WalletNotFoundError(`seller accountSequence: ${command.sellerAccountSequence}`);
}
const sellerAvailable = new Decimal(sellerWallet.usdtAvailable.toString());
const newSellerAvailable = sellerAvailable.plus(sellerAmount);
const sellerUpdateResult = await tx.walletAccount.updateMany({
where: { id: sellerWallet.id, version: sellerWallet.version },
data: {
usdtAvailable: newSellerAvailable,
version: sellerWallet.version + 1,
updatedAt: new Date(),
},
});
if (sellerUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for seller wallet ${sellerWallet.id}`);
}
affectedUserIds.push(sellerWallet.userId);
// 卖方入账流水
await tx.ledgerEntry.create({
data: {
accountSequence: sellerWallet.accountSequence,
userId: sellerWallet.userId,
entryType: LedgerEntryType.TRANSFER_IN,
amount: sellerAmount,
assetType: 'USDT',
balanceAfter: newSellerAvailable,
refOrderId: command.transferOrderNo,
memo: `Tree transfer received from ${command.buyerAccountSequence}`,
},
});
// ===== 3. 手续费归集账户 =====
if (feeAmount.greaterThan(0)) {
const feeAccountSequence = 'S0000000001'; // 总部账户
const feeWallet = await tx.walletAccount.findUnique({
where: { accountSequence: feeAccountSequence },
});
if (feeWallet) {
const feeAvailable = new Decimal(feeWallet.usdtAvailable.toString());
const newFeeAvailable = feeAvailable.plus(feeAmount);
const feeUpdateResult = await tx.walletAccount.updateMany({
where: { id: feeWallet.id, version: feeWallet.version },
data: {
usdtAvailable: newFeeAvailable,
version: feeWallet.version + 1,
updatedAt: new Date(),
},
});
if (feeUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for fee wallet ${feeWallet.id}`);
}
affectedUserIds.push(feeWallet.userId);
// 手续费流水
await tx.ledgerEntry.create({
data: {
accountSequence: feeAccountSequence,
userId: feeWallet.userId,
entryType: LedgerEntryType.FEE_COLLECTION,
amount: feeAmount,
assetType: 'USDT',
balanceAfter: newFeeAvailable,
refOrderId: command.transferOrderNo,
memo: `Transfer platform fee from ${command.buyerAccountSequence}`,
},
});
} else {
this.logger.warn(`[settleTransferPayment] Fee account ${feeAccountSequence} not found, skipping fee collection`);
}
}
this.logger.log(`[settleTransferPayment] 结算成功: buyer frozen -${totalDeduct}, seller +${sellerAmount}, fee +${feeAmount}`);
});
// 事务成功后,使所有受影响钱包的缓存失效
for (const userId of affectedUserIds) {
await this.walletCacheService.invalidateWallet(userId);
}
this.logger.log(`[settleTransferPayment] 转让 ${command.transferOrderNo} 结算完成`);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,337 @@
'use client';
/**
* [2026-02-19]
*
*
* - ///
* - Saga 线8
* -
* -
*
* === ===
* transfers/
*/
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatDateTime } from '@/utils/formatters';
import { useTransferDetail, useForceCancel } from '@/hooks';
import type { TransferOrderStatus } from '@/services/transferService';
import styles from '../transfers.module.scss';
// Saga 步骤顺序
const SAGA_STEPS: { status: TransferOrderStatus; label: string }[] = [
{ status: 'PENDING', label: '待卖方确认' },
{ status: 'SELLER_CONFIRMED', label: '卖方已确认' },
{ status: 'PAYMENT_FROZEN', label: '买方资金冻结' },
{ status: 'TREES_LOCKED', label: '卖方树锁定' },
{ status: 'OWNERSHIP_TRANSFERRED', label: '所有权变更' },
{ status: 'CONTRIBUTION_ADJUSTED', label: '算力调整' },
{ status: 'STATS_UPDATED', label: '团队统计更新' },
{ status: 'PAYMENT_SETTLED', label: '资金结算' },
{ status: 'COMPLETED', label: '转让完成' },
];
// 状态标签映射
const STATUS_LABEL: Record<string, string> = {
PENDING: '待确认',
SELLER_CONFIRMED: '卖方已确认',
PAYMENT_FROZEN: '资金已冻结',
TREES_LOCKED: '树已锁定',
OWNERSHIP_TRANSFERRED: '所有权变更',
CONTRIBUTION_ADJUSTED: '算力调整',
STATS_UPDATED: '统计更新',
PAYMENT_SETTLED: '资金结算',
COMPLETED: '已完成',
CANCELLED: '已取消',
FAILED: '失败',
ROLLING_BACK: '补偿中',
ROLLED_BACK: '已回滚',
};
// 终态判断
const TERMINAL_STATUSES = ['COMPLETED', 'CANCELLED', 'ROLLED_BACK'];
/**
*
*/
export default function TransferDetailPage() {
const params = useParams();
const router = useRouter();
const transferOrderNo = params.transferOrderNo as string;
const { data: order, isLoading, error, refetch } = useTransferDetail(transferOrderNo);
const forceCancel = useForceCancel();
const [cancelReason, setCancelReason] = useState('');
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const handleForceCancel = () => {
if (!cancelReason.trim()) return;
forceCancel.mutate(
{ transferOrderNo, reason: cancelReason },
{
onSuccess: () => {
setShowCancelConfirm(false);
setCancelReason('');
refetch();
},
},
);
};
if (isLoading) {
return (
<PageContainer title="转让详情">
<div className={styles.transfers__loading}>...</div>
</PageContainer>
);
}
if (error || !order) {
return (
<PageContainer title="转让详情">
<div className={styles.transfers__error}>
<span>{error?.message || '订单不存在'}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}></Button>
</div>
</PageContainer>
);
}
const isTerminal = TERMINAL_STATUSES.includes(order.status);
const currentStepIndex = SAGA_STEPS.findIndex((s) => s.status === order.status);
const isFailed = order.status === 'FAILED' || order.status === 'ROLLING_BACK' || order.status === 'ROLLED_BACK';
return (
<PageContainer title={`转让详情 - ${transferOrderNo}`}>
<div className={styles.transfers}>
{/* 返回按钮 */}
<button className={styles.transfers__backBtn} onClick={() => router.push('/transfers')}>
&larr;
</button>
{/* 页面标题 */}
<div className={styles.transfers__header}>
<h1 className={styles.transfers__title}>
{order.transferOrderNo}
<span
className={cn(
styles.transfers__status,
styles[`transfers__status--${statusToStyle(order.status)}`],
)}
style={{ marginLeft: 12, fontSize: 14, verticalAlign: 'middle' }}
>
{STATUS_LABEL[order.status] || order.status}
</span>
</h1>
</div>
{/* 基本信息卡片 */}
<div className={styles.transfers__card}>
<div className={styles.transfers__detailGrid}>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{order.sellerAccountSequence}</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{order.buyerAccountSequence}</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{order.treeCount} </span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{Number(order.pricePerTree).toLocaleString()} USDT</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{Number(order.totalPrice).toLocaleString()} USDT</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{(Number(order.platformFeeRate) * 100).toFixed(1)}%</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{Number(order.platformFeeAmount).toLocaleString()} USDT</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{Number(order.sellerReceiveAmount).toLocaleString()} USDT</span>
</div>
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{formatDateTime(order.createdAt)}</span>
</div>
{order.completedAt && (
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{formatDateTime(order.completedAt)}</span>
</div>
)}
{order.cancelledAt && (
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{formatDateTime(order.cancelledAt)}</span>
</div>
)}
{order.cancelReason && (
<div className={styles.transfers__detailItem}>
<span className={styles.transfers__detailLabel}></span>
<span className={styles.transfers__detailValue}>{order.cancelReason}</span>
</div>
)}
</div>
</div>
{/* Saga 进度时间线 */}
<div className={styles.transfers__section}>
<h2 className={styles.transfers__sectionTitle}>Saga </h2>
<div className={styles.transfers__card}>
<div className={styles.transfers__timeline}>
{SAGA_STEPS.map((step, idx) => {
let dotStyle = '';
if (isFailed) {
dotStyle = idx <= currentStepIndex ? 'completed' : '';
if (idx === currentStepIndex) dotStyle = 'failed';
} else if (idx < currentStepIndex || (isTerminal && idx <= currentStepIndex)) {
dotStyle = 'completed';
} else if (idx === currentStepIndex) {
dotStyle = 'current';
}
// 从状态日志中查找对应时间
const logEntry = order.statusLogs?.find((l) => l.toStatus === step.status);
return (
<div key={step.status} className={styles.transfers__timelineStep}>
<div
className={cn(
styles.transfers__timelineDot,
dotStyle && styles[`transfers__timelineDot--${dotStyle}`],
)}
>
{dotStyle === 'completed' ? '\u2713' : dotStyle === 'failed' ? '\u2717' : ''}
</div>
<div className={styles.transfers__timelineContent}>
<div className={styles.transfers__timelineLabel}>{step.label}</div>
{logEntry && (
<div className={styles.transfers__timelineTime}>
{formatDateTime(logEntry.createdAt)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 状态变更日志 */}
{order.statusLogs && order.statusLogs.length > 0 && (
<div className={styles.transfers__section}>
<h2 className={styles.transfers__sectionTitle}></h2>
<div className={styles.transfers__card}>
<table className={styles.transfers__table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{order.statusLogs.map((log) => (
<tr key={log.id}>
<td>{STATUS_LABEL[log.fromStatus] || log.fromStatus}</td>
<td>{STATUS_LABEL[log.toStatus] || log.toStatus}</td>
<td>{log.reason || '-'}</td>
<td>{formatDateTime(log.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 强制取消操作 */}
{!isTerminal && (
<div className={styles.transfers__actions}>
{!showCancelConfirm ? (
<button
className={styles.transfers__cancelBtn}
onClick={() => setShowCancelConfirm(true)}
>
</button>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<input
type="text"
placeholder="取消原因(必填)"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #ff4d4f',
borderRadius: 8,
fontSize: 14,
width: 280,
}}
/>
<button
className={styles.transfers__cancelBtn}
onClick={handleForceCancel}
disabled={!cancelReason.trim() || forceCancel.isPending}
>
{forceCancel.isPending ? '取消中...' : '确认取消'}
</button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowCancelConfirm(false);
setCancelReason('');
}}
>
</Button>
</div>
)}
</div>
)}
</div>
</PageContainer>
);
}
/**
*
*/
function statusToStyle(status: string): string {
const map: Record<string, string> = {
PENDING: 'pending',
SELLER_CONFIRMED: 'sellerConfirmed',
PAYMENT_FROZEN: 'paymentFrozen',
TREES_LOCKED: 'treesLocked',
OWNERSHIP_TRANSFERRED: 'ownershipTransferred',
CONTRIBUTION_ADJUSTED: 'contributionAdjusted',
STATS_UPDATED: 'statsUpdated',
PAYMENT_SETTLED: 'paymentSettled',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
FAILED: 'failed',
ROLLING_BACK: 'rollingBack',
ROLLED_BACK: 'rolledBack',
};
return map[status] || '';
}

View File

@ -0,0 +1,266 @@
'use client';
/**
* [2026-02-19]
*
*
* - /////
* - + +
* -
* - 13 Saga badge
*
* === ===
* transfers/
*/
import { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatDateTime } from '@/utils/formatters';
import { useTransferStats, useTransferList } from '@/hooks';
import type { TransferOrder, TransferOrderStatus } from '@/services/transferService';
import styles from './transfers.module.scss';
// 状态显示映射13 种)
const STATUS_MAP: Record<TransferOrderStatus, { label: string; style: string }> = {
PENDING: { label: '待确认', style: 'pending' },
SELLER_CONFIRMED: { label: '卖方已确认', style: 'sellerConfirmed' },
PAYMENT_FROZEN: { label: '资金已冻结', style: 'paymentFrozen' },
TREES_LOCKED: { label: '树已锁定', style: 'treesLocked' },
OWNERSHIP_TRANSFERRED: { label: '所有权变更', style: 'ownershipTransferred' },
CONTRIBUTION_ADJUSTED: { label: '算力调整', style: 'contributionAdjusted' },
STATS_UPDATED: { label: '统计更新', style: 'statsUpdated' },
PAYMENT_SETTLED: { label: '资金结算', style: 'paymentSettled' },
COMPLETED: { label: '已完成', style: 'completed' },
CANCELLED: { label: '已取消', style: 'cancelled' },
FAILED: { label: '失败', style: 'failed' },
ROLLING_BACK: { label: '补偿中', style: 'rollingBack' },
ROLLED_BACK: { label: '已回滚', style: 'rolledBack' },
};
// 状态筛选选项
const STATUS_OPTIONS: { value: TransferOrderStatus | ''; label: string }[] = [
{ value: '', label: '全部状态' },
{ value: 'PENDING', label: '待确认' },
{ value: 'SELLER_CONFIRMED', label: '卖方已确认' },
{ value: 'PAYMENT_FROZEN', label: '资金已冻结' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'FAILED', label: '失败' },
{ value: 'ROLLING_BACK', label: '补偿中' },
{ value: 'ROLLED_BACK', label: '已回滚' },
];
/**
*
*/
export default function TransfersPage() {
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState<TransferOrderStatus | ''>('');
const [page, setPage] = useState(1);
const pageSize = 20;
const { data: stats, isLoading: statsLoading } = useTransferStats();
const listQuery = useTransferList({
page,
pageSize,
keyword: keyword || undefined,
status: statusFilter || undefined,
});
const formatNumber = (n: number) => n.toLocaleString('en-US');
const totalPages = listQuery.data ? Math.ceil(listQuery.data.total / pageSize) : 0;
return (
<PageContainer title="转让管理">
<div className={styles.transfers}>
{/* 页面标题 */}
<div className={styles.transfers__header}>
<h1 className={styles.transfers__title}></h1>
</div>
{/* 统计卡片 */}
<div className={styles.transfers__statsGrid}>
<div className={styles.transfers__statCard}>
<div className={styles.transfers__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalOrders ?? 0)}
</div>
<div className={styles.transfers__statLabel}></div>
</div>
<div className={styles.transfers__statCard}>
<div className={styles.transfers__statValue}>
{statsLoading ? '-' : formatNumber(stats?.pendingOrders ?? 0)}
</div>
<div className={styles.transfers__statLabel}></div>
</div>
<div className={styles.transfers__statCard}>
<div className={styles.transfers__statValue}>
{statsLoading ? '-' : formatNumber(stats?.completedOrders ?? 0)}
</div>
<div className={styles.transfers__statLabel}></div>
</div>
<div className={styles.transfers__statCard}>
<div className={styles.transfers__statValue}>
{statsLoading ? '-' : formatNumber(stats?.cancelledOrders ?? 0)}
</div>
<div className={styles.transfers__statLabel}></div>
</div>
<div className={styles.transfers__statCard}>
<div className={styles.transfers__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalVolume ?? 0)}
</div>
<div className={styles.transfers__statLabel}> (USDT)</div>
</div>
</div>
{/* 数据区 */}
<div className={styles.transfers__card}>
{/* 工具栏:搜索 + 筛选 + 刷新 */}
<div className={styles.transfers__toolbar}>
<div className={styles.transfers__toolbarLeft}>
<div className={styles.transfers__search}>
<input
type="text"
placeholder="搜索订单号或用户账号..."
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
</div>
<div className={styles.transfers__filter}>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as TransferOrderStatus | '');
setPage(1);
}}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => listQuery.refetch()}
>
</Button>
</div>
{/* 表格 */}
<TransfersTable
data={listQuery.data?.items ?? []}
loading={listQuery.isLoading}
error={listQuery.error}
onRetry={() => listQuery.refetch()}
/>
{/* 分页 */}
{totalPages > 1 && (
<div className={styles.transfers__pagination}>
<button
className={styles.transfers__pageBtn}
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
</button>
<span className={styles.transfers__pageInfo}>
{page} / {totalPages}
</span>
<button
className={styles.transfers__pageBtn}
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
</button>
</div>
)}
</div>
</div>
</PageContainer>
);
}
// ============================================
// 子组件:转让订单表格
// ============================================
function TransfersTable({
data,
loading,
error,
onRetry,
}: {
data: TransferOrder[];
loading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (loading) return <div className={styles.transfers__loading}>...</div>;
if (error) {
return (
<div className={styles.transfers__error}>
<span>{error.message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={onRetry}></Button>
</div>
);
}
if (data.length === 0) return <div className={styles.transfers__empty}></div>;
return (
<table className={styles.transfers__table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th> (USDT)</th>
<th> (USDT)</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((order) => {
const status = STATUS_MAP[order.status] ?? { label: order.status, style: '' };
return (
<tr key={order.id}>
<td>
<Link
href={`/transfers/${order.transferOrderNo}`}
className={styles.transfers__orderLink}
>
{order.transferOrderNo}
</Link>
</td>
<td>{order.sellerAccountSequence}</td>
<td>{order.buyerAccountSequence}</td>
<td>{order.treeCount}</td>
<td>{Number(order.pricePerTree).toLocaleString()}</td>
<td>{Number(order.totalPrice).toLocaleString()}</td>
<td>
<span className={cn(styles.transfers__status, styles[`transfers__status--${status.style}`])}>
{status.label}
</span>
</td>
<td>{formatDateTime(order.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
);
}

View File

@ -0,0 +1,453 @@
/**
* [2026-02-19] 树转让管理页面样式纯新增
*
* 包含统计卡片状态筛选数据表格详情页Saga 进度时间线
* 风格与预种管理页面一致
*
* 回滚方式删除此文件
*/
@use '@/styles/variables' as *;
.transfers {
display: flex;
flex-direction: column;
gap: 24px;
// 页面标题
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__title {
font-size: 24px;
font-weight: 600;
color: $text-primary;
}
// 统计卡片网格
&__statsGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
@media (max-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__statCard {
background: $card-background;
border-radius: 12px;
padding: 20px;
box-shadow: $shadow-base;
text-align: center;
}
&__statValue {
font-size: 28px;
font-weight: 700;
color: $text-primary;
}
&__statLabel {
font-size: 13px;
color: $text-secondary;
margin-top: 4px;
}
// 数据卡片容器
&__card {
background: $card-background;
border-radius: 12px;
padding: 24px;
box-shadow: $shadow-base;
}
&__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
&__toolbarLeft {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
&__search {
input {
width: 260px;
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
outline: none;
&:focus {
border-color: #d4af37;
}
}
}
&__filter {
select {
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
outline: none;
background: white;
cursor: pointer;
&:focus {
border-color: #d4af37;
}
}
}
// 表格
&__table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid $border-color;
font-size: 14px;
}
th {
font-weight: 600;
color: $text-secondary;
background: #fafafa;
}
td {
color: $text-primary;
}
tr:hover td {
background: #fafafa;
}
}
&__orderLink {
color: #d4af37;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
// 状态标签13 种状态
&__status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
// 进行中状态
&--pending {
background: #fff7e6;
color: #d48806;
}
&--sellerConfirmed {
background: #e6f7ff;
color: #096dd9;
}
&--paymentFrozen {
background: #f0f5ff;
color: #2f54eb;
}
&--treesLocked {
background: #f9f0ff;
color: #722ed1;
}
&--ownershipTransferred {
background: #fff0f6;
color: #c41d7f;
}
&--contributionAdjusted {
background: #fcffe6;
color: #7cb305;
}
&--statsUpdated {
background: #e6fffb;
color: #08979c;
}
&--paymentSettled {
background: #f6ffed;
color: #389e0d;
}
// 终态
&--completed {
background: #f6ffed;
color: #52c41a;
}
&--cancelled {
background: #f5f5f5;
color: #8c8c8c;
}
&--failed {
background: #fff1f0;
color: #cf1322;
}
&--rollingBack {
background: #fff2e8;
color: #d4380d;
}
&--rolledBack {
background: #f5f5f5;
color: #595959;
}
}
// 加载 / / 错误 状态
&__loading,
&__empty,
&__error {
text-align: center;
padding: 48px 24px;
color: $text-secondary;
font-size: 14px;
}
&__error {
color: #ff4d4f;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
// 分页
&__pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
gap: 8px;
align-items: center;
}
&__pageBtn {
padding: 6px 12px;
border: 1px solid $border-color;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 13px;
&:hover {
border-color: #d4af37;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__pageInfo {
font-size: 13px;
color: $text-secondary;
}
// === 详情页样式 ===
&__backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid $border-color;
border-radius: 8px;
background: white;
cursor: pointer;
font-size: 14px;
color: $text-secondary;
&:hover {
border-color: #d4af37;
color: $text-primary;
}
}
&__detailGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__detailItem {
display: flex;
flex-direction: column;
gap: 4px;
}
&__detailLabel {
font-size: 12px;
color: $text-secondary;
}
&__detailValue {
font-size: 15px;
font-weight: 500;
color: $text-primary;
}
&__section {
margin-top: 24px;
}
&__sectionTitle {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 16px;
}
// Saga 进度时间线
&__timeline {
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 24px;
}
&__timelineStep {
display: flex;
align-items: flex-start;
gap: 12px;
position: relative;
padding-bottom: 24px;
&:last-child {
padding-bottom: 0;
}
// 竖线连接
&::before {
content: '';
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 2px;
background: $border-color;
}
&:last-child::before {
display: none;
}
}
&__timelineDot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid $border-color;
background: white;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
z-index: 1;
&--completed {
border-color: #52c41a;
background: #52c41a;
color: white;
}
&--current {
border-color: #d4af37;
background: #fff7e6;
color: #d4af37;
}
&--failed {
border-color: #ff4d4f;
background: #ff4d4f;
color: white;
}
}
&__timelineContent {
flex: 1;
min-width: 0;
}
&__timelineLabel {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
&__timelineTime {
font-size: 12px;
color: $text-secondary;
margin-top: 2px;
}
// 操作按钮区
&__actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
&__cancelBtn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid #ff4d4f;
background: white;
color: #ff4d4f;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #fff1f0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@ -37,6 +37,8 @@ const topMenuItems: MenuItem[] = [
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
// [2026-02-17] 新增预种计划管理3171 USDT/份预种开关 + 订单/持仓/合并查询)
{ key: 'pre-planting', icon: '/images/Container3.svg', label: '预种管理', path: '/pre-planting' },
// [2026-02-19] 纯新增:树转让管理
{ key: 'transfers', icon: '/images/Container5.svg', label: '转让管理', path: '/transfers' },
{ key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
];

View File

@ -7,3 +7,5 @@ export * from './useAuthorizations';
export * from './useSystemWithdrawal';
// [2026-02-17] 预种计划管理
export * from './usePrePlanting';
// [2026-02-19] 树转让管理
export * from './useTransfers';

View File

@ -0,0 +1,80 @@
/**
* [2026-02-19] React Query Hooks
*
* admin-web hooks
* React Query Query key factory + hooks
*
* === ===
* hooks/index.ts export
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
transferService,
type TransferListParams,
} from '@/services/transferService';
// ============================================
// Query Key Factory
// ============================================
export const transferKeys = {
all: ['transfers'] as const,
stats: () => [...transferKeys.all, 'stats'] as const,
list: (params: TransferListParams) =>
[...transferKeys.all, 'list', params] as const,
detail: (transferOrderNo: string) =>
[...transferKeys.all, 'detail', transferOrderNo] as const,
};
// ============================================
// Query Hooks
// ============================================
/** 获取转让统计汇总 */
export function useTransferStats() {
return useQuery({
queryKey: transferKeys.stats(),
queryFn: () => transferService.getStats(),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/** 获取转让订单列表 */
export function useTransferList(params: TransferListParams = {}) {
return useQuery({
queryKey: transferKeys.list(params),
queryFn: () => transferService.getList(params),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/** 获取转让订单详情 */
export function useTransferDetail(transferOrderNo: string) {
return useQuery({
queryKey: transferKeys.detail(transferOrderNo),
queryFn: () => transferService.getDetail(transferOrderNo),
enabled: !!transferOrderNo,
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
// ============================================
// Mutation Hooks
// ============================================
/** 强制取消转让订单 */
export function useForceCancel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ transferOrderNo, reason }: { transferOrderNo: string; reason: string }) =>
transferService.forceCancel(transferOrderNo, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: transferKeys.all });
},
});
}

Some files were not shown because too many files have changed in this diff Show More