rwadurian/backend/services/planting-service/DEVELOPMENT_GUIDE.md

41 KiB
Raw Blame History

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 项目

cd backend/services
npx @nestjs/cli new planting-service --skip-git --package-manager npm
cd planting-service

1.2 安装依赖

npm install @nestjs/config @prisma/client class-validator class-transformer uuid
npm install -D prisma @types/uuid

1.3 配置环境变量

创建 .env.development:

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

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 初始化数据库

npx prisma migrate dev --name init
npx prisma generate

第三阶段:领域层实现

3.1 值对象 (Value Objects)

3.1.1 planting-order-status.enum.ts

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

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, number> = {
  [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

export enum BatchStatus {
  PENDING = 'PENDING',        // 待注入 (收集订单中)
  SCHEDULED = 'SCHEDULED',    // 已排期
  INJECTING = 'INJECTING',    // 注入中
  INJECTED = 'INJECTED',      // 已注入
}

3.1.4 tree-count.vo.ts

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

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

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<string, any>,
  ) {}

  toDTO() {
    return {
      targetType: this.targetType,
      amount: this.amount,
      targetAccountId: this.targetAccountId,
      metadata: this.metadata,
    };
  }
}

3.2 聚合根 (Aggregates)

3.2.1 planting-order.aggregate.ts

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

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

import { PlantingOrder } from '../aggregates/planting-order.aggregate';
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';

export interface IPlantingOrderRepository {
  save(order: PlantingOrder): Promise<void>;
  findById(orderId: bigint): Promise<PlantingOrder | null>;
  findByOrderNo(orderNo: string): Promise<PlantingOrder | null>;
  findByUserId(userId: bigint, page?: number, pageSize?: number): Promise<PlantingOrder[]>;
  findByStatus(status: PlantingOrderStatus, limit?: number): Promise<PlantingOrder[]>;
  findPendingPoolScheduling(): Promise<PlantingOrder[]>;
  findByBatchId(batchId: bigint): Promise<PlantingOrder[]>;
  findReadyForMining(): Promise<PlantingOrder[]>;
  countTreesByUserId(userId: bigint): Promise<number>;
}

export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');

第四阶段:应用层实现

4.1 应用服务

// 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 定义

// 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 控制器

// 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秒倒计时由前端控制后端再次验证时间