diff --git a/backend/services/planting-service/DEVELOPMENT_GUIDE.md b/backend/services/planting-service/DEVELOPMENT_GUIDE.md new file mode 100644 index 00000000..28681479 --- /dev/null +++ b/backend/services/planting-service/DEVELOPMENT_GUIDE.md @@ -0,0 +1,1294 @@ +# Planting Service 开发指导 + +## 项目概述 + +Planting Service 是 RWA 榴莲女皇平台的认种微服务,负责管理认种订单生命周期、省市选择确认、资金分配规则计算、底池注入批次管理和挖矿资格管理。 + +## 技术栈 + +- **框架**: NestJS +- **数据库**: PostgreSQL + Prisma ORM +- **架构**: DDD + Hexagonal Architecture (六边形架构) +- **语言**: TypeScript + +## 架构参考 + +请参考 `identity-service` 的架构模式,保持一致性: + +``` +planting-service/ +├── prisma/ +│ └── schema.prisma # 数据库模型 +├── src/ +│ ├── api/ # Presentation Layer (API层) +│ │ ├── controllers/ +│ │ │ ├── planting-order.controller.ts +│ │ │ ├── planting-position.controller.ts +│ │ │ └── pool-batch.controller.ts +│ │ ├── dto/ +│ │ │ ├── create-planting-order.dto.ts +│ │ │ ├── select-province-city.dto.ts +│ │ │ ├── planting-order.dto.ts +│ │ │ └── planting-position.dto.ts +│ │ └── api.module.ts +│ │ +│ ├── application/ # Application Layer (应用层) +│ │ ├── commands/ +│ │ │ ├── create-planting-order.command.ts +│ │ │ ├── select-province-city.command.ts +│ │ │ ├── confirm-province-city.command.ts +│ │ │ ├── pay-planting-order.command.ts +│ │ │ ├── inject-pool.command.ts +│ │ │ └── enable-mining.command.ts +│ │ ├── queries/ +│ │ │ ├── get-user-planting-orders.query.ts +│ │ │ ├── get-user-position.query.ts +│ │ │ └── get-pool-batch-info.query.ts +│ │ ├── handlers/ +│ │ │ ├── create-planting-order.handler.ts +│ │ │ ├── select-province-city.handler.ts +│ │ │ ├── confirm-province-city.handler.ts +│ │ │ └── pay-planting-order.handler.ts +│ │ └── services/ +│ │ └── planting-application.service.ts +│ │ +│ ├── domain/ # Domain Layer (领域层) +│ │ ├── aggregates/ +│ │ │ ├── planting-order.aggregate.ts +│ │ │ ├── planting-position.aggregate.ts +│ │ │ └── pool-injection-batch.aggregate.ts +│ │ ├── value-objects/ +│ │ │ ├── order-id.vo.ts +│ │ │ ├── order-no.vo.ts +│ │ │ ├── tree-count.vo.ts +│ │ │ ├── money.vo.ts +│ │ │ ├── province-code.vo.ts +│ │ │ ├── city-code.vo.ts +│ │ │ ├── province-city-selection.vo.ts +│ │ │ ├── fund-allocation.vo.ts +│ │ │ ├── pool-injection-info.vo.ts +│ │ │ ├── planting-order-status.enum.ts +│ │ │ ├── fund-allocation-target-type.enum.ts +│ │ │ └── batch-status.enum.ts +│ │ ├── events/ +│ │ │ ├── planting-order-created.event.ts +│ │ │ ├── province-city-confirmed.event.ts +│ │ │ ├── planting-order-paid.event.ts +│ │ │ ├── funds-allocated.event.ts +│ │ │ ├── pool-injected.event.ts +│ │ │ └── mining-enabled.event.ts +│ │ ├── repositories/ +│ │ │ ├── planting-order.repository.interface.ts +│ │ │ ├── planting-position.repository.interface.ts +│ │ │ └── pool-injection-batch.repository.interface.ts +│ │ └── services/ +│ │ ├── fund-allocation.service.ts +│ │ └── pool-injection-scheduler.service.ts +│ │ +│ ├── infrastructure/ # Infrastructure Layer (基础设施层) +│ │ ├── persistence/ +│ │ │ ├── mappers/ +│ │ │ │ ├── planting-order.mapper.ts +│ │ │ │ ├── planting-position.mapper.ts +│ │ │ │ └── pool-injection-batch.mapper.ts +│ │ │ └── repositories/ +│ │ │ ├── planting-order.repository.impl.ts +│ │ │ ├── planting-position.repository.impl.ts +│ │ │ └── pool-injection-batch.repository.impl.ts +│ │ ├── external/ +│ │ │ ├── wallet-service.client.ts +│ │ │ ├── referral-service.client.ts +│ │ │ └── blockchain-service.client.ts +│ │ └── infrastructure.module.ts +│ │ +│ ├── app.module.ts +│ └── main.ts +├── .env.development +├── .env.example +├── package.json +└── tsconfig.json +``` + +--- + +## 第一阶段:项目初始化 + +### 1.1 创建 NestJS 项目 + +```bash +cd backend/services +npx @nestjs/cli new planting-service --skip-git --package-manager npm +cd planting-service +``` + +### 1.2 安装依赖 + +```bash +npm install @nestjs/config @prisma/client class-validator class-transformer uuid +npm install -D prisma @types/uuid +``` + +### 1.3 配置环境变量 + +创建 `.env.development`: +```env +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public" +NODE_ENV=development +PORT=3003 + +# 外部服务 +WALLET_SERVICE_URL=http://localhost:3002 +IDENTITY_SERVICE_URL=http://localhost:3001 +``` + +--- + +## 第二阶段:数据库设计 (Prisma Schema) + +### 2.1 创建 prisma/schema.prisma + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// 认种订单表 (状态表) +// ============================================ +model PlantingOrder { + id BigInt @id @default(autoincrement()) @map("order_id") + orderNo String @unique @map("order_no") @db.VarChar(50) + userId BigInt @map("user_id") + + // 认种信息 + treeCount Int @map("tree_count") + totalAmount Decimal @map("total_amount") @db.Decimal(20, 8) + + // 省市选择 (不可修改) + selectedProvince String? @map("selected_province") @db.VarChar(10) + selectedCity String? @map("selected_city") @db.VarChar(10) + provinceCitySelectedAt DateTime? @map("province_city_selected_at") + provinceCityConfirmedAt DateTime? @map("province_city_confirmed_at") + + // 订单状态 + status String @default("CREATED") @map("status") @db.VarChar(30) + + // 底池信息 + poolInjectionBatchId BigInt? @map("pool_injection_batch_id") + poolInjectionScheduledTime DateTime? @map("pool_injection_scheduled_time") + poolInjectionActualTime DateTime? @map("pool_injection_actual_time") + poolInjectionTxHash String? @map("pool_injection_tx_hash") @db.VarChar(100) + + // 挖矿 + miningEnabledAt DateTime? @map("mining_enabled_at") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + paidAt DateTime? @map("paid_at") + fundAllocatedAt DateTime? @map("fund_allocated_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + fundAllocations FundAllocation[] + batch PoolInjectionBatch? @relation(fields: [poolInjectionBatchId], references: [id]) + + @@map("planting_orders") + @@index([userId]) + @@index([orderNo]) + @@index([status]) + @@index([poolInjectionBatchId]) + @@index([selectedProvince, selectedCity]) + @@index([createdAt]) + @@index([paidAt]) +} + +// ============================================ +// 资金分配明细表 (行为表, append-only) +// ============================================ +model FundAllocation { + id BigInt @id @default(autoincrement()) @map("allocation_id") + orderId BigInt @map("order_id") + + // 分配信息 + targetType String @map("target_type") @db.VarChar(50) + amount Decimal @map("amount") @db.Decimal(20, 8) + targetAccountId String? @map("target_account_id") @db.VarChar(100) + + // 元数据 + metadata Json? @map("metadata") + + createdAt DateTime @default(now()) @map("created_at") + + // 关联 + order PlantingOrder @relation(fields: [orderId], references: [id]) + + @@map("fund_allocations") + @@index([orderId]) + @@index([targetType, targetAccountId]) + @@index([createdAt]) +} + +// ============================================ +// 用户持仓表 (状态表) +// ============================================ +model PlantingPosition { + id BigInt @id @default(autoincrement()) @map("position_id") + userId BigInt @unique @map("user_id") + + // 持仓统计 + totalTreeCount Int @default(0) @map("total_tree_count") + effectiveTreeCount Int @default(0) @map("effective_tree_count") + pendingTreeCount Int @default(0) @map("pending_tree_count") + + // 挖矿状态 + firstMiningStartAt DateTime? @map("first_mining_start_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + distributions PositionDistribution[] + + @@map("planting_positions") + @@index([userId]) + @@index([totalTreeCount]) +} + +// ============================================ +// 持仓省市分布表 +// ============================================ +model PositionDistribution { + id BigInt @id @default(autoincrement()) @map("distribution_id") + userId BigInt @map("user_id") + + // 省市信息 + provinceCode String? @map("province_code") @db.VarChar(10) + cityCode String? @map("city_code") @db.VarChar(10) + + // 数量 + treeCount Int @default(0) @map("tree_count") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + position PlantingPosition @relation(fields: [userId], references: [userId]) + + @@unique([userId, provinceCode, cityCode]) + @@map("position_province_city_distribution") + @@index([userId]) + @@index([provinceCode]) + @@index([cityCode]) +} + +// ============================================ +// 底池注入批次表 (状态表) +// ============================================ +model PoolInjectionBatch { + id BigInt @id @default(autoincrement()) @map("batch_id") + batchNo String @unique @map("batch_no") @db.VarChar(50) + + // 批次时间窗口 (5天) + startDate DateTime @map("start_date") @db.Date + endDate DateTime @map("end_date") @db.Date + + // 统计信息 + orderCount Int @default(0) @map("order_count") + totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(20, 8) + + // 注入状态 + status String @default("PENDING") @map("status") @db.VarChar(20) + scheduledInjectionTime DateTime? @map("scheduled_injection_time") + actualInjectionTime DateTime? @map("actual_injection_time") + injectionTxHash String? @map("injection_tx_hash") @db.VarChar(100) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + orders PlantingOrder[] + + @@map("pool_injection_batches") + @@index([batchNo]) + @@index([startDate, endDate]) + @@index([status]) + @@index([scheduledInjectionTime]) +} + +// ============================================ +// 认种事件表 (行为表, append-only) +// ============================================ +model PlantingEvent { + id BigInt @id @default(autoincrement()) @map("event_id") + eventType String @map("event_type") @db.VarChar(50) + + // 聚合根信息 + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + + // 事件数据 + eventData Json @map("event_data") + + // 元数据 + userId BigInt? @map("user_id") + occurredAt DateTime @default(now()) @map("occurred_at") + version Int @default(1) @map("version") + + @@map("planting_events") + @@index([aggregateType, aggregateId]) + @@index([eventType]) + @@index([userId]) + @@index([occurredAt]) +} +``` + +### 2.2 初始化数据库 + +```bash +npx prisma migrate dev --name init +npx prisma generate +``` + +--- + +## 第三阶段:领域层实现 + +### 3.1 值对象 (Value Objects) + +#### 3.1.1 planting-order-status.enum.ts +```typescript +export enum PlantingOrderStatus { + CREATED = 'CREATED', // 已创建 + PROVINCE_CITY_CONFIRMED = 'PROVINCE_CITY_CONFIRMED', // 省市已确认 + PAID = 'PAID', // 已支付 + FUND_ALLOCATED = 'FUND_ALLOCATED', // 资金已分配 + POOL_SCHEDULED = 'POOL_SCHEDULED', // 底池已排期 + POOL_INJECTED = 'POOL_INJECTED', // 底池已注入 + MINING_ENABLED = 'MINING_ENABLED', // 挖矿已开启 + CANCELLED = 'CANCELLED', // 已取消 +} +``` + +#### 3.1.2 fund-allocation-target-type.enum.ts +```typescript +export enum FundAllocationTargetType { + COST_ACCOUNT = 'COST_ACCOUNT', // 400 USDT - 成本账户 + OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 300 USDT - 运营账户 + HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY',// 9 USDT - 总部社区 + REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 500 USDT - 分享权益 + PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 15 USDT - 省区域权益 + PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 20 USDT - 省团队权益 + CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 35 USDT - 市区域权益 + CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 40 USDT - 市团队权益 + COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 80 USDT - 社区权益 + RWAD_POOL = 'RWAD_POOL', // 800 USDT - RWAD底池 +} + +// 每棵树的资金分配规则 (总计 2199 USDT) +export const FUND_ALLOCATION_AMOUNTS: Record = { + [FundAllocationTargetType.COST_ACCOUNT]: 400, + [FundAllocationTargetType.OPERATION_ACCOUNT]: 300, + [FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 9, + [FundAllocationTargetType.REFERRAL_RIGHTS]: 500, + [FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 15, + [FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 20, + [FundAllocationTargetType.CITY_AREA_RIGHTS]: 35, + [FundAllocationTargetType.CITY_TEAM_RIGHTS]: 40, + [FundAllocationTargetType.COMMUNITY_RIGHTS]: 80, + [FundAllocationTargetType.RWAD_POOL]: 800, +}; + +// 验证总额 +const TOTAL = Object.values(FUND_ALLOCATION_AMOUNTS).reduce((a, b) => a + b, 0); +if (TOTAL !== 2199) { + throw new Error(`资金分配配置错误: 总额 ${TOTAL} != 2199`); +} +``` + +#### 3.1.3 batch-status.enum.ts +```typescript +export enum BatchStatus { + PENDING = 'PENDING', // 待注入 (收集订单中) + SCHEDULED = 'SCHEDULED', // 已排期 + INJECTING = 'INJECTING', // 注入中 + INJECTED = 'INJECTED', // 已注入 +} +``` + +#### 3.1.4 tree-count.vo.ts +```typescript +export class TreeCount { + private constructor(public readonly value: number) { + if (value <= 0 || !Number.isInteger(value)) { + throw new Error('认种数量必须是正整数'); + } + } + + static create(value: number): TreeCount { + return new TreeCount(value); + } + + multiply(factor: number): number { + return this.value * factor; + } +} +``` + +#### 3.1.5 province-city-selection.vo.ts +```typescript +export class ProvinceCitySelection { + private constructor( + public readonly provinceCode: string, + public readonly provinceName: string, + public readonly cityCode: string, + public readonly cityName: string, + public readonly selectedAt: Date, + public readonly isConfirmed: boolean, + ) {} + + static create( + provinceCode: string, + provinceName: string, + cityCode: string, + cityName: string, + ): ProvinceCitySelection { + return new ProvinceCitySelection( + provinceCode, + provinceName, + cityCode, + cityName, + new Date(), + false, + ); + } + + confirm(): ProvinceCitySelection { + if (this.isConfirmed) { + throw new Error('省市已确认,不可重复确认'); + } + + return new ProvinceCitySelection( + this.provinceCode, + this.provinceName, + this.cityCode, + this.cityName, + this.selectedAt, + true, + ); + } + + /** + * 检查是否已过5秒确认时间 + */ + canConfirm(): boolean { + const elapsed = Date.now() - this.selectedAt.getTime(); + return elapsed >= 5000; // 5秒 + } +} +``` + +#### 3.1.6 fund-allocation.vo.ts +```typescript +import { FundAllocationTargetType } from './fund-allocation-target-type.enum'; + +export class FundAllocation { + constructor( + public readonly targetType: FundAllocationTargetType, + public readonly amount: number, + public readonly targetAccountId: string | null, + public readonly metadata?: Record, + ) {} + + toDTO() { + return { + targetType: this.targetType, + amount: this.amount, + targetAccountId: this.targetAccountId, + metadata: this.metadata, + }; + } +} +``` + +### 3.2 聚合根 (Aggregates) + +#### 3.2.1 planting-order.aggregate.ts + +```typescript +import { v4 as uuidv4 } from 'uuid'; +import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum'; +import { ProvinceCitySelection } from '../value-objects/province-city-selection.vo'; +import { FundAllocation } from '../value-objects/fund-allocation.vo'; +import { TreeCount } from '../value-objects/tree-count.vo'; + +export class PlantingOrder { + private _id: bigint | null; + private readonly _orderNo: string; + private readonly _userId: bigint; + private readonly _treeCount: TreeCount; + private readonly _totalAmount: number; + private _provinceCitySelection: ProvinceCitySelection | null; + private _status: PlantingOrderStatus; + private _fundAllocations: FundAllocation[]; + private _poolInjectionBatchId: bigint | null; + private _poolInjectionScheduledTime: Date | null; + private _poolInjectionActualTime: Date | null; + private _poolInjectionTxHash: string | null; + private _miningEnabledAt: Date | null; + private readonly _createdAt: Date; + private _paidAt: Date | null; + private _fundAllocatedAt: Date | null; + + // 领域事件 + private _domainEvents: any[] = []; + + private constructor( + orderNo: string, + userId: bigint, + treeCount: TreeCount, + totalAmount: number, + ) { + this._orderNo = orderNo; + this._userId = userId; + this._treeCount = treeCount; + this._totalAmount = totalAmount; + this._status = PlantingOrderStatus.CREATED; + this._provinceCitySelection = null; + this._fundAllocations = []; + this._poolInjectionBatchId = null; + this._poolInjectionScheduledTime = null; + this._poolInjectionActualTime = null; + this._poolInjectionTxHash = null; + this._miningEnabledAt = null; + this._createdAt = new Date(); + this._paidAt = null; + this._fundAllocatedAt = null; + } + + // Getters + get id(): bigint | null { return this._id; } + get orderNo(): string { return this._orderNo; } + get userId(): bigint { return this._userId; } + get treeCount(): TreeCount { return this._treeCount; } + get totalAmount(): number { return this._totalAmount; } + get status(): PlantingOrderStatus { return this._status; } + get provinceCitySelection(): ProvinceCitySelection | null { return this._provinceCitySelection; } + get fundAllocations(): ReadonlyArray { return this._fundAllocations; } + get poolInjectionBatchId(): bigint | null { return this._poolInjectionBatchId; } + get miningEnabledAt(): Date | null { return this._miningEnabledAt; } + get isMiningEnabled(): boolean { return this._miningEnabledAt !== null; } + get domainEvents(): any[] { return this._domainEvents; } + + /** + * 工厂方法:创建认种订单 + */ + static create(userId: bigint, treeCount: number): PlantingOrder { + const PRICE_PER_TREE = 2199; + + if (treeCount <= 0) { + throw new Error('认种数量必须大于0'); + } + + const orderNo = `PLT${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`; + const tree = TreeCount.create(treeCount); + const totalAmount = treeCount * PRICE_PER_TREE; + + const order = new PlantingOrder(orderNo, userId, tree, totalAmount); + + // 发布领域事件 + order._domainEvents.push({ + type: 'PlantingOrderCreated', + data: { + orderNo: order.orderNo, + userId: order.userId.toString(), + treeCount: order.treeCount.value, + totalAmount: order.totalAmount, + }, + }); + + return order; + } + + /** + * 选择省市 (5秒倒计时前) + */ + selectProvinceCity( + provinceCode: string, + provinceName: string, + cityCode: string, + cityName: string, + ): void { + this.ensureStatus(PlantingOrderStatus.CREATED); + + if (this._provinceCitySelection?.isConfirmed) { + throw new Error('省市已确认,不可修改'); + } + + this._provinceCitySelection = ProvinceCitySelection.create( + provinceCode, + provinceName, + cityCode, + cityName, + ); + } + + /** + * 确认省市选择 (5秒后) + */ + confirmProvinceCity(): void { + this.ensureStatus(PlantingOrderStatus.CREATED); + + if (!this._provinceCitySelection) { + throw new Error('请先选择省市'); + } + + if (!this._provinceCitySelection.canConfirm()) { + throw new Error('请等待5秒确认时间'); + } + + this._provinceCitySelection = this._provinceCitySelection.confirm(); + this._status = PlantingOrderStatus.PROVINCE_CITY_CONFIRMED; + + this._domainEvents.push({ + type: 'ProvinceCityConfirmed', + data: { + orderNo: this.orderNo, + userId: this.userId.toString(), + provinceCode: this._provinceCitySelection.provinceCode, + provinceName: this._provinceCitySelection.provinceName, + cityCode: this._provinceCitySelection.cityCode, + cityName: this._provinceCitySelection.cityName, + }, + }); + } + + /** + * 标记为已支付 + */ + markAsPaid(): void { + this.ensureStatus(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED); + + this._status = PlantingOrderStatus.PAID; + this._paidAt = new Date(); + + this._domainEvents.push({ + type: 'PlantingOrderPaid', + data: { + orderNo: this.orderNo, + userId: this.userId.toString(), + treeCount: this.treeCount.value, + totalAmount: this.totalAmount, + provinceCode: this._provinceCitySelection!.provinceCode, + cityCode: this._provinceCitySelection!.cityCode, + }, + }); + } + + /** + * 分配资金 + */ + allocateFunds(allocations: FundAllocation[]): void { + this.ensureStatus(PlantingOrderStatus.PAID); + + // 验证分配总额 + const totalAllocated = allocations.reduce((sum, a) => sum + a.amount, 0); + if (Math.abs(totalAllocated - this.totalAmount) > 0.01) { + throw new Error(`资金分配总额不匹配: 期望 ${this.totalAmount}, 实际 ${totalAllocated}`); + } + + this._fundAllocations = allocations; + this._status = PlantingOrderStatus.FUND_ALLOCATED; + this._fundAllocatedAt = new Date(); + + this._domainEvents.push({ + type: 'FundsAllocated', + data: { + orderNo: this.orderNo, + allocations: allocations.map(a => a.toDTO()), + }, + }); + } + + /** + * 安排底池注入 + */ + schedulePoolInjection(batchId: bigint, scheduledTime: Date): void { + this.ensureStatus(PlantingOrderStatus.FUND_ALLOCATED); + + this._poolInjectionBatchId = batchId; + this._poolInjectionScheduledTime = scheduledTime; + this._status = PlantingOrderStatus.POOL_SCHEDULED; + } + + /** + * 确认底池注入完成 + */ + confirmPoolInjection(txHash: string): void { + this.ensureStatus(PlantingOrderStatus.POOL_SCHEDULED); + + this._poolInjectionActualTime = new Date(); + this._poolInjectionTxHash = txHash; + this._status = PlantingOrderStatus.POOL_INJECTED; + + this._domainEvents.push({ + type: 'PoolInjected', + data: { + orderNo: this.orderNo, + userId: this.userId.toString(), + amount: this.treeCount.value * 800, // 800 USDT/棵 + txHash, + }, + }); + } + + /** + * 开启挖矿 + */ + enableMining(): void { + this.ensureStatus(PlantingOrderStatus.POOL_INJECTED); + + this._miningEnabledAt = new Date(); + this._status = PlantingOrderStatus.MINING_ENABLED; + + this._domainEvents.push({ + type: 'MiningEnabled', + data: { + orderNo: this.orderNo, + userId: this.userId.toString(), + treeCount: this.treeCount.value, + }, + }); + } + + /** + * 清除领域事件 + */ + clearDomainEvents(): void { + this._domainEvents = []; + } + + private ensureStatus(...allowedStatuses: PlantingOrderStatus[]): void { + if (!allowedStatuses.includes(this._status)) { + throw new Error(`订单状态错误: 当前 ${this._status}, 期望 ${allowedStatuses.join(' 或 ')}`); + } + } + + // 用于从数据库重建 + static reconstitute(data: any): PlantingOrder { + const order = new PlantingOrder( + data.orderNo, + data.userId, + TreeCount.create(data.treeCount), + Number(data.totalAmount), + ); + order._id = data.id; + order._status = data.status; + order._paidAt = data.paidAt; + order._fundAllocatedAt = data.fundAllocatedAt; + order._poolInjectionBatchId = data.poolInjectionBatchId; + order._poolInjectionScheduledTime = data.poolInjectionScheduledTime; + order._poolInjectionActualTime = data.poolInjectionActualTime; + order._poolInjectionTxHash = data.poolInjectionTxHash; + order._miningEnabledAt = data.miningEnabledAt; + + if (data.selectedProvince && data.selectedCity) { + order._provinceCitySelection = ProvinceCitySelection.create( + data.selectedProvince, + data.selectedProvinceName || '', + data.selectedCity, + data.selectedCityName || '', + ); + if (data.provinceCityConfirmedAt) { + order._provinceCitySelection = order._provinceCitySelection.confirm(); + } + } + + return order; + } +} +``` + +### 3.3 领域服务 + +#### 3.3.1 fund-allocation.service.ts + +```typescript +import { Injectable } from '@nestjs/common'; +import { FundAllocation } from '../value-objects/fund-allocation.vo'; +import { + FundAllocationTargetType, + FUND_ALLOCATION_AMOUNTS, +} from '../value-objects/fund-allocation-target-type.enum'; +import { PlantingOrder } from '../aggregates/planting-order.aggregate'; + +@Injectable() +export class FundAllocationService { + /** + * 计算认种订单的资金分配 + * 核心业务规则: 2199 USDT 的 10 个去向 + */ + calculateAllocations( + order: PlantingOrder, + referralChain: string[], + nearestProvinceAuth: string | null, + nearestCityAuth: string | null, + nearestCommunity: string | null, + ): FundAllocation[] { + const treeCount = order.treeCount.value; + const allocations: FundAllocation[] = []; + const selection = order.provinceCitySelection!; + + // 1. 成本账户: 400 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.COST_ACCOUNT, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.COST_ACCOUNT] * treeCount, + 'SYSTEM_COST_ACCOUNT', + )); + + // 2. 运营账户: 300 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.OPERATION_ACCOUNT, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.OPERATION_ACCOUNT] * treeCount, + 'SYSTEM_OPERATION_ACCOUNT', + )); + + // 3. 总部社区: 9 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.HEADQUARTERS_COMMUNITY, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.HEADQUARTERS_COMMUNITY] * treeCount, + 'SYSTEM_HEADQUARTERS_COMMUNITY', + )); + + // 4. 分享权益: 500 USDT/棵 (分配给推荐链) + allocations.push(new FundAllocation( + FundAllocationTargetType.REFERRAL_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.REFERRAL_RIGHTS] * treeCount, + referralChain.length > 0 ? referralChain[0] : 'SYSTEM_HEADQUARTERS_COMMUNITY', + { referralChain }, + )); + + // 5. 省区域权益: 15 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.PROVINCE_AREA_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.PROVINCE_AREA_RIGHTS] * treeCount, + `SYSTEM_PROVINCE_${selection.provinceCode}`, + )); + + // 6. 省团队权益: 20 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.PROVINCE_TEAM_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.PROVINCE_TEAM_RIGHTS] * treeCount, + nearestProvinceAuth || 'SYSTEM_HEADQUARTERS_COMMUNITY', + )); + + // 7. 市区域权益: 35 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.CITY_AREA_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.CITY_AREA_RIGHTS] * treeCount, + `SYSTEM_CITY_${selection.cityCode}`, + )); + + // 8. 市团队权益: 40 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.CITY_TEAM_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.CITY_TEAM_RIGHTS] * treeCount, + nearestCityAuth || 'SYSTEM_HEADQUARTERS_COMMUNITY', + )); + + // 9. 社区权益: 80 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.COMMUNITY_RIGHTS, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.COMMUNITY_RIGHTS] * treeCount, + nearestCommunity || 'SYSTEM_HEADQUARTERS_COMMUNITY', + )); + + // 10. RWAD底池: 800 USDT/棵 + allocations.push(new FundAllocation( + FundAllocationTargetType.RWAD_POOL, + FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.RWAD_POOL] * treeCount, + 'SYSTEM_RWAD_POOL', + )); + + // 验证总额 + const total = allocations.reduce((sum, a) => sum + a.amount, 0); + const expected = 2199 * treeCount; + if (Math.abs(total - expected) > 0.01) { + throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); + } + + return allocations; + } +} +``` + +### 3.4 仓储接口 + +#### 3.4.1 planting-order.repository.interface.ts + +```typescript +import { PlantingOrder } from '../aggregates/planting-order.aggregate'; +import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum'; + +export interface IPlantingOrderRepository { + save(order: PlantingOrder): Promise; + findById(orderId: bigint): Promise; + findByOrderNo(orderNo: string): Promise; + findByUserId(userId: bigint, page?: number, pageSize?: number): Promise; + findByStatus(status: PlantingOrderStatus, limit?: number): Promise; + findPendingPoolScheduling(): Promise; + findByBatchId(batchId: bigint): Promise; + findReadyForMining(): Promise; + countTreesByUserId(userId: bigint): Promise; +} + +export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository'); +``` + +--- + +## 第四阶段:应用层实现 + +### 4.1 应用服务 + +```typescript +// application/services/planting-application.service.ts + +import { Injectable, Inject } from '@nestjs/common'; +import { PlantingOrder } from '../../domain/aggregates/planting-order.aggregate'; +import { IPlantingOrderRepository, PLANTING_ORDER_REPOSITORY } from '../../domain/repositories/planting-order.repository.interface'; +import { IPlantingPositionRepository, PLANTING_POSITION_REPOSITORY } from '../../domain/repositories/planting-position.repository.interface'; +import { FundAllocationService } from '../../domain/services/fund-allocation.service'; +import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client'; + +@Injectable() +export class PlantingApplicationService { + constructor( + @Inject(PLANTING_ORDER_REPOSITORY) + private readonly orderRepository: IPlantingOrderRepository, + @Inject(PLANTING_POSITION_REPOSITORY) + private readonly positionRepository: IPlantingPositionRepository, + private readonly fundAllocationService: FundAllocationService, + private readonly walletService: WalletServiceClient, + ) {} + + /** + * 创建认种订单 + */ + async createOrder(userId: bigint, treeCount: number) { + // 风控检查 + await this.checkRiskControl(userId, treeCount); + + // 创建订单 + const order = PlantingOrder.create(userId, treeCount); + await this.orderRepository.save(order); + + return { + orderNo: order.orderNo, + treeCount: order.treeCount.value, + totalAmount: order.totalAmount, + status: order.status, + }; + } + + /** + * 选择省市 + */ + async selectProvinceCity( + orderNo: string, + provinceCode: string, + provinceName: string, + cityCode: string, + cityName: string, + ) { + const order = await this.orderRepository.findByOrderNo(orderNo); + if (!order) throw new Error('订单不存在'); + + order.selectProvinceCity(provinceCode, provinceName, cityCode, cityName); + await this.orderRepository.save(order); + + return { success: true }; + } + + /** + * 确认省市选择 (5秒后调用) + */ + async confirmProvinceCity(orderNo: string) { + const order = await this.orderRepository.findByOrderNo(orderNo); + if (!order) throw new Error('订单不存在'); + + order.confirmProvinceCity(); + await this.orderRepository.save(order); + + return { success: true }; + } + + /** + * 支付认种订单 + */ + async payOrder(orderNo: string, userId: bigint) { + const order = await this.orderRepository.findByOrderNo(orderNo); + if (!order) throw new Error('订单不存在'); + if (order.userId !== userId) throw new Error('无权操作此订单'); + + // 调用钱包服务扣款 + await this.walletService.deductForPlanting({ + userId: userId.toString(), + amount: order.totalAmount, + orderId: order.orderNo, + }); + + // 标记已支付 + order.markAsPaid(); + + // 计算资金分配 + const allocations = this.fundAllocationService.calculateAllocations( + order, + [], // referralChain - 需要从推荐服务获取 + null, // nearestProvinceAuth + null, // nearestCityAuth + null, // nearestCommunity + ); + + // 分配资金 + order.allocateFunds(allocations); + await this.orderRepository.save(order); + + // 调用钱包服务执行资金分配 + await this.walletService.allocateFunds({ + orderId: order.orderNo, + allocations: allocations.map(a => a.toDTO()), + }); + + // 更新用户持仓 + const position = await this.positionRepository.getOrCreate(userId); + position.addPlanting( + order.treeCount.value, + order.provinceCitySelection!.provinceCode, + order.provinceCitySelection!.cityCode, + ); + await this.positionRepository.save(position); + + return { + orderNo: order.orderNo, + status: order.status, + allocations: allocations.map(a => a.toDTO()), + }; + } + + /** + * 查询用户订单列表 + */ + async getUserOrders(userId: bigint, page = 1, pageSize = 10) { + const orders = await this.orderRepository.findByUserId(userId, page, pageSize); + return orders.map(o => ({ + orderNo: o.orderNo, + treeCount: o.treeCount.value, + totalAmount: o.totalAmount, + status: o.status, + provinceName: o.provinceCitySelection?.provinceName, + cityName: o.provinceCitySelection?.cityName, + isMiningEnabled: o.isMiningEnabled, + })); + } + + /** + * 查询用户持仓 + */ + async getUserPosition(userId: bigint) { + const position = await this.positionRepository.findByUserId(userId); + if (!position) { + return { + totalTreeCount: 0, + effectiveTreeCount: 0, + pendingTreeCount: 0, + }; + } + return { + totalTreeCount: position.totalTreeCount, + effectiveTreeCount: position.effectiveTreeCount, + pendingTreeCount: position.pendingTreeCount, + }; + } + + private async checkRiskControl(userId: bigint, treeCount: number) { + // 检查用户限购等风控规则 + const existingCount = await this.orderRepository.countTreesByUserId(userId); + if (existingCount + treeCount > 1000) { + throw new Error('超过个人最大认种数量限制'); + } + } +} +``` + +--- + +## 第五阶段:API层实现 + +### 5.1 DTO 定义 + +```typescript +// api/dto/create-planting-order.dto.ts +import { IsInt, Min } from 'class-validator'; + +export class CreatePlantingOrderDto { + @IsInt() + @Min(1) + treeCount: number; +} + +// api/dto/select-province-city.dto.ts +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SelectProvinceCityDto { + @IsString() + @IsNotEmpty() + provinceCode: string; + + @IsString() + @IsNotEmpty() + provinceName: string; + + @IsString() + @IsNotEmpty() + cityCode: string; + + @IsString() + @IsNotEmpty() + cityName: string; +} +``` + +### 5.2 控制器 + +```typescript +// api/controllers/planting-order.controller.ts + +import { Controller, Post, Get, Body, Param, Query, UseGuards, Req } from '@nestjs/common'; +import { PlantingApplicationService } from '../../application/services/planting-application.service'; +import { CreatePlantingOrderDto } from '../dto/create-planting-order.dto'; +import { SelectProvinceCityDto } from '../dto/select-province-city.dto'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; + +@Controller('planting') +@UseGuards(JwtAuthGuard) +export class PlantingOrderController { + constructor(private readonly plantingService: PlantingApplicationService) {} + + @Post('orders') + async createOrder(@Req() req: any, @Body() dto: CreatePlantingOrderDto) { + const userId = BigInt(req.user.id); + return this.plantingService.createOrder(userId, dto.treeCount); + } + + @Post('orders/:orderNo/select-province-city') + async selectProvinceCity( + @Param('orderNo') orderNo: string, + @Body() dto: SelectProvinceCityDto, + ) { + return this.plantingService.selectProvinceCity( + orderNo, + dto.provinceCode, + dto.provinceName, + dto.cityCode, + dto.cityName, + ); + } + + @Post('orders/:orderNo/confirm-province-city') + async confirmProvinceCity(@Param('orderNo') orderNo: string) { + return this.plantingService.confirmProvinceCity(orderNo); + } + + @Post('orders/:orderNo/pay') + async payOrder(@Req() req: any, @Param('orderNo') orderNo: string) { + const userId = BigInt(req.user.id); + return this.plantingService.payOrder(orderNo, userId); + } + + @Get('orders') + async getUserOrders( + @Req() req: any, + @Query('page') page = 1, + @Query('pageSize') pageSize = 10, + ) { + const userId = BigInt(req.user.id); + return this.plantingService.getUserOrders(userId, page, pageSize); + } + + @Get('position') + async getUserPosition(@Req() req: any) { + const userId = BigInt(req.user.id); + return this.plantingService.getUserPosition(userId); + } +} +``` + +--- + +## 关键业务规则 (不变式) + +1. **省市选择后不可修改**: 一旦确认省市,终生不可修改 +2. **省市确认需要5秒倒计时**: 防止用户误操作 +3. **资金分配规则**: 2199 USDT 必须精确分配到10个去向 +4. **800 USDT 必须进底池**: 每棵树的800 USDT 必须注入 RWAD 底池 +5. **底池注入后才能挖矿**: 只有底池注入完成才能开启挖矿 + +--- + +## API 端点汇总 + +| 方法 | 路径 | 描述 | 认证 | +|------|------|------|------| +| POST | /planting/orders | 创建认种订单 | 需要 | +| POST | /planting/orders/:orderNo/select-province-city | 选择省市 | 需要 | +| POST | /planting/orders/:orderNo/confirm-province-city | 确认省市(5秒后) | 需要 | +| POST | /planting/orders/:orderNo/pay | 支付订单 | 需要 | +| GET | /planting/orders | 查询我的订单列表 | 需要 | +| GET | /planting/position | 查询我的持仓 | 需要 | + +--- + +## 开发顺序建议 + +1. 项目初始化和 Prisma Schema +2. 值对象和枚举实现 +3. 聚合根实现 (PlantingOrder) +4. 领域服务实现 (FundAllocationService) +5. 仓储接口和实现 +6. 应用服务实现 +7. 外部服务客户端 (WalletServiceClient) +8. DTO 和控制器实现 +9. 模块配置和测试 + +--- + +## 与前端页面对应关系 + +### 认种--选择数量 页面 +- 调用余额API: `GET /api/deposit/balances` (Wallet Service) +- 计算最大可认种数量: `余额 / 2199` +- 用户选择数量后,调用: `POST /planting/orders` + +### 认种--选择省市 页面 +- 使用 `city_pickers` 包选择省市 +- 调用: `POST /planting/orders/:orderNo/select-province-city` + +### 认种--确认弹窗 +- 显示5秒倒计时 +- 倒计时结束后调用: `POST /planting/orders/:orderNo/confirm-province-city` +- 确认后自动调用: `POST /planting/orders/:orderNo/pay` + +--- + +## 注意事项 + +1. 所有金额使用 `Decimal(20, 8)` 存储 +2. 省市代码使用国家统计局标准代码 +3. 资金分配明细表是 append-only,不允许修改 +4. 使用事务确保订单状态和资金分配的一致性 +5. 5秒倒计时由前端控制,后端再次验证时间