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

1295 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, 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
```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<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
```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<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
```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<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 应用服务
```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秒倒计时由前端控制后端再次验证时间