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:
parent
765a4f41d3
commit
b3a3652f21
|
|
@ -207,6 +207,9 @@ model ContributionRecord {
|
||||||
isExpired Boolean @default(false) @map("is_expired")
|
isExpired Boolean @default(false) @map("is_expired")
|
||||||
expiredAt DateTime? @map("expired_at")
|
expiredAt DateTime? @map("expired_at")
|
||||||
|
|
||||||
|
// ========== 转让关联(纯新增,可空字段)==========
|
||||||
|
transferOrderNo String? @map("transfer_order_no") @db.VarChar(50) // 关联转让订单号(仅转让产生的记录有值)
|
||||||
|
|
||||||
// ========== 备注 ==========
|
// ========== 备注 ==========
|
||||||
remark String? @map("remark") @db.VarChar(500) // 备注说明
|
remark String? @map("remark") @db.VarChar(500) // 备注说明
|
||||||
|
|
||||||
|
|
@ -220,6 +223,7 @@ model ContributionRecord {
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([expireDate])
|
@@index([expireDate])
|
||||||
@@index([isExpired])
|
@@index([isExpired])
|
||||||
|
@@index([transferOrderNo])
|
||||||
@@map("contribution_records")
|
@@map("contribution_records")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,6 +283,9 @@ model UnallocatedContribution {
|
||||||
amount Decimal @map("amount") @db.Decimal(30, 10)
|
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||||
reason String? @db.VarChar(200) // 未分配原因
|
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
|
status String @default("PENDING") @map("status") @db.VarChar(20) // PENDING / ALLOCATED_TO_USER / ALLOCATED_TO_HQ
|
||||||
allocatedAt DateTime? @map("allocated_at")
|
allocatedAt DateTime? @map("allocated_at")
|
||||||
|
|
@ -294,6 +301,7 @@ model UnallocatedContribution {
|
||||||
@@index([wouldBeAccountSequence])
|
@@index([wouldBeAccountSequence])
|
||||||
@@index([unallocType])
|
@@index([unallocType])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([transferOrderNo])
|
||||||
@@map("unallocated_contributions")
|
@@map("unallocated_contributions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +343,9 @@ model SystemContributionRecord {
|
||||||
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
|
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
|
||||||
amount Decimal @map("amount") @db.Decimal(30, 10)
|
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||||
|
|
||||||
|
// 转让关联(纯新增,可空字段)
|
||||||
|
transferOrderNo String? @map("transfer_order_no") @db.VarChar(50)
|
||||||
|
|
||||||
effectiveDate DateTime @map("effective_date") @db.Date
|
effectiveDate DateTime @map("effective_date") @db.Date
|
||||||
expireDate DateTime? @map("expire_date") @db.Date
|
expireDate DateTime? @map("expire_date") @db.Date
|
||||||
isExpired Boolean @default(false) @map("is_expired")
|
isExpired Boolean @default(false) @map("is_expired")
|
||||||
|
|
@ -348,6 +359,7 @@ model SystemContributionRecord {
|
||||||
@@index([sourceAdoptionId])
|
@@index([sourceAdoptionId])
|
||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
@@index([sourceType])
|
@@index([sourceType])
|
||||||
|
@@index([transferOrderNo])
|
||||||
@@map("system_contribution_records")
|
@@map("system_contribution_records")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { UserSyncedHandler } from './event-handlers/user-synced.handler';
|
||||||
import { ReferralSyncedHandler } from './event-handlers/referral-synced.handler';
|
import { ReferralSyncedHandler } from './event-handlers/referral-synced.handler';
|
||||||
import { AdoptionSyncedHandler } from './event-handlers/adoption-synced.handler';
|
import { AdoptionSyncedHandler } from './event-handlers/adoption-synced.handler';
|
||||||
import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
|
import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
|
||||||
|
// [2026-02-19] 纯新增:转让所有权事件处理器
|
||||||
|
import { TransferOwnershipHandler } from './event-handlers/transfer-ownership.handler';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
||||||
|
|
@ -14,6 +16,8 @@ import { ContributionDistributionPublisherService } from './services/contributio
|
||||||
import { ContributionRateService } from './services/contribution-rate.service';
|
import { ContributionRateService } from './services/contribution-rate.service';
|
||||||
import { BonusClaimService } from './services/bonus-claim.service';
|
import { BonusClaimService } from './services/bonus-claim.service';
|
||||||
import { SnapshotService } from './services/snapshot.service';
|
import { SnapshotService } from './services/snapshot.service';
|
||||||
|
// [2026-02-19] 纯新增:转让算力调整服务
|
||||||
|
import { TransferAdjustmentService } from './services/transfer-adjustment.service';
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
||||||
|
|
@ -36,6 +40,7 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
ReferralSyncedHandler,
|
ReferralSyncedHandler,
|
||||||
AdoptionSyncedHandler,
|
AdoptionSyncedHandler,
|
||||||
CDCEventDispatcher,
|
CDCEventDispatcher,
|
||||||
|
TransferOwnershipHandler, // [2026-02-19] 纯新增:转让所有权事件处理器
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
ContributionCalculationService,
|
ContributionCalculationService,
|
||||||
|
|
@ -43,6 +48,7 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
ContributionRateService,
|
ContributionRateService,
|
||||||
BonusClaimService,
|
BonusClaimService,
|
||||||
SnapshotService,
|
SnapshotService,
|
||||||
|
TransferAdjustmentService, // [2026-02-19] 纯新增:转让算力调整服务
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
GetContributionAccountQuery,
|
GetContributionAccountQuery,
|
||||||
|
|
|
||||||
|
|
@ -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 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,16 @@ export enum ContributionSourceType {
|
||||||
PERSONAL = 'PERSONAL', // 来自自己认种
|
PERSONAL = 'PERSONAL', // 来自自己认种
|
||||||
TEAM_LEVEL = 'TEAM_LEVEL', // 来自团队层级 (1-15级)
|
TEAM_LEVEL = 'TEAM_LEVEL', // 来自团队层级 (1-15级)
|
||||||
TEAM_BONUS = 'TEAM_BONUS', // 来自团队加成奖励 (3档)
|
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', // 买方个人加成算力转入
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,7 @@ migrate() {
|
||||||
"admin-service"
|
"admin-service"
|
||||||
"presence-service"
|
"presence-service"
|
||||||
"blockchain-service"
|
"blockchain-service"
|
||||||
|
"transfer-service"
|
||||||
)
|
)
|
||||||
|
|
||||||
for svc in "${services[@]}"; do
|
for svc in "${services[@]}"; do
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ services:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-rwa_user}
|
POSTGRES_USER: ${POSTGRES_USER:-rwa_user}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rwa_secure_password}
|
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:
|
ports:
|
||||||
# 安全加固: 仅绑定 127.0.0.1, 禁止公网直连数据库
|
# 安全加固: 仅绑定 127.0.0.1, 禁止公网直连数据库
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
|
|
@ -782,6 +782,54 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- rwa-network
|
- 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
|
# Volumes
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ model PlantingOrder {
|
||||||
treeCount Int @map("tree_count")
|
treeCount Int @map("tree_count")
|
||||||
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
|
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)
|
selectedProvince String? @map("selected_province") @db.VarChar(10)
|
||||||
selectedCity String? @map("selected_city") @db.VarChar(10)
|
selectedCity String? @map("selected_city") @db.VarChar(10)
|
||||||
|
|
@ -535,3 +538,47 @@ model PrePlantingRewardEntry {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("pre_planting_reward_entries")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
} from './controllers/contract-signing.controller';
|
} from './controllers/contract-signing.controller';
|
||||||
// [2026-02-05] 新增:合同管理内部 API
|
// [2026-02-05] 新增:合同管理内部 API
|
||||||
import { ContractAdminController } from './controllers/contract-admin.controller';
|
import { ContractAdminController } from './controllers/contract-admin.controller';
|
||||||
|
// [纯新增] 转让内部 API
|
||||||
|
import { InternalTransferController } from './controllers/internal-transfer.controller';
|
||||||
import { ApplicationModule } from '../application/application.module';
|
import { ApplicationModule } from '../application/application.module';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
|
@ -23,6 +25,8 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
ContractSigningConfigController,
|
ContractSigningConfigController,
|
||||||
// [2026-02-05] 新增:合同管理内部 API
|
// [2026-02-05] 新增:合同管理内部 API
|
||||||
ContractAdminController,
|
ContractAdminController,
|
||||||
|
// [纯新增] 转让内部 API
|
||||||
|
InternalTransferController,
|
||||||
],
|
],
|
||||||
providers: [JwtAuthGuard],
|
providers: [JwtAuthGuard],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,10 @@ import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
||||||
import { EventAckController } from './kafka/event-ack.controller';
|
import { EventAckController } from './kafka/event-ack.controller';
|
||||||
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
||||||
import { KycVerifiedEventConsumer } from './kafka/kyc-verified-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 { PdfGeneratorService } from './pdf/pdf-generator.service';
|
||||||
import { MinioStorageService } from './storage/minio-storage.service';
|
import { MinioStorageService } from './storage/minio-storage.service';
|
||||||
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
||||||
|
|
@ -36,7 +40,15 @@ import { ContractSigningService } from '../application/services/contract-signing
|
||||||
}),
|
}),
|
||||||
KafkaModule,
|
KafkaModule,
|
||||||
],
|
],
|
||||||
controllers: [EventAckController, ContractSigningEventConsumer, KycVerifiedEventConsumer],
|
controllers: [
|
||||||
|
EventAckController,
|
||||||
|
ContractSigningEventConsumer,
|
||||||
|
KycVerifiedEventConsumer,
|
||||||
|
// [纯新增] 转让事件处理器
|
||||||
|
TransferLockHandler,
|
||||||
|
TransferExecuteHandler,
|
||||||
|
TransferRollbackHandler,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 重试机制)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 微服务
|
// 启动所有 Kafka 微服务
|
||||||
await app.startAllMicroservices();
|
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;
|
const port = process.env.APP_PORT || 3003;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './user-registered.handler';
|
export * from './user-registered.handler';
|
||||||
export * from './planting-created.handler';
|
export * from './planting-created.handler';
|
||||||
export * from './contract-signing.handler';
|
export * from './contract-signing.handler';
|
||||||
|
// [纯新增] 转让事件处理器
|
||||||
|
export * from './planting-transferred.handler';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新计算龙虎榜分值
|
* 重新计算龙虎榜分值
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export class TeamStatisticsUpdatedEvent extends DomainEvent {
|
||||||
public readonly totalTeamCount: number,
|
public readonly totalTeamCount: number,
|
||||||
public readonly directReferralCount: number,
|
public readonly directReferralCount: number,
|
||||||
public readonly leaderboardScore: 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();
|
super();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,20 @@ export interface ITeamStatisticsRepository {
|
||||||
}>,
|
}>,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [纯新增] 批量更新团队统计(转让场景,支持负数 delta)
|
||||||
|
* 与 batchUpdateTeamCounts 区别:负数 delta 时重新扫描所有直推找最大支线
|
||||||
|
*/
|
||||||
|
batchUpdateTeamCountsForTransfer(
|
||||||
|
updates: Array<{
|
||||||
|
userId: bigint;
|
||||||
|
countDelta: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
fromDirectReferralId?: bigint;
|
||||||
|
}>,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建初始团队统计记录
|
* 创建初始团队统计记录
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,25 @@ export class ProvinceCityDistribution {
|
||||||
return new ProvinceCityDistribution(newDist);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某省的总认种量
|
* 获取某省的总认种量
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
async create(userId: bigint, accountSequence: string): Promise<TeamStatistics> {
|
||||||
const created = await this.prisma.teamStatistics.create({
|
const created = await this.prisma.teamStatistics.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
UserRegisteredHandler,
|
UserRegisteredHandler,
|
||||||
PlantingCreatedHandler,
|
PlantingCreatedHandler,
|
||||||
ContractSigningHandler,
|
ContractSigningHandler,
|
||||||
|
// [纯新增] 转让事件处理器
|
||||||
|
PlantingTransferredHandler,
|
||||||
} from '../application';
|
} from '../application';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -17,6 +19,8 @@ import {
|
||||||
UserRegisteredHandler,
|
UserRegisteredHandler,
|
||||||
PlantingCreatedHandler,
|
PlantingCreatedHandler,
|
||||||
ContractSigningHandler,
|
ContractSigningHandler,
|
||||||
|
// [纯新增] 转让事件处理器
|
||||||
|
PlantingTransferredHandler,
|
||||||
],
|
],
|
||||||
exports: [ReferralService, TeamStatisticsService],
|
exports: [ReferralService, TeamStatisticsService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ EOSQL
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create all required databases
|
# 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"
|
create_database "$db"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './api.module';
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './application.module';
|
||||||
|
export * from './services/transfer-application.service';
|
||||||
|
export * from './services/saga-orchestrator.service';
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
}));
|
||||||
|
|
@ -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',
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import appConfig from './app.config';
|
||||||
|
import jwtConfig from './jwt.config';
|
||||||
|
import externalConfig from './external.config';
|
||||||
|
|
||||||
|
export default [appConfig, jwtConfig, externalConfig];
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('jwt', () => ({
|
||||||
|
secret: process.env.JWT_SECRET || 'default-secret-change-me',
|
||||||
|
}));
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TransferFeeDomainService } from './services/transfer-fee.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [TransferFeeDomainService],
|
||||||
|
exports: [TransferFeeDomainService],
|
||||||
|
})
|
||||||
|
export class DomainModule {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface DomainEvent {
|
||||||
|
type: string;
|
||||||
|
aggregateId: string;
|
||||||
|
aggregateType: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './transfer-order-status.enum';
|
||||||
|
export * from './saga-step.enum';
|
||||||
|
|
@ -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', // 补偿:解冻资金
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
55
backend/services/transfer-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal file
55
backend/services/transfer-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './wallet-service.client';
|
||||||
|
export * from './planting-service.client';
|
||||||
|
export * from './identity-service.client';
|
||||||
83
backend/services/transfer-service/src/infrastructure/external/planting-service.client.ts
vendored
Normal file
83
backend/services/transfer-service/src/infrastructure/external/planting-service.client.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
backend/services/transfer-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal file
84
backend/services/transfer-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './infrastructure.module';
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './global-exception.filter';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './filters';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "prisma", "**/*spec.ts"]
|
||||||
|
}
|
||||||
|
|
@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
import { InternalWalletController } from './controllers/internal-wallet.controller';
|
import { InternalWalletController } from './controllers/internal-wallet.controller';
|
||||||
import { FiatWithdrawalController } from './controllers/fiat-withdrawal.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 { WalletApplicationService, FiatWithdrawalApplicationService, SystemWithdrawalApplicationService } from '@/application/services';
|
||||||
import { DepositConfirmedHandler, PlantingCreatedHandler, UserAccountCreatedHandler } from '@/application/event-handlers';
|
import { DepositConfirmedHandler, PlantingCreatedHandler, UserAccountCreatedHandler } from '@/application/event-handlers';
|
||||||
import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler';
|
import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler';
|
||||||
|
|
@ -37,6 +39,7 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
|
||||||
InternalWalletController,
|
InternalWalletController,
|
||||||
FiatWithdrawalController,
|
FiatWithdrawalController,
|
||||||
SystemWithdrawalController,
|
SystemWithdrawalController,
|
||||||
|
InternalTransferWalletController, // [2026-02-19] 纯新增:转让钱包内部端点
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WalletApplicationService,
|
WalletApplicationService,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 转让冻结资金命令(纯新增)
|
||||||
|
* 用于在树转让流程中冻结买方资金
|
||||||
|
*/
|
||||||
|
export class FreezeForTransferCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly accountSequence: string,
|
||||||
|
public readonly amount: string,
|
||||||
|
public readonly transferOrderNo: string,
|
||||||
|
public readonly reason: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -9,3 +9,7 @@ export * from './settle-rewards.command';
|
||||||
export * from './settle-to-balance.command';
|
export * from './settle-to-balance.command';
|
||||||
export * from './allocate-funds.command';
|
export * from './allocate-funds.command';
|
||||||
export * from './request-withdrawal.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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 转让解冻资金命令(纯新增)
|
||||||
|
* 用于在树转让失败时解冻买方资金
|
||||||
|
*/
|
||||||
|
export class UnfreezeForTransferCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly accountSequence: string,
|
||||||
|
public readonly freezeId: string,
|
||||||
|
public readonly transferOrderNo: string,
|
||||||
|
public readonly reason: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem,
|
ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem,
|
||||||
RequestWithdrawalCommand, UpdateWithdrawalStatusCommand,
|
RequestWithdrawalCommand, UpdateWithdrawalStatusCommand,
|
||||||
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
|
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
|
||||||
|
FreezeForTransferCommand, UnfreezeForTransferCommand, SettleTransferPaymentCommand,
|
||||||
} from '@/application/commands';
|
} from '@/application/commands';
|
||||||
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
||||||
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
|
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
@ -3945,4 +3946,440 @@ export class WalletApplicationService {
|
||||||
|
|
||||||
return results;
|
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
|
|
@ -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')}>
|
||||||
|
← 返回列表
|
||||||
|
</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] || '';
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,8 @@ const topMenuItems: MenuItem[] = [
|
||||||
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
|
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
|
||||||
// [2026-02-17] 新增:预种计划管理(3171 USDT/份预种开关 + 订单/持仓/合并查询)
|
// [2026-02-17] 新增:预种计划管理(3171 USDT/份预种开关 + 订单/持仓/合并查询)
|
||||||
{ key: 'pre-planting', icon: '/images/Container3.svg', label: '预种管理', path: '/pre-planting' },
|
{ 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: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
|
||||||
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
|
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,5 @@ export * from './useAuthorizations';
|
||||||
export * from './useSystemWithdrawal';
|
export * from './useSystemWithdrawal';
|
||||||
// [2026-02-17] 预种计划管理
|
// [2026-02-17] 预种计划管理
|
||||||
export * from './usePrePlanting';
|
export * from './usePrePlanting';
|
||||||
|
// [2026-02-19] 树转让管理
|
||||||
|
export * from './useTransfers';
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue