41 KiB
41 KiB
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);
}
}
关键业务规则 (不变式)
- 省市选择后不可修改: 一旦确认省市,终生不可修改
- 省市确认需要5秒倒计时: 防止用户误操作
- 资金分配规则: 2199 USDT 必须精确分配到10个去向
- 800 USDT 必须进底池: 每棵树的800 USDT 必须注入 RWAD 底池
- 底池注入后才能挖矿: 只有底池注入完成才能开启挖矿
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 | 查询我的持仓 | 需要 |
开发顺序建议
- 项目初始化和 Prisma Schema
- 值对象和枚举实现
- 聚合根实现 (PlantingOrder)
- 领域服务实现 (FundAllocationService)
- 仓储接口和实现
- 应用服务实现
- 外部服务客户端 (WalletServiceClient)
- DTO 和控制器实现
- 模块配置和测试
与前端页面对应关系
认种--选择数量 页面
- 调用余额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
注意事项
- 所有金额使用
Decimal(20, 8)存储 - 省市代码使用国家统计局标准代码
- 资金分配明细表是 append-only,不允许修改
- 使用事务确保订单状态和资金分配的一致性
- 5秒倒计时由前端控制,后端再次验证时间