feat(planting-service): Implement complete planting service with DDD architecture

- Domain Layer:
  - PlantingOrder aggregate with full lifecycle management
  - PlantingPosition aggregate for user holdings
  - PoolInjectionBatch aggregate for 5-day batch processing
  - Value objects: TreeCount, ProvinceCitySelection, FundAllocation, Money
  - Domain events for state transitions
  - FundAllocationDomainService for 10-target fund distribution (2199 USDT/tree)

- Application Layer:
  - PlantingApplicationService for order management
  - PoolInjectionService for batch processing
  - 5-second province/city confirmation mechanism

- Infrastructure Layer:
  - Prisma ORM with PostgreSQL
  - Repository implementations
  - External service clients (Wallet, Referral)
  - Object mappers

- API Layer:
  - REST controllers with Swagger documentation
  - JWT authentication guard
  - Request/Response DTOs with validation

- Testing:
  - 45+ unit tests
  - 12+ integration tests
  - 17+ E2E tests
  - Docker test environment

- Documentation:
  - Architecture design (DDD + Hexagonal)
  - API documentation
  - Development guide
  - Testing guide
  - Deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-11-30 18:55:50 -08:00
parent 2d18155ac2
commit 98f5d948dd
99 changed files with 19272 additions and 0 deletions

View File

@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(dir:*)",
"Bash(tree:*)",
"Bash(npm install:*)",
"Bash(npx prisma generate:*)",
"Bash(npm run test:*)",
"Bash(npm run build:*)",
"Bash(npm run test:e2e:*)",
"Bash(npm run test:cov:*)",
"Bash(cat:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -0,0 +1,14 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
# App
NODE_ENV=development
APP_PORT=3003
# JWT
JWT_SECRET="planting-service-dev-jwt-secret"
# External Services
WALLET_SERVICE_URL=http://localhost:3002
IDENTITY_SERVICE_URL=http://localhost:3001
REFERRAL_SERVICE_URL=http://localhost:3004

View File

@ -0,0 +1,14 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
# App
NODE_ENV=development
APP_PORT=3003
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
# External Services
WALLET_SERVICE_URL=http://localhost:3002
IDENTITY_SERVICE_URL=http://localhost:3001
REFERRAL_SERVICE_URL=http://localhost:3004

View File

@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Build output
dist/
# Coverage
coverage/
# Environment files
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
# Prisma
prisma/*.db
prisma/*.db-journal
# Testing
.nyc_output/
# Temporary files
tmp/
temp/
*.tmp

View File

@ -0,0 +1,48 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy prisma schema and generate client
COPY prisma ./prisma/
RUN npx prisma generate
# Copy source code
COPY . .
# Build
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy prisma schema and generate client
COPY prisma ./prisma/
RUN npx prisma generate
# Copy built application
COPY --from=builder /app/dist ./dist
# Expose port
EXPOSE 3003
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/api/v1/health || exit 1
# Start the application
CMD ["node", "dist/main"]

View File

@ -0,0 +1,22 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy prisma schema
COPY prisma ./prisma/
# Generate Prisma client
RUN npx prisma generate
# Copy source code
COPY . .
# Build
RUN npm run build
# Default command for tests
CMD ["npm", "run", "test"]

View File

@ -0,0 +1,178 @@
.PHONY: install build start dev test test-unit test-integration test-e2e test-cov test-watch test-docker-all clean prisma-generate prisma-migrate prisma-studio lint format docker-build docker-up docker-down
# =============================================================================
# 环境变量
# =============================================================================
NODE_ENV ?= development
DATABASE_URL ?= postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test?schema=public
# =============================================================================
# 安装和构建
# =============================================================================
install:
npm install
build:
npm run build
clean:
rm -rf dist node_modules coverage .nyc_output
# =============================================================================
# 开发
# =============================================================================
start:
npm run start
dev:
npm run start:dev
# =============================================================================
# 数据库
# =============================================================================
prisma-generate:
npx prisma generate
prisma-migrate:
npx prisma migrate dev
prisma-migrate-prod:
npx prisma migrate deploy
prisma-studio:
npx prisma studio
prisma-reset:
npx prisma migrate reset --force
# =============================================================================
# 测试
# =============================================================================
test: test-unit
test-unit:
@echo "=========================================="
@echo "Running Unit Tests..."
@echo "=========================================="
npm run test
test-unit-verbose:
npm run test -- --verbose
test-watch:
npm run test:watch
test-cov:
@echo "=========================================="
@echo "Running Unit Tests with Coverage..."
@echo "=========================================="
npm run test:cov
test-integration:
@echo "=========================================="
@echo "Running Integration Tests..."
@echo "=========================================="
npm run test -- --testPathPattern=integration --runInBand
test-e2e:
@echo "=========================================="
@echo "Running E2E Tests..."
@echo "=========================================="
npm run test:e2e
test-all:
@echo "=========================================="
@echo "Running All Tests..."
@echo "=========================================="
$(MAKE) test-unit
$(MAKE) test-integration
$(MAKE) test-e2e
@echo "=========================================="
@echo "All Tests Completed!"
@echo "=========================================="
# =============================================================================
# Docker 测试
# =============================================================================
docker-build:
docker build -t planting-service:test .
docker-up:
docker-compose up -d
docker-down:
docker-compose down -v
docker-test-unit:
@echo "=========================================="
@echo "Running Unit Tests in Docker..."
@echo "=========================================="
docker-compose -f docker-compose.test.yml run --rm test npm run test
docker-test-integration:
@echo "=========================================="
@echo "Running Integration Tests in Docker..."
@echo "=========================================="
docker-compose -f docker-compose.test.yml run --rm test npm run test -- --testPathPattern=integration --runInBand
docker-test-e2e:
@echo "=========================================="
@echo "Running E2E Tests in Docker..."
@echo "=========================================="
docker-compose -f docker-compose.test.yml run --rm test npm run test:e2e
test-docker-all:
@echo "=========================================="
@echo "Running All Tests in Docker..."
@echo "=========================================="
docker-compose -f docker-compose.test.yml up -d db
@sleep 5
docker-compose -f docker-compose.test.yml run --rm test sh -c "npx prisma migrate deploy && npm run test && npm run test:e2e"
docker-compose -f docker-compose.test.yml down -v
@echo "=========================================="
@echo "All Docker Tests Completed!"
@echo "=========================================="
# =============================================================================
# 代码质量
# =============================================================================
lint:
npm run lint
format:
npm run format
# =============================================================================
# 帮助
# =============================================================================
help:
@echo "Planting Service - Available Commands:"
@echo ""
@echo " Development:"
@echo " make install - Install dependencies"
@echo " make build - Build the project"
@echo " make dev - Start in development mode"
@echo " make start - Start in production mode"
@echo ""
@echo " Database:"
@echo " make prisma-generate - Generate Prisma client"
@echo " make prisma-migrate - Run database migrations"
@echo " make prisma-studio - Open Prisma Studio"
@echo " make prisma-reset - Reset database"
@echo ""
@echo " Testing:"
@echo " make test-unit - Run unit tests"
@echo " make test-integration - Run integration tests"
@echo " make test-e2e - Run end-to-end tests"
@echo " make test-cov - Run tests with coverage"
@echo " make test-all - Run all tests"
@echo " make test-docker-all - Run all tests in Docker"
@echo ""
@echo " Code Quality:"
@echo " make lint - Run linter"
@echo " make format - Format code"
@echo ""
@echo " Docker:"
@echo " make docker-build - Build Docker image"
@echo " make docker-up - Start Docker containers"
@echo " make docker-down - Stop Docker containers"

View File

@ -0,0 +1,40 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rwadurian_planting_test
ports:
- "5433:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
test:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test?schema=public
JWT_SECRET: test-jwt-secret
WALLET_SERVICE_URL: http://localhost:3002
IDENTITY_SERVICE_URL: http://localhost:3001
REFERRAL_SERVICE_URL: http://localhost:3004
volumes:
- ./src:/app/src
- ./test:/app/test
- ./prisma:/app/prisma
volumes:
postgres_test_data:

View File

@ -0,0 +1,665 @@
# Planting Service API 文档
## 目录
- [概述](#概述)
- [认证](#认证)
- [通用响应格式](#通用响应格式)
- [API 端点](#api-端点)
- [健康检查](#健康检查)
- [订单管理](#订单管理)
- [持仓查询](#持仓查询)
- [错误码](#错误码)
- [数据模型](#数据模型)
---
## 概述
- **Base URL**: `http://localhost:3003/api/v1`
- **协议**: HTTP/HTTPS
- **数据格式**: JSON
- **字符编码**: UTF-8
### Swagger 文档
启动服务后访问: `http://localhost:3003/api/docs`
---
## 认证
所有业务 API除健康检查外需要 JWT Bearer Token 认证。
### 请求头
```http
Authorization: Bearer <jwt_token>
```
### Token 结构
```json
{
"id": "1",
"username": "user@example.com",
"iat": 1699999999,
"exp": 1700003599
}
```
---
## 通用响应格式
### 成功响应
```json
{
"data": { ... },
"timestamp": "2024-11-30T10:00:00.000Z"
}
```
### 错误响应
```json
{
"statusCode": 400,
"message": "错误信息",
"error": "Bad Request",
"timestamp": "2024-11-30T10:00:00.000Z"
}
```
---
## API 端点
### 健康检查
#### GET /health
检查服务健康状态。
**请求**
```http
GET /api/v1/health
```
**响应**
```json
{
"status": "ok",
"timestamp": "2024-11-30T10:00:00.000Z",
"service": "planting-service"
}
```
#### GET /health/ready
检查服务就绪状态。
**请求**
```http
GET /api/v1/health/ready
```
**响应**
```json
{
"status": "ready",
"timestamp": "2024-11-30T10:00:00.000Z"
}
```
---
### 订单管理
#### POST /planting/orders
创建认种订单。
**请求**
```http
POST /api/v1/planting/orders
Authorization: Bearer <token>
Content-Type: application/json
{
"treeCount": 5
}
```
**参数说明**
| 参数 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| treeCount | number | 是 | 认种数量1-1000 |
**响应**
```json
{
"orderNo": "PO202411300001",
"userId": "1",
"treeCount": 5,
"totalAmount": 10995,
"status": "CREATED",
"createdAt": "2024-11-30T10:00:00.000Z"
}
```
**错误码**
| 状态码 | 说明 |
|-------|------|
| 400 | 参数错误(数量超限) |
| 400 | 超过个人最大认种数量限制1000棵|
| 400 | 余额不足 |
| 401 | 未授权 |
---
#### GET /planting/orders
查询用户订单列表。
**请求**
```http
GET /api/v1/planting/orders?page=1&pageSize=10
Authorization: Bearer <token>
```
**参数说明**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|-----|------|-----|-------|------|
| page | number | 否 | 1 | 页码 |
| pageSize | number | 否 | 10 | 每页数量最大100 |
**响应**
```json
[
{
"orderNo": "PO202411300001",
"treeCount": 5,
"totalAmount": 10995,
"status": "CREATED",
"selectedProvince": null,
"selectedCity": null,
"createdAt": "2024-11-30T10:00:00.000Z"
},
{
"orderNo": "PO202411300002",
"treeCount": 3,
"totalAmount": 6597,
"status": "MINING_ENABLED",
"selectedProvince": "广东省",
"selectedCity": "广州市",
"createdAt": "2024-11-25T08:00:00.000Z"
}
]
```
---
#### GET /planting/orders/:orderNo
查询订单详情。
**请求**
```http
GET /api/v1/planting/orders/PO202411300001
Authorization: Bearer <token>
```
**响应**
```json
{
"orderNo": "PO202411300001",
"userId": "1",
"treeCount": 5,
"totalAmount": 10995,
"status": "FUND_ALLOCATED",
"selectedProvince": "广东省",
"selectedCity": "广州市",
"provinceCitySelectedAt": "2024-11-30T10:01:00.000Z",
"provinceCityConfirmedAt": "2024-11-30T10:01:05.000Z",
"paidAt": "2024-11-30T10:02:00.000Z",
"fundAllocatedAt": "2024-11-30T10:02:01.000Z",
"allocations": [
{
"targetType": "POOL",
"amount": 9895.5
},
{
"targetType": "OPERATION",
"amount": 549.75
}
],
"createdAt": "2024-11-30T10:00:00.000Z"
}
```
---
#### POST /planting/orders/:orderNo/select-province-city
选择省市开始5秒倒计时
**请求**
```http
POST /api/v1/planting/orders/PO202411300001/select-province-city
Authorization: Bearer <token>
Content-Type: application/json
{
"provinceCode": "440000",
"provinceName": "广东省",
"cityCode": "440100",
"cityName": "广州市"
}
```
**参数说明**
| 参数 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| provinceCode | string | 是 | 省份代码 |
| provinceName | string | 是 | 省份名称 |
| cityCode | string | 是 | 城市代码 |
| cityName | string | 是 | 城市名称 |
**响应**
```json
{
"success": true,
"message": "省市选择成功请在5秒后确认",
"expiresAt": "2024-11-30T10:01:05.000Z"
}
```
**错误码**
| 状态码 | 说明 |
|-------|------|
| 400 | 订单状态不允许选择省市 |
| 404 | 订单不存在 |
| 403 | 无权操作此订单 |
---
#### POST /planting/orders/:orderNo/confirm-province-city
确认省市选择需在选择5秒后调用
**请求**
```http
POST /api/v1/planting/orders/PO202411300001/confirm-province-city
Authorization: Bearer <token>
```
**响应**
```json
{
"success": true,
"message": "省市确认成功"
}
```
**错误码**
| 状态码 | 说明 |
|-------|------|
| 400 | 还需等待5秒才能确认 |
| 400 | 订单状态不允许确认 |
| 404 | 订单不存在 |
---
#### POST /planting/orders/:orderNo/pay
支付订单。
**请求**
```http
POST /api/v1/planting/orders/PO202411300001/pay
Authorization: Bearer <token>
```
**响应**
```json
{
"orderNo": "PO202411300001",
"status": "POOL_SCHEDULED",
"paidAt": "2024-11-30T10:02:00.000Z",
"totalAmount": 10995,
"allocations": [
{
"targetType": "POOL",
"amount": 9895.5,
"description": "资金池"
},
{
"targetType": "OPERATION",
"amount": 549.75,
"description": "运营费用"
},
{
"targetType": "PROVINCE_AUTH",
"amount": 65.97,
"description": "省代奖励"
},
{
"targetType": "CITY_AUTH",
"amount": 32.985,
"description": "市代奖励"
},
{
"targetType": "COMMUNITY",
"amount": 54.975,
"description": "社区长奖励"
},
{
"targetType": "REFERRAL_L1",
"amount": 164.925,
"description": "一级推荐奖励"
},
{
"targetType": "REFERRAL_L2",
"amount": 109.95,
"description": "二级推荐奖励"
},
{
"targetType": "REFERRAL_L3",
"amount": 54.975,
"description": "三级推荐奖励"
},
{
"targetType": "PLATFORM",
"amount": 32.985,
"description": "平台费用"
},
{
"targetType": "RESERVE",
"amount": 32.985,
"description": "储备金"
}
],
"batchInfo": {
"batchNo": "BATCH202411300001",
"scheduledInjectionTime": "2024-12-05T00:00:00.000Z"
}
}
```
**错误码**
| 状态码 | 说明 |
|-------|------|
| 400 | 余额不足 |
| 400 | 订单状态不允许支付 |
| 400 | 请先确认省市选择 |
| 404 | 订单不存在 |
---
#### POST /planting/orders/:orderNo/cancel
取消订单(仅未支付订单可取消)。
**请求**
```http
POST /api/v1/planting/orders/PO202411300001/cancel
Authorization: Bearer <token>
```
**响应**
```json
{
"success": true,
"message": "订单取消成功"
}
```
**错误码**
| 状态码 | 说明 |
|-------|------|
| 400 | 订单状态不允许取消(已支付) |
| 404 | 订单不存在 |
| 403 | 无权操作此订单 |
---
### 持仓查询
#### GET /planting/position
查询用户持仓信息。
**请求**
```http
GET /api/v1/planting/position
Authorization: Bearer <token>
```
**响应**
```json
{
"totalTreeCount": 15,
"effectiveTreeCount": 12,
"pendingTreeCount": 3,
"firstMiningStartAt": "2024-11-20T00:00:00.000Z",
"distributions": [
{
"provinceCode": "440000",
"provinceName": "广东省",
"cityCode": "440100",
"cityName": "广州市",
"treeCount": 10
},
{
"provinceCode": "310000",
"provinceName": "上海市",
"cityCode": "310100",
"cityName": "上海市",
"treeCount": 5
}
]
}
```
**字段说明**
| 字段 | 类型 | 说明 |
|-----|------|------|
| totalTreeCount | number | 总认种数量 |
| effectiveTreeCount | number | 有效数量(已开始挖矿)|
| pendingTreeCount | number | 待生效数量 |
| firstMiningStartAt | string | 首次挖矿开始时间 |
| distributions | array | 按省市分布 |
---
## 错误码
### HTTP 状态码
| 状态码 | 说明 |
|-------|------|
| 200 | 成功 |
| 201 | 创建成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
### 业务错误码
| 错误信息 | 说明 |
|---------|------|
| 超过个人最大认种数量限制 | 单用户最多认种1000棵 |
| 余额不足 | USDT 余额不足以支付 |
| 订单不存在 | 订单号无效或已删除 |
| 无权操作此订单 | 订单不属于当前用户 |
| 订单状态不允许此操作 | 状态机校验失败 |
| 还需等待5秒才能确认 | 省市选择5秒机制 |
---
## 数据模型
### PlantingOrder
```typescript
interface PlantingOrder {
orderNo: string; // 订单号格式PO + 年月日 + 序号
userId: string; // 用户ID
treeCount: number; // 认种数量
totalAmount: number; // 总金额 (USDT)
status: PlantingOrderStatus;
selectedProvince?: string; // 选择的省份
selectedCity?: string; // 选择的城市
provinceCitySelectedAt?: Date;
provinceCityConfirmedAt?: Date;
paidAt?: Date;
fundAllocatedAt?: Date;
poolInjectionBatchId?: string;
poolInjectionScheduledTime?: Date;
poolInjectionActualTime?: Date;
poolInjectionTxHash?: string;
miningEnabledAt?: Date;
createdAt: Date;
updatedAt: Date;
}
```
### PlantingOrderStatus
```typescript
enum PlantingOrderStatus {
CREATED = 'CREATED',
PROVINCE_CITY_SELECTED = 'PROVINCE_CITY_SELECTED',
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'
}
```
### FundAllocationTargetType
```typescript
enum FundAllocationTargetType {
POOL = 'POOL', // 资金池 90%
OPERATION = 'OPERATION', // 运营 5%
PROVINCE_AUTH = 'PROVINCE_AUTH', // 省代 0.6%
CITY_AUTH = 'CITY_AUTH', // 市代 0.3%
COMMUNITY = 'COMMUNITY', // 社区长 0.5%
REFERRAL_L1 = 'REFERRAL_L1', // 一级推荐 1.5%
REFERRAL_L2 = 'REFERRAL_L2', // 二级推荐 1.0%
REFERRAL_L3 = 'REFERRAL_L3', // 三级推荐 0.5%
PLATFORM = 'PLATFORM', // 平台 0.3%
RESERVE = 'RESERVE' // 储备 0.3%
}
```
### PlantingPosition
```typescript
interface PlantingPosition {
userId: string;
totalTreeCount: number;
effectiveTreeCount: number;
pendingTreeCount: number;
firstMiningStartAt?: Date;
distributions: PositionDistribution[];
}
interface PositionDistribution {
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
treeCount: number;
}
```
---
## 使用示例
### cURL 示例
```bash
# 1. 创建订单
curl -X POST http://localhost:3003/api/v1/planting/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"treeCount": 5}'
# 2. 选择省市
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/select-province-city \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"provinceCode": "440000",
"provinceName": "广东省",
"cityCode": "440100",
"cityName": "广州市"
}'
# 3. 等待5秒后确认省市
sleep 5
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/confirm-province-city \
-H "Authorization: Bearer $TOKEN"
# 4. 支付订单
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/pay \
-H "Authorization: Bearer $TOKEN"
# 5. 查询持仓
curl http://localhost:3003/api/v1/planting/position \
-H "Authorization: Bearer $TOKEN"
```
### JavaScript/TypeScript 示例
```typescript
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3003/api/v1',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// 创建订单
const { data: order } = await api.post('/planting/orders', { treeCount: 5 });
// 选择省市
await api.post(`/planting/orders/${order.orderNo}/select-province-city`, {
provinceCode: '440000',
provinceName: '广东省',
cityCode: '440100',
cityName: '广州市'
});
// 等待5秒
await new Promise(resolve => setTimeout(resolve, 5000));
// 确认省市
await api.post(`/planting/orders/${order.orderNo}/confirm-province-city`);
// 支付
const { data: payResult } = await api.post(`/planting/orders/${order.orderNo}/pay`);
console.log('支付成功,资金分配:', payResult.allocations);
```

View File

@ -0,0 +1,423 @@
# Planting Service 架构文档
## 目录
- [概述](#概述)
- [架构模式](#架构模式)
- [分层架构](#分层架构)
- [领域模型](#领域模型)
- [数据流](#数据流)
- [技术栈](#技术栈)
- [目录结构](#目录结构)
---
## 概述
Planting Service 是 RWA Durian Queen 平台的核心微服务之一,负责处理榴莲树认种业务的完整生命周期。该服务采用 **领域驱动设计 (DDD)** 结合 **六边形架构 (Hexagonal Architecture)** 进行设计和实现。
### 核心职责
1. **认种订单管理** - 创建、支付、取消订单
2. **省市选择机制** - 5秒确认机制防止误操作
3. **资金分配** - 10种目标的精确分配总计2199 USDT/棵)
4. **持仓管理** - 用户认种持仓的统计与追踪
5. **资金池注入** - 5天批次管理机制
---
## 架构模式
### 领域驱动设计 (DDD)
```
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers, DTOs) │
├─────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ (Application Services, Use Cases) │
├─────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ (Aggregates, Entities, Value Objects, Domain Services) │
├─────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Repositories, External Services, Persistence) │
└─────────────────────────────────────────────────────────────────┘
```
### 六边形架构 (Ports & Adapters)
```
┌─────────────────────────┐
│ HTTP API │
│ (Primary Adapter) │
└───────────┬─────────────┘
┌───────────▼─────────────┐
│ │
┌───────────────┤ Application Core ├───────────────┐
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Domain Model │ │ │
│ │ │ │ │ │
│ │ │ - Aggregates │ │ │
│ │ │ - Services │ │ │
│ │ │ - Events │ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ └─────────────────────────┘ │
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────────┐ ┌───────────────┐
│ PostgreSQL│ │ Wallet Service│ │Referral Service│
│ (Prisma) │ │ (HTTP) │ │ (HTTP) │
└───────────┘ └───────────────┘ └───────────────┘
Secondary Secondary Secondary
Adapter Adapter Adapter
```
---
## 分层架构
### 1. API 层 (`src/api/`)
**职责**: 处理 HTTP 请求/响应,输入验证,认证授权
```
src/api/
├── controllers/ # 控制器
│ ├── health.controller.ts
│ ├── planting-order.controller.ts
│ └── planting-position.controller.ts
├── dto/ # 数据传输对象
│ ├── request/ # 请求 DTO
│ └── response/ # 响应 DTO
├── guards/ # 守卫
│ └── jwt-auth.guard.ts
└── api.module.ts
```
**设计原则**:
- 控制器仅负责 HTTP 协议处理
- DTO 用于数据验证和转换
- 不包含业务逻辑
### 2. Application 层 (`src/application/`)
**职责**: 编排业务用例,协调领域对象,事务管理
```
src/application/
├── services/
│ ├── planting-application.service.ts # 主应用服务
│ └── pool-injection.service.ts # 资金池注入服务
└── application.module.ts
```
**核心职责**:
- 用例编排
- 跨聚合协调
- 外部服务调用
- 事务边界控制
### 3. Domain 层 (`src/domain/`)
**职责**: 核心业务逻辑,业务规则验证
```
src/domain/
├── aggregates/ # 聚合根
│ ├── planting-order.aggregate.ts
│ ├── planting-position.aggregate.ts
│ └── pool-injection-batch.aggregate.ts
├── value-objects/ # 值对象
│ ├── tree-count.vo.ts
│ ├── province-city-selection.vo.ts
│ ├── fund-allocation.vo.ts
│ └── money.vo.ts
├── events/ # 领域事件
│ ├── planting-order-created.event.ts
│ ├── province-city-confirmed.event.ts
│ └── funds-allocated.event.ts
├── services/ # 领域服务
│ └── fund-allocation.service.ts
├── repositories/ # 仓储接口
│ ├── planting-order.repository.interface.ts
│ ├── planting-position.repository.interface.ts
│ └── pool-injection-batch.repository.interface.ts
└── domain.module.ts
```
**设计原则**:
- 聚合是事务一致性边界
- 值对象不可变
- 领域事件用于跨聚合通信
- 仓储接口定义在领域层
### 4. Infrastructure 层 (`src/infrastructure/`)
**职责**: 技术实现,外部系统集成
```
src/infrastructure/
├── persistence/
│ ├── prisma/
│ │ └── prisma.service.ts
│ ├── 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
└── infrastructure.module.ts
```
---
## 领域模型
### 聚合关系图
```
┌─────────────────────────────────────────────────────────────────────┐
│ PlantingOrder │
│ (聚合根 - 认种订单) │
│ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ TreeCount │ │ ProvinceCitySelection│ │
│ │ (认种数量) │ │ (省市选择) │ │
│ └──────────────────┘ └────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ FundAllocation[] │ │
│ │ (资金分配列表) │ │
│ │ │ │
│ │ POOL(90%) | OPERATION(5%) | PROVINCE(0.6%) | CITY(0.3%) ... │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ 1:N
┌─────────────────────────────────────────────────────────────────────┐
│ PlantingPosition │
│ (聚合根 - 用户持仓) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PositionDistribution[] │ │
│ │ (持仓分布 - 按省市统计) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ PoolInjectionBatch │
│ (聚合根 - 资金池注入批次) │
│ │
│ - 5天为一个批次周期 │
│ - 批量处理订单的资金池注入 │
│ - 状态: OPEN -> SCHEDULED -> INJECTING -> COMPLETED │
└─────────────────────────────────────────────────────────────────────┘
```
### 订单状态流转
```
┌──────────┐
│ CREATED │ ◄─── 创建订单
└────┬─────┘
│ selectProvinceCity()
┌──────────────────────┐
│ PROVINCE_CITY_SELECTED│ ◄─── 选择省市5秒等待
└────┬─────────────────┘
│ confirmProvinceCity() (5秒后)
┌──────────────────────┐
│PROVINCE_CITY_CONFIRMED│ ◄─── 确认省市
└────┬─────────────────┘
│ pay()
┌──────────┐
│ PAID │ ◄─── 支付完成
└────┬─────┘
│ allocateFunds()
┌────────────────┐
│ FUND_ALLOCATED │ ◄─── 资金分配完成
└────┬───────────┘
│ scheduleToBatch()
┌────────────────┐
│ POOL_SCHEDULED │ ◄─── 加入注入批次
└────┬───────────┘
│ markPoolInjected()
┌────────────────┐
│ POOL_INJECTED │ ◄─── 资金池注入完成
└────┬───────────┘
│ enableMining()
┌────────────────┐
│ MINING_ENABLED │ ◄─── 开始挖矿
└────────────────┘
任意状态PAID之前可转为:
┌───────────┐
│ CANCELLED │ ◄─── 取消订单
└───────────┘
```
### 资金分配模型
每棵树 **2199 USDT** 分配到 10 个目标:
| 序号 | 目标类型 | 金额 (USDT) | 比例 |
|-----|---------|------------|------|
| 1 | POOL (资金池) | 1979.1 | 90% |
| 2 | OPERATION (运营) | 109.95 | 5% |
| 3 | PROVINCE_AUTH (省代) | 13.194 | 0.6% |
| 4 | CITY_AUTH (市代) | 6.597 | 0.3% |
| 5 | COMMUNITY (社区长) | 10.995 | 0.5% |
| 6 | REFERRAL_L1 (一级推荐) | 32.985 | 1.5% |
| 7 | REFERRAL_L2 (二级推荐) | 21.99 | 1.0% |
| 8 | REFERRAL_L3 (三级推荐) | 10.995 | 0.5% |
| 9 | PLATFORM (平台) | 6.597 | 0.3% |
| 10 | RESERVE (储备) | 6.597 | 0.3% |
| **总计** | | **2199** | **100%** |
---
## 数据流
### 创建订单流程
```
┌──────┐ ┌────────────┐ ┌─────────────────┐ ┌──────────────┐
│Client│────▶│ Controller │────▶│ Application Svc │────▶│ Domain Layer │
└──────┘ └────────────┘ └─────────────────┘ └──────────────┘
│ │ │ │
│ POST /orders│ │ │
│ {treeCount} │ │ │
│ │ │ │
│ │ createOrder() │ │
│ │────────────────────▶│ │
│ │ │ │
│ │ │ 检查限购数量 │
│ │ │─────────────────────▶│
│ │ │ │
│ │ │ 检查余额 │
│ │ │─────────────────────▶│
│ │ │ (Wallet Service) │
│ │ │ │
│ │ │ 创建订单聚合 │
│ │ │─────────────────────▶│
│ │ │ │
│ │ │ 保存订单 │
│ │ │─────────────────────▶│
│ │ │ (Repository) │
│ │ │ │
│◀─────────────│◀────────────────────│◀─────────────────────│
│ OrderDTO │ │ │
```
### 支付订单流程
```
┌──────┐ ┌────────────┐ ┌─────────────────┐ ┌──────────────┐
│Client│────▶│ Controller │────▶│ Application Svc │────▶│ Domain Layer │
└──────┘ └────────────┘ └─────────────────┘ └──────────────┘
│ │ │ │
│ POST /pay │ │ │
│ │ payOrder() │ │
│ │────────────────────▶│ │
│ │ │ │
│ │ │ 1. 扣款 │
│ │ │─────────────────────▶│
│ │ │ (Wallet Service) │
│ │ │ │
│ │ │ 2. 获取推荐上下文 │
│ │ │─────────────────────▶│
│ │ │ (Referral Service) │
│ │ │ │
│ │ │ 3. 计算资金分配 │
│ │ │─────────────────────▶│
│ │ │ (FundAllocationSvc) │
│ │ │ │
│ │ │ 4. 执行资金分配 │
│ │ │─────────────────────▶│
│ │ │ (Wallet Service) │
│ │ │ │
│ │ │ 5. 更新用户持仓 │
│ │ │─────────────────────▶│
│ │ │ (Position Repo) │
│ │ │ │
│ │ │ 6. 加入注入批次 │
│ │ │─────────────────────▶│
│ │ │ (Batch Repo) │
│ │ │ │
│◀─────────────│◀────────────────────│◀─────────────────────│
│ PayResult │ │ │
```
---
## 技术栈
| 组件 | 技术选型 | 用途 |
|-----|---------|------|
| 框架 | NestJS 10.x | Web 框架 |
| 语言 | TypeScript 5.x | 开发语言 |
| 数据库 | PostgreSQL 16 | 主数据库 |
| ORM | Prisma 5.x | 数据访问 |
| 验证 | class-validator | 输入验证 |
| 文档 | Swagger/OpenAPI | API 文档 |
| 测试 | Jest + Supertest | 测试框架 |
| 容器 | Docker | 容器化部署 |
---
## 目录结构
```
planting-service/
├── docs/ # 文档目录
│ ├── ARCHITECTURE.md # 架构文档
│ ├── API.md # API 文档
│ ├── DEVELOPMENT.md # 开发指南
│ ├── TESTING.md # 测试文档
│ └── DEPLOYMENT.md # 部署文档
├── prisma/
│ ├── schema.prisma # 数据库模型
│ └── migrations/ # 数据库迁移
├── src/
│ ├── api/ # API 层
│ ├── application/ # 应用层
│ ├── domain/ # 领域层
│ ├── infrastructure/ # 基础设施层
│ ├── config/ # 配置
│ ├── shared/ # 共享模块
│ ├── app.module.ts # 根模块
│ └── main.ts # 入口文件
├── test/ # E2E 测试
├── docker-compose.yml # Docker 编排
├── docker-compose.test.yml # 测试环境编排
├── Dockerfile # 生产镜像
├── Dockerfile.test # 测试镜像
├── Makefile # 构建脚本
└── package.json
```
---
## 参考资料
- [领域驱动设计 (Eric Evans)](https://www.domainlanguage.com/ddd/)
- [六边形架构 (Alistair Cockburn)](https://alistair.cockburn.us/hexagonal-architecture/)
- [NestJS 官方文档](https://docs.nestjs.com/)
- [Prisma 官方文档](https://www.prisma.io/docs/)

View File

@ -0,0 +1,760 @@
# Planting Service 部署文档
## 目录
- [部署概述](#部署概述)
- [环境要求](#环境要求)
- [配置管理](#配置管理)
- [Docker 部署](#docker-部署)
- [Kubernetes 部署](#kubernetes-部署)
- [数据库迁移](#数据库迁移)
- [健康检查](#健康检查)
- [监控与日志](#监控与日志)
- [故障排查](#故障排查)
- [回滚策略](#回滚策略)
---
## 部署概述
### 部署架构
```
┌─────────────────┐
│ Load Balancer │
│ (Nginx/ALB) │
└────────┬────────┘
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Planting Service│ │ Planting Service│ │ Planting Service│
│ Instance 1 │ │ Instance 2 │ │ Instance 3 │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────┼───────────────────┘
┌────────▼────────┐
│ PostgreSQL │
│ (Primary/RDS) │
└─────────────────┘
```
### 部署方式
| 方式 | 适用场景 | 复杂度 |
|-----|---------|-------|
| Docker Compose | 开发/测试环境 | 低 |
| Docker Swarm | 小规模生产 | 中 |
| Kubernetes | 大规模生产 | 高 |
| Cloud Run/ECS | 托管云服务 | 中 |
---
## 环境要求
### 生产环境最低配置
| 资源 | 最低配置 | 推荐配置 |
|-----|---------|---------|
| CPU | 2 核 | 4 核 |
| 内存 | 2 GB | 4 GB |
| 磁盘 | 20 GB SSD | 50 GB SSD |
| 网络 | 100 Mbps | 1 Gbps |
### 数据库配置
| 环境 | 实例类型 | 存储 |
|-----|---------|------|
| 开发 | db.t3.micro | 20 GB |
| 测试 | db.t3.small | 50 GB |
| 生产 | db.r5.large | 200 GB |
---
## 配置管理
### 环境变量
```bash
# 基础配置
NODE_ENV=production
PORT=3003
# 数据库
DATABASE_URL=postgresql://user:password@host:5432/dbname?schema=public
# JWT 认证
JWT_SECRET=<secure-random-string>
# 外部服务
WALLET_SERVICE_URL=http://wallet-service:3002
IDENTITY_SERVICE_URL=http://identity-service:3001
REFERRAL_SERVICE_URL=http://referral-service:3004
# 日志
LOG_LEVEL=info
# 性能
MAX_CONNECTIONS=100
QUERY_TIMEOUT=30000
```
### 配置文件示例
**.env.production**
```env
NODE_ENV=production
PORT=3003
DATABASE_URL=postgresql://planting:${DB_PASSWORD}@db.prod.internal:5432/rwadurian_planting?schema=public&connection_limit=50
JWT_SECRET=${JWT_SECRET}
WALLET_SERVICE_URL=http://wallet-service.prod.internal:3002
IDENTITY_SERVICE_URL=http://identity-service.prod.internal:3001
REFERRAL_SERVICE_URL=http://referral-service.prod.internal:3004
LOG_LEVEL=info
```
### 敏感信息管理
使用密钥管理服务:
```bash
# AWS Secrets Manager
aws secretsmanager get-secret-value --secret-id planting-service/prod
# Kubernetes Secrets
kubectl create secret generic planting-secrets \
--from-literal=DATABASE_URL='...' \
--from-literal=JWT_SECRET='...'
```
---
## Docker 部署
### 生产 Dockerfile
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制 Prisma schema 并生成客户端
COPY prisma ./prisma/
RUN npx prisma generate
# 复制源代码
COPY . .
# 构建
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs
# 复制依赖文件
COPY package*.json ./
# 仅安装生产依赖
RUN npm ci --only=production
# 复制 Prisma
COPY prisma ./prisma/
RUN npx prisma generate
# 复制构建产物
COPY --from=builder /app/dist ./dist
# 切换到非 root 用户
USER nestjs
# 暴露端口
EXPOSE 3003
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/api/v1/health || exit 1
# 启动命令
CMD ["node", "dist/main"]
```
### Docker Compose 生产配置
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
planting-service:
build:
context: .
dockerfile: Dockerfile
target: production
image: planting-service:${VERSION:-latest}
container_name: planting-service
restart: unless-stopped
ports:
- "3003:3003"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- WALLET_SERVICE_URL=${WALLET_SERVICE_URL}
- IDENTITY_SERVICE_URL=${IDENTITY_SERVICE_URL}
- REFERRAL_SERVICE_URL=${REFERRAL_SERVICE_URL}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3003/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- rwadurian-network
networks:
rwadurian-network:
external: true
```
### 部署命令
```bash
# 构建镜像
docker build -t planting-service:v1.0.0 .
# 推送到仓库
docker tag planting-service:v1.0.0 registry.example.com/planting-service:v1.0.0
docker push registry.example.com/planting-service:v1.0.0
# 部署
docker-compose -f docker-compose.prod.yml up -d
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f planting-service
# 重启
docker-compose -f docker-compose.prod.yml restart planting-service
# 停止
docker-compose -f docker-compose.prod.yml down
```
---
## Kubernetes 部署
### Deployment
```yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: planting-service
labels:
app: planting-service
spec:
replicas: 3
selector:
matchLabels:
app: planting-service
template:
metadata:
labels:
app: planting-service
spec:
containers:
- name: planting-service
image: registry.example.com/planting-service:v1.0.0
ports:
- containerPort: 3003
env:
- name: NODE_ENV
value: production
- name: PORT
value: "3003"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: planting-secrets
key: DATABASE_URL
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: planting-secrets
key: JWT_SECRET
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2000m"
memory: "2Gi"
livenessProbe:
httpGet:
path: /api/v1/health
port: 3003
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/v1/health/ready
port: 3003
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
imagePullSecrets:
- name: registry-credentials
```
### Service
```yaml
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: planting-service
spec:
selector:
app: planting-service
ports:
- protocol: TCP
port: 3003
targetPort: 3003
type: ClusterIP
```
### Ingress
```yaml
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: planting-service-ingress
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- api.rwadurian.com
secretName: api-tls
rules:
- host: api.rwadurian.com
http:
paths:
- path: /api/v1/planting
pathType: Prefix
backend:
service:
name: planting-service
port:
number: 3003
```
### HPA (Horizontal Pod Autoscaler)
```yaml
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: planting-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: planting-service
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
```
### 部署命令
```bash
# 创建 secrets
kubectl create secret generic planting-secrets \
--from-literal=DATABASE_URL='postgresql://...' \
--from-literal=JWT_SECRET='...'
# 应用配置
kubectl apply -f k8s/
# 查看状态
kubectl get pods -l app=planting-service
kubectl get svc planting-service
# 查看日志
kubectl logs -f -l app=planting-service
# 扩容
kubectl scale deployment planting-service --replicas=5
# 回滚
kubectl rollout undo deployment/planting-service
```
---
## 数据库迁移
### 迁移策略
```
┌─────────────────────────────────────────────────────────────────┐
│ 数据库迁移流程 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 创建迁移脚本 (开发环境) │
│ npx prisma migrate dev --name add_new_feature │
│ │
│ 2. 代码审查迁移脚本 │
│ 检查 prisma/migrations/ 目录 │
│ │
│ 3. 测试环境验证 │
│ npx prisma migrate deploy │
│ │
│ 4. 生产环境部署 │
│ - 备份数据库 │
│ - 运行迁移 (npx prisma migrate deploy) │
│ - 部署新版本应用 │
└─────────────────────────────────────────────────────────────────┘
```
### 迁移命令
```bash
# 开发环境 - 创建迁移
npx prisma migrate dev --name add_new_feature
# 生产环境 - 应用迁移
npx prisma migrate deploy
# 查看迁移状态
npx prisma migrate status
# 重置数据库 (仅开发)
npx prisma migrate reset
```
### 迁移最佳实践
1. **向后兼容**: 新版本应用应兼容旧数据库 schema
2. **分步迁移**: 大型变更分多个小迁移执行
3. **备份优先**: 生产迁移前必须备份
4. **回滚脚本**: 准备对应的回滚 SQL
---
## 健康检查
### 端点说明
| 端点 | 用途 | 检查内容 |
|-----|------|---------|
| `/api/v1/health` | 存活检查 | 服务是否运行 |
| `/api/v1/health/ready` | 就绪检查 | 服务是否可接收请求 |
### 健康检查实现
```typescript
// src/api/controllers/health.controller.ts
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'planting-service',
};
}
@Get('ready')
async ready() {
// 可添加数据库连接检查
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
}
```
### 负载均衡器配置
**Nginx 配置**
```nginx
upstream planting_service {
server planting-service-1:3003;
server planting-service-2:3003;
server planting-service-3:3003;
}
server {
listen 80;
server_name api.rwadurian.com;
location /api/v1/planting {
proxy_pass http://planting_service;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# 健康检查
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
location /health {
proxy_pass http://planting_service/api/v1/health;
proxy_connect_timeout 5s;
proxy_read_timeout 5s;
}
}
```
---
## 监控与日志
### 日志配置
```typescript
// src/main.ts
import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: process.env.NODE_ENV === 'production'
? ['error', 'warn', 'log']
: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// ...
}
```
### 日志格式
```json
{
"timestamp": "2024-11-30T10:00:00.000Z",
"level": "info",
"context": "PlantingApplicationService",
"message": "Order created",
"metadata": {
"orderNo": "PO202411300001",
"userId": "1",
"treeCount": 5
}
}
```
### Prometheus 指标
```yaml
# prometheus/scrape_configs
- job_name: 'planting-service'
static_configs:
- targets: ['planting-service:3003']
metrics_path: '/metrics'
```
### Grafana 仪表板
关键指标:
- 请求速率 (requests/second)
- 响应时间 (p50, p95, p99)
- 错误率
- 数据库连接池状态
- 订单创建数
- 支付成功率
---
## 故障排查
### 常见问题
#### 1. 服务无法启动
```bash
# 检查日志
docker logs planting-service
# 常见原因
# - 数据库连接失败
# - 环境变量缺失
# - 端口冲突
```
#### 2. 数据库连接失败
```bash
# 检查连接
psql $DATABASE_URL -c "SELECT 1"
# 检查 Prisma
npx prisma db pull
```
#### 3. 内存不足
```bash
# 检查内存使用
docker stats planting-service
# 调整 Node.js 内存限制
NODE_OPTIONS="--max-old-space-size=4096" node dist/main
```
#### 4. 高延迟
```bash
# 检查数据库查询
# 启用 Prisma 查询日志
# 检查外部服务响应
curl -w "@curl-format.txt" http://wallet-service:3002/health
```
### 调试命令
```bash
# 进入容器
docker exec -it planting-service sh
# 检查网络
docker exec planting-service ping db
# 检查环境变量
docker exec planting-service env | grep DATABASE
# 实时日志
docker logs -f --tail 100 planting-service
```
---
## 回滚策略
### Docker 回滚
```bash
# 停止当前版本
docker-compose -f docker-compose.prod.yml down
# 启动上一版本
docker-compose -f docker-compose.prod.yml up -d --no-build
```
### Kubernetes 回滚
```bash
# 查看部署历史
kubectl rollout history deployment/planting-service
# 回滚到上一版本
kubectl rollout undo deployment/planting-service
# 回滚到指定版本
kubectl rollout undo deployment/planting-service --to-revision=2
# 查看回滚状态
kubectl rollout status deployment/planting-service
```
### 数据库回滚
```sql
-- 准备回滚脚本
-- prisma/rollback/20241130_rollback.sql
-- 示例:回滚列添加
ALTER TABLE "PlantingOrder" DROP COLUMN IF EXISTS "newColumn";
```
### 回滚检查清单
- [ ] 确认问题根因
- [ ] 通知相关团队
- [ ] 执行回滚操作
- [ ] 验证服务恢复
- [ ] 检查数据一致性
- [ ] 更新事故报告
---
## 部署检查清单
### 部署前
- [ ] 代码审查通过
- [ ] 所有测试通过
- [ ] 数据库迁移已测试
- [ ] 环境变量已配置
- [ ] 备份已完成
### 部署中
- [ ] 监控仪表板就绪
- [ ] 日志收集正常
- [ ] 渐进式部署 (金丝雀/蓝绿)
- [ ] 健康检查通过
### 部署后
- [ ] 功能验证
- [ ] 性能验证
- [ ] 错误率监控
- [ ] 用户反馈收集

View File

@ -0,0 +1,650 @@
# Planting Service 开发指南
## 目录
- [环境要求](#环境要求)
- [项目设置](#项目设置)
- [开发流程](#开发流程)
- [代码规范](#代码规范)
- [领域开发指南](#领域开发指南)
- [常用命令](#常用命令)
- [调试技巧](#调试技巧)
- [常见问题](#常见问题)
---
## 环境要求
### 必需工具
| 工具 | 版本 | 用途 |
|-----|------|------|
| Node.js | ≥ 20.x | 运行时 |
| npm | ≥ 10.x | 包管理 |
| PostgreSQL | ≥ 16.x | 数据库 |
| Docker | ≥ 24.x | 容器化 (可选) |
| Git | ≥ 2.x | 版本控制 |
### 推荐 IDE
- **VS Code** + 推荐插件:
- ESLint
- Prettier
- Prisma
- GitLens
- REST Client
---
## 项目设置
### 1. 克隆项目
```bash
git clone <repository-url>
cd backend/services/planting-service
```
### 2. 安装依赖
```bash
npm install
# 或使用 make
make install
```
### 3. 环境配置
复制环境配置文件:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
# 数据库
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
# JWT
JWT_SECRET="your-secret-key"
# 服务端口
PORT=3003
# 外部服务
WALLET_SERVICE_URL=http://localhost:3002
IDENTITY_SERVICE_URL=http://localhost:3001
REFERRAL_SERVICE_URL=http://localhost:3004
```
### 4. 数据库设置
```bash
# 生成 Prisma Client
npx prisma generate
# 运行数据库迁移
npx prisma migrate dev
# (可选) 打开 Prisma Studio
npx prisma studio
```
### 5. 启动开发服务器
```bash
npm run start:dev
# 或使用 make
make dev
```
服务将在 `http://localhost:3003` 启动。
---
## 开发流程
### Git 分支策略
```
main # 生产分支
├── develop # 开发分支
├── feature/* # 功能分支
├── bugfix/* # 修复分支
└── hotfix/* # 紧急修复分支
```
### 开发流程
1. **创建功能分支**
```bash
git checkout develop
git pull origin develop
git checkout -b feature/new-feature
```
2. **开发功能**
- 编写代码
- 编写测试
- 运行测试确保通过
3. **提交代码**
```bash
git add .
git commit -m "feat: add new feature"
```
4. **推送并创建 PR**
```bash
git push origin feature/new-feature
# 在 GitHub 创建 Pull Request
```
### 提交规范
使用 [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
**类型 (type)**:
- `feat`: 新功能
- `fix`: Bug 修复
- `docs`: 文档更新
- `style`: 代码格式
- `refactor`: 重构
- `test`: 测试
- `chore`: 构建/工具
**示例**:
```bash
feat(order): add province-city selection
fix(payment): fix balance check logic
docs(api): update API documentation
test(order): add integration tests
```
---
## 代码规范
### TypeScript 规范
```typescript
// 1. 使用明确的类型声明
function createOrder(userId: bigint, treeCount: number): PlantingOrder {
// ...
}
// 2. 使用 readonly 保护不可变数据
class TreeCount {
constructor(private readonly _value: number) {}
}
// 3. 使用接口定义契约
interface IPlantingOrderRepository {
save(order: PlantingOrder): Promise<void>;
findById(id: bigint): Promise<PlantingOrder | null>;
}
// 4. 使用枚举定义常量
enum PlantingOrderStatus {
CREATED = 'CREATED',
PAID = 'PAID',
}
```
### 命名规范
| 类型 | 规范 | 示例 |
|-----|------|------|
| 类名 | PascalCase | `PlantingOrder` |
| 接口 | I + PascalCase | `IPlantingOrderRepository` |
| 方法 | camelCase | `createOrder` |
| 常量 | UPPER_SNAKE_CASE | `MAX_TREE_COUNT` |
| 文件 | kebab-case | `planting-order.aggregate.ts` |
| 目录 | kebab-case | `value-objects` |
### 文件命名约定
```
*.aggregate.ts # 聚合根
*.entity.ts # 实体
*.vo.ts # 值对象
*.service.ts # 服务
*.repository.*.ts # 仓储
*.controller.ts # 控制器
*.dto.ts # 数据传输对象
*.spec.ts # 单元测试
*.integration.spec.ts # 集成测试
*.e2e-spec.ts # E2E 测试
*.mapper.ts # 映射器
*.guard.ts # 守卫
*.filter.ts # 过滤器
```
### ESLint + Prettier
项目已配置 ESLint 和 Prettier:
```bash
# 运行 lint
npm run lint
# 格式化代码
npm run format
```
---
## 领域开发指南
### 创建新的聚合根
```typescript
// src/domain/aggregates/new-aggregate.aggregate.ts
export interface NewAggregateData {
id?: bigint;
name: string;
// ... 其他字段
}
export class NewAggregate {
private _id?: bigint;
private _name: string;
private _domainEvents: DomainEvent[] = [];
private constructor(data: NewAggregateData) {
this._id = data.id;
this._name = data.name;
}
// 工厂方法 - 创建新实例
static create(name: string): NewAggregate {
const aggregate = new NewAggregate({ name });
aggregate.addDomainEvent(new NewAggregateCreatedEvent(aggregate));
return aggregate;
}
// 工厂方法 - 从持久化重建
static reconstitute(data: NewAggregateData): NewAggregate {
return new NewAggregate(data);
}
// 业务方法
doSomething(): void {
// 业务逻辑
this.addDomainEvent(new SomethingHappenedEvent(this));
}
// Getters
get id(): bigint | undefined { return this._id; }
get name(): string { return this._name; }
// 领域事件
private addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
getDomainEvents(): DomainEvent[] {
return [...this._domainEvents];
}
clearDomainEvents(): void {
this._domainEvents = [];
}
}
```
### 创建值对象
```typescript
// src/domain/value-objects/email.vo.ts
export class Email {
private readonly _value: string;
private constructor(value: string) {
this._value = value;
}
static create(value: string): Email {
if (!this.isValid(value)) {
throw new Error('Invalid email format');
}
return new Email(value);
}
private static isValid(value: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
get value(): string {
return this._value;
}
equals(other: Email): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}
```
### 创建领域服务
```typescript
// src/domain/services/pricing.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class PricingDomainService {
private readonly PRICE_PER_TREE = 2199;
calculateTotalPrice(treeCount: number): number {
return treeCount * this.PRICE_PER_TREE;
}
calculateDiscount(treeCount: number): number {
if (treeCount >= 100) return 0.05;
if (treeCount >= 50) return 0.03;
if (treeCount >= 10) return 0.01;
return 0;
}
}
```
### 创建仓储
**1. 定义接口 (领域层)**
```typescript
// src/domain/repositories/new-aggregate.repository.interface.ts
export const NEW_AGGREGATE_REPOSITORY = Symbol('INewAggregateRepository');
export interface INewAggregateRepository {
save(aggregate: NewAggregate): Promise<void>;
findById(id: bigint): Promise<NewAggregate | null>;
findByName(name: string): Promise<NewAggregate[]>;
}
```
**2. 实现仓储 (基础设施层)**
```typescript
// src/infrastructure/persistence/repositories/new-aggregate.repository.impl.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { INewAggregateRepository } from '../../../domain/repositories/new-aggregate.repository.interface';
@Injectable()
export class NewAggregateRepositoryImpl implements INewAggregateRepository {
constructor(private readonly prisma: PrismaService) {}
async save(aggregate: NewAggregate): Promise<void> {
const data = NewAggregateMapper.toPersistence(aggregate);
if (aggregate.id) {
await this.prisma.newAggregate.update({
where: { id: aggregate.id },
data,
});
} else {
const created = await this.prisma.newAggregate.create({ data });
aggregate.setId(created.id);
}
}
async findById(id: bigint): Promise<NewAggregate | null> {
const record = await this.prisma.newAggregate.findUnique({
where: { id },
});
return record ? NewAggregateMapper.toDomain(record) : null;
}
async findByName(name: string): Promise<NewAggregate[]> {
const records = await this.prisma.newAggregate.findMany({
where: { name: { contains: name } },
});
return records.map(NewAggregateMapper.toDomain);
}
}
```
**3. 注册到模块**
```typescript
// src/infrastructure/infrastructure.module.ts
@Module({
providers: [
{
provide: NEW_AGGREGATE_REPOSITORY,
useClass: NewAggregateRepositoryImpl,
},
],
exports: [NEW_AGGREGATE_REPOSITORY],
})
export class InfrastructureModule {}
```
---
## 常用命令
### Makefile 命令
```bash
# 开发
make install # 安装依赖
make dev # 启动开发服务器
make build # 构建项目
# 数据库
make prisma-generate # 生成 Prisma Client
make prisma-migrate # 运行迁移
make prisma-studio # 打开 Prisma Studio
make prisma-reset # 重置数据库
# 测试
make test-unit # 单元测试
make test-integration # 集成测试
make test-e2e # E2E 测试
make test-cov # 测试覆盖率
make test-all # 运行所有测试
# Docker
make docker-build # 构建 Docker 镜像
make docker-up # 启动容器
make docker-down # 停止容器
make test-docker-all # Docker 中运行测试
# 代码质量
make lint # 运行 ESLint
make format # 格式化代码
```
### npm 命令
```bash
npm run start # 启动服务
npm run start:dev # 开发模式
npm run start:debug # 调试模式
npm run build # 构建
npm run test # 运行测试
npm run test:watch # 监听模式测试
npm run test:cov # 覆盖率测试
npm run test:e2e # E2E 测试
npm run lint # 代码检查
npm run format # 代码格式化
```
---
## 调试技巧
### VS Code 调试配置
创建 `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug NestJS",
"type": "node",
"request": "launch",
"runtimeArgs": [
"--inspect-brk",
"-r",
"tsconfig-paths/register",
"-r",
"ts-node/register"
],
"args": ["${workspaceFolder}/src/main.ts"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"protocol": "inspector"
},
{
"name": "Debug Jest Tests",
"type": "node",
"request": "launch",
"runtimeArgs": [
"--inspect-brk",
"${workspaceRoot}/node_modules/.bin/jest",
"--runInBand"
],
"console": "integratedTerminal"
}
]
}
```
### Prisma 查询日志
`.env` 中启用:
```env
DATABASE_URL="postgresql://...?connection_limit=5"
```
`prisma.service.ts` 中配置:
```typescript
super({
log: ['query', 'info', 'warn', 'error'],
});
```
### 请求日志
使用 NestJS Logger:
```typescript
import { Logger } from '@nestjs/common';
@Injectable()
export class PlantingApplicationService {
private readonly logger = new Logger(PlantingApplicationService.name);
async createOrder(userId: bigint, treeCount: number) {
this.logger.log(`Creating order for user ${userId}, trees: ${treeCount}`);
// ...
this.logger.debug('Order created successfully', { orderNo });
}
}
```
---
## 常见问题
### Q: Prisma Client 未生成
```bash
# 解决方案
npx prisma generate
```
### Q: 数据库连接失败
检查:
1. PostgreSQL 服务是否启动
2. `.env` 中 DATABASE_URL 是否正确
3. 数据库是否存在
```bash
# 创建数据库
createdb rwadurian_planting
```
### Q: BigInt 序列化错误
在返回 JSON 时BigInt 需要转换为字符串:
```typescript
// 在响应 DTO 中
class OrderResponse {
@Transform(({ value }) => value.toString())
userId: string;
}
```
### Q: 测试时数据库冲突
使用独立的测试数据库:
```env
# .env.test
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test"
```
### Q: 循环依赖错误
使用 `forwardRef`:
```typescript
@Module({
imports: [forwardRef(() => OtherModule)],
})
export class MyModule {}
```
### Q: 热重载不工作
检查 `nest-cli.json`:
```json
{
"compilerOptions": {
"deleteOutDir": true,
"webpack": true,
"watchAssets": true
}
}
```
---
## 参考资料
- [NestJS 官方文档](https://docs.nestjs.com/)
- [Prisma 官方文档](https://www.prisma.io/docs/)
- [TypeScript 手册](https://www.typescriptlang.org/docs/)
- [领域驱动设计精粹](https://www.domainlanguage.com/ddd/)

View File

@ -0,0 +1,237 @@
# Planting Service 文档中心
## 概述
Planting Service 是 RWA Durian Queen 平台的核心微服务,负责处理榴莲树认种业务的完整生命周期。
### 核心功能
- **认种订单管理** - 创建、支付、取消订单
- **省市选择机制** - 5秒确认机制防止误操作
- **资金分配** - 10种目标的精确分配2199 USDT/棵)
- **持仓管理** - 用户认种持仓统计与追踪
- **资金池注入** - 5天批次管理机制
### 技术栈
| 技术 | 版本 | 用途 |
|-----|------|------|
| NestJS | 10.x | Web 框架 |
| TypeScript | 5.x | 开发语言 |
| PostgreSQL | 16 | 数据库 |
| Prisma | 5.x | ORM |
| Jest | 29.x | 测试框架 |
| Docker | 24.x | 容器化 |
---
## 文档索引
| 文档 | 描述 | 适用人员 |
|-----|------|---------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 系统架构设计 | 架构师、技术负责人 |
| [API.md](./API.md) | API 接口文档 | 前端开发、测试 |
| [DEVELOPMENT.md](./DEVELOPMENT.md) | 开发指南 | 后端开发 |
| [TESTING.md](./TESTING.md) | 测试文档 | 开发、测试 |
| [DEPLOYMENT.md](./DEPLOYMENT.md) | 部署文档 | DevOps、运维 |
---
## 快速开始
### 1. 环境准备
```bash
# 要求
Node.js >= 20.x
PostgreSQL >= 16
Docker >= 24.x (可选)
```
### 2. 安装依赖
```bash
npm install
```
### 3. 配置环境
```bash
cp .env.example .env
# 编辑 .env 文件配置数据库等
```
### 4. 初始化数据库
```bash
npx prisma generate
npx prisma migrate dev
```
### 5. 启动服务
```bash
# 开发模式
npm run start:dev
# 生产模式
npm run build
npm run start:prod
```
### 6. 访问服务
- 服务地址: http://localhost:3003
- API 文档: http://localhost:3003/api/docs
- 健康检查: http://localhost:3003/api/v1/health
---
## 常用命令
```bash
# 开发
make dev # 启动开发服务器
make build # 构建项目
# 测试
make test-unit # 单元测试
make test-integration # 集成测试
make test-e2e # E2E 测试
make test-cov # 覆盖率测试
make test-all # 所有测试
# 数据库
make prisma-generate # 生成 Prisma Client
make prisma-migrate # 运行迁移
make prisma-studio # 打开 Prisma Studio
# Docker
make docker-build # 构建镜像
make docker-up # 启动容器
make test-docker-all # Docker 测试
# 代码质量
make lint # 代码检查
make format # 代码格式化
```
---
## 项目结构
```
planting-service/
├── docs/ # 文档目录
│ ├── README.md # 文档索引
│ ├── ARCHITECTURE.md # 架构文档
│ ├── API.md # API 文档
│ ├── DEVELOPMENT.md # 开发指南
│ ├── TESTING.md # 测试文档
│ └── DEPLOYMENT.md # 部署文档
├── prisma/ # 数据库
│ ├── schema.prisma # 模型定义
│ └── migrations/ # 迁移文件
├── src/ # 源代码
│ ├── api/ # API 层
│ ├── application/ # 应用层
│ ├── domain/ # 领域层
│ ├── infrastructure/ # 基础设施层
│ └── main.ts # 入口
├── test/ # E2E 测试
├── docker-compose.yml # Docker 编排
├── Dockerfile # 生产镜像
└── Makefile # 构建脚本
```
---
## 业务规则概览
### 定价
- 单价: **2199 USDT/棵**
- 单次限制: 1-1000 棵
- 个人限制: 最多 1000 棵
### 资金分配
| 目标 | 比例 | 金额 (USDT) |
|-----|------|------------|
| 资金池 | 90% | 1979.1 |
| 运营 | 5% | 109.95 |
| 省代 | 0.6% | 13.194 |
| 市代 | 0.3% | 6.597 |
| 社区长 | 0.5% | 10.995 |
| 一级推荐 | 1.5% | 32.985 |
| 二级推荐 | 1.0% | 21.99 |
| 三级推荐 | 0.5% | 10.995 |
| 平台 | 0.3% | 6.597 |
| 储备 | 0.3% | 6.597 |
### 订单状态流转
```
CREATED → PROVINCE_CITY_SELECTED → PROVINCE_CITY_CONFIRMED
→ PAID → FUND_ALLOCATED → POOL_SCHEDULED
→ POOL_INJECTED → MINING_ENABLED
任意状态(PAID前) → CANCELLED
```
---
## 测试报告
### 测试概览
| 类型 | 数量 | 状态 |
|-----|------|-----|
| 单元测试 | 45+ | ✅ 通过 |
| 集成测试 | 12+ | ✅ 通过 |
| E2E 测试 | 17+ | ✅ 通过 |
### 覆盖率
| 模块 | 覆盖率 |
|-----|-------|
| 应用服务 | 89% |
| 领域服务 | 93% |
| 聚合根 | 69% |
---
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
### 提交规范
使用 [Conventional Commits](https://www.conventionalcommits.org/):
- `feat`: 新功能
- `fix`: Bug 修复
- `docs`: 文档更新
- `test`: 测试
- `refactor`: 重构
---
## 相关链接
- [NestJS 文档](https://docs.nestjs.com/)
- [Prisma 文档](https://www.prisma.io/docs/)
- [TypeScript 文档](https://www.typescriptlang.org/docs/)
---
## 联系方式
如有问题或建议,请通过以下方式联系:
- 项目 Issues
- 技术负责人邮箱

View File

@ -0,0 +1,896 @@
# Planting Service 测试文档
## 目录
- [测试策略](#测试策略)
- [测试架构](#测试架构)
- [单元测试](#单元测试)
- [集成测试](#集成测试)
- [端到端测试](#端到端测试)
- [测试覆盖率](#测试覆盖率)
- [Docker 测试](#docker-测试)
- [CI/CD 集成](#cicd-集成)
- [最佳实践](#最佳实践)
---
## 测试策略
### 测试金字塔
```
┌─────────┐
│ E2E │ ◄── 少量,验证完整流程
│ Tests │
─┴─────────┴─
┌─────────────┐
│ Integration │ ◄── 中等数量,验证模块协作
│ Tests │
─┴─────────────┴─
┌─────────────────┐
│ Unit Tests │ ◄── 大量,验证业务逻辑
│ │
└─────────────────┘
```
### 测试分布
| 测试类型 | 数量 | 覆盖范围 | 执行时间 |
|---------|------|---------|---------|
| 单元测试 | 45+ | 领域逻辑、值对象 | < 10s |
| 集成测试 | 12+ | 应用服务、用例 | < 30s |
| E2E 测试 | 17+ | API 端点、认证 | < 60s |
---
## 测试架构
### 目录结构
```
planting-service/
├── src/
│ ├── domain/
│ │ ├── aggregates/
│ │ │ ├── planting-order.aggregate.ts
│ │ │ └── planting-order.aggregate.spec.ts # 单元测试
│ │ ├── services/
│ │ │ ├── fund-allocation.service.ts
│ │ │ └── fund-allocation.service.spec.ts # 单元测试
│ │ └── value-objects/
│ │ ├── tree-count.vo.ts
│ │ └── tree-count.vo.spec.ts # 单元测试
│ └── application/
│ └── services/
│ ├── planting-application.service.ts
│ └── planting-application.service.integration.spec.ts # 集成测试
└── test/
├── app.e2e-spec.ts # E2E 测试
└── jest-e2e.json # E2E 配置
```
### 测试配置
**Jest 配置 (package.json)**
```json
{
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}
```
**E2E 配置 (test/jest-e2e.json)**
```json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/../src/$1"
}
}
```
---
## 单元测试
### 概述
单元测试专注于测试独立的业务逻辑单元,不依赖外部服务或数据库。
### 测试领域聚合
```typescript
// src/domain/aggregates/planting-order.aggregate.spec.ts
import { PlantingOrder } from './planting-order.aggregate';
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
describe('PlantingOrder', () => {
describe('create', () => {
it('应该创建有效的订单', () => {
const order = PlantingOrder.create(BigInt(1), 5);
expect(order.userId).toBe(BigInt(1));
expect(order.treeCount).toBe(5);
expect(order.totalAmount).toBe(5 * 2199);
expect(order.status).toBe(PlantingOrderStatus.CREATED);
});
it('应该生成唯一的订单号', () => {
const order1 = PlantingOrder.create(BigInt(1), 1);
const order2 = PlantingOrder.create(BigInt(1), 1);
expect(order1.orderNo).not.toBe(order2.orderNo);
expect(order1.orderNo).toMatch(/^PO\d{14}$/);
});
it('应该产生领域事件', () => {
const order = PlantingOrder.create(BigInt(1), 5);
const events = order.getDomainEvents();
expect(events).toHaveLength(1);
expect(events[0].constructor.name).toBe('PlantingOrderCreatedEvent');
});
});
describe('selectProvinceCity', () => {
it('应该允许在CREATED状态下选择省市', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
expect(order.provinceCode).toBe('440000');
expect(order.cityCode).toBe('440100');
});
it('不应该允许在非CREATED状态下选择省市', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.cancel();
expect(() => {
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
}).toThrow();
});
});
describe('confirmProvinceCity', () => {
it('应该在5秒后允许确认', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
expect(() => order.confirmProvinceCity()).not.toThrow();
expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
jest.useRealTimers();
});
it('应该在5秒内拒绝确认', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(3000); // 只过了3秒
expect(() => order.confirmProvinceCity()).toThrow('还需等待');
jest.useRealTimers();
});
});
});
```
### 测试值对象
```typescript
// src/domain/value-objects/tree-count.vo.spec.ts
import { TreeCount } from './tree-count.vo';
describe('TreeCount', () => {
describe('create', () => {
it('应该创建有效的数量', () => {
const count = TreeCount.create(5);
expect(count.value).toBe(5);
});
it('应该拒绝0或负数', () => {
expect(() => TreeCount.create(0)).toThrow('认种数量必须大于0');
expect(() => TreeCount.create(-1)).toThrow('认种数量必须大于0');
});
it('应该拒绝超过最大限制', () => {
expect(() => TreeCount.create(1001)).toThrow('单次认种数量不能超过1000');
});
it('应该拒绝小数', () => {
expect(() => TreeCount.create(1.5)).toThrow('认种数量必须为整数');
});
});
describe('checkLimit', () => {
it('应该正确检查限购', () => {
expect(TreeCount.checkLimit(900, 100)).toBe(true); // 刚好1000
expect(TreeCount.checkLimit(901, 100)).toBe(false); // 超过1000
});
});
});
```
### 测试领域服务
```typescript
// src/domain/services/fund-allocation.service.spec.ts
import { FundAllocationDomainService } from './fund-allocation.service';
describe('FundAllocationDomainService', () => {
let service: FundAllocationDomainService;
beforeEach(() => {
service = new FundAllocationDomainService();
});
describe('calculateAllocations', () => {
it('应该返回10种分配', () => {
const allocations = service.calculateAllocations(1, {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
});
expect(allocations).toHaveLength(10);
});
it('应该正确计算资金池分配', () => {
const allocations = service.calculateAllocations(1, {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
});
const poolAllocation = allocations.find(a => a.targetType === 'POOL');
expect(poolAllocation?.amount).toBe(1979.1); // 2199 * 90%
});
it('应该正确计算总金额', () => {
const allocations = service.calculateAllocations(5, {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
});
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
expect(total).toBeCloseTo(5 * 2199, 2);
});
});
});
```
### 运行单元测试
```bash
# 运行所有单元测试
npm run test
# 或
make test-unit
# 监听模式
npm run test:watch
# 运行特定文件
npm run test -- planting-order.aggregate.spec.ts
# 详细输出
npm run test -- --verbose
```
---
## 集成测试
### 概述
集成测试验证多个组件协同工作,使用 Mock 替代外部依赖。
### 应用服务集成测试
```typescript
// src/application/services/planting-application.service.integration.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PlantingApplicationService } from './planting-application.service';
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
import {
IPlantingOrderRepository,
PLANTING_ORDER_REPOSITORY,
} from '../../domain/repositories/planting-order.repository.interface';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
describe('PlantingApplicationService (Integration)', () => {
let service: PlantingApplicationService;
let orderRepository: jest.Mocked<IPlantingOrderRepository>;
let walletService: jest.Mocked<WalletServiceClient>;
let referralService: jest.Mocked<ReferralServiceClient>;
beforeEach(async () => {
// 创建 Mocks
orderRepository = {
save: jest.fn(),
findById: jest.fn(),
findByOrderNo: jest.fn(),
findByUserId: jest.fn(),
countTreesByUserId: jest.fn(),
} as any;
walletService = {
getBalance: jest.fn(),
deductForPlanting: jest.fn(),
allocateFunds: jest.fn(),
} as any;
referralService = {
getReferralContext: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
PlantingApplicationService,
FundAllocationDomainService,
{ provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository },
{ provide: WalletServiceClient, useValue: walletService },
{ provide: ReferralServiceClient, useValue: referralService },
],
}).compile();
service = module.get<PlantingApplicationService>(PlantingApplicationService);
});
describe('createOrder', () => {
it('应该成功创建订单', async () => {
// Arrange
orderRepository.countTreesByUserId.mockResolvedValue(0);
walletService.getBalance.mockResolvedValue({
userId: '1',
available: 100000,
locked: 0,
currency: 'USDT',
});
orderRepository.save.mockResolvedValue(undefined);
// Act
const result = await service.createOrder(BigInt(1), 5);
// Assert
expect(result.treeCount).toBe(5);
expect(result.totalAmount).toBe(5 * 2199);
expect(orderRepository.save).toHaveBeenCalled();
});
it('应该拒绝超过限购数量', async () => {
orderRepository.countTreesByUserId.mockResolvedValue(950);
await expect(service.createOrder(BigInt(1), 100))
.rejects.toThrow('超过个人最大认种数量限制');
});
it('应该拒绝余额不足', async () => {
orderRepository.countTreesByUserId.mockResolvedValue(0);
walletService.getBalance.mockResolvedValue({
userId: '1',
available: 100, // 余额不足
locked: 0,
currency: 'USDT',
});
await expect(service.createOrder(BigInt(1), 5))
.rejects.toThrow('余额不足');
});
});
describe('payOrder', () => {
it('应该成功支付订单并完成资金分配', async () => {
jest.useFakeTimers();
// 准备订单
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
// 设置 mocks
orderRepository.findByOrderNo.mockResolvedValue(order);
walletService.deductForPlanting.mockResolvedValue(true);
referralService.getReferralContext.mockResolvedValue({
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
});
walletService.allocateFunds.mockResolvedValue(true);
// Act
const result = await service.payOrder(order.orderNo, BigInt(1));
// Assert
expect(result.allocations.length).toBe(10);
expect(walletService.deductForPlanting).toHaveBeenCalled();
expect(walletService.allocateFunds).toHaveBeenCalled();
jest.useRealTimers();
});
});
});
```
### 运行集成测试
```bash
# 运行集成测试
npm run test -- --testPathPattern=integration --runInBand
# 或
make test-integration
```
---
## 端到端测试
### 概述
E2E 测试通过 HTTP 请求验证完整的 API 功能。
### E2E 测试实现
```typescript
// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
import * as request from 'supertest';
import { HealthController } from '../src/api/controllers/health.controller';
import { PlantingOrderController } from '../src/api/controllers/planting-order.controller';
import { PlantingApplicationService } from '../src/application/services/planting-application.service';
import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard';
// Mock 服务
const mockPlantingService = {
createOrder: jest.fn(),
selectProvinceCity: jest.fn(),
payOrder: jest.fn(),
getUserOrders: jest.fn(),
getUserPosition: jest.fn(),
};
describe('PlantingController (e2e) - Unauthorized', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [HealthController, PlantingOrderController],
providers: [
{ provide: PlantingApplicationService, useValue: mockPlantingService },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => false })
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('/health (GET)', () => {
it('应该返回健康状态', () => {
return request(app.getHttpServer())
.get('/api/v1/health')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ok');
});
});
});
describe('/planting/orders (POST)', () => {
it('应该拒绝未认证的请求', () => {
return request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 1 })
.expect(403);
});
});
});
describe('PlantingController (e2e) - Authorized', () => {
let app: INestApplication;
beforeAll(async () => {
// 设置认证通过的 Guard
const mockAuthGuard = {
canActivate: (context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
req.user = { id: '1', username: 'testuser' };
return true;
},
};
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [HealthController, PlantingOrderController],
providers: [
{ provide: PlantingApplicationService, useValue: mockPlantingService },
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockAuthGuard)
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.init();
});
describe('/planting/orders (POST)', () => {
it('应该成功创建订单', async () => {
mockPlantingService.createOrder.mockResolvedValue({
orderNo: 'PO202411300001',
treeCount: 5,
totalAmount: 10995,
status: 'CREATED',
});
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 5 })
.expect(201);
expect(response.body.orderNo).toBe('PO202411300001');
});
it('应该验证 treeCount 必须为正整数', async () => {
await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 0 })
.expect(400);
});
});
});
```
### 运行 E2E 测试
```bash
# 运行 E2E 测试
npm run test:e2e
# 或
make test-e2e
```
---
## 测试覆盖率
### 生成覆盖率报告
```bash
npm run test:cov
# 或
make test-cov
```
### 覆盖率指标
| 指标 | 目标 | 当前 |
|-----|------|-----|
| 语句覆盖率 | > 80% | 34% |
| 分支覆盖率 | > 70% | 17% |
| 函数覆盖率 | > 80% | 36% |
| 行覆盖率 | > 80% | 34% |
### 核心模块覆盖率
| 模块 | 覆盖率 | 说明 |
|-----|-------|------|
| planting-application.service | 89% | 核心应用服务 |
| fund-allocation.service | 93% | 资金分配服务 |
| planting-order.aggregate | 69% | 订单聚合 |
| tree-count.vo | 100% | 数量值对象 |
### 覆盖率报告位置
覆盖率报告生成在 `coverage/` 目录:
```
coverage/
├── lcov-report/
│ └── index.html # HTML 报告
├── lcov.info # LCOV 格式
└── clover.xml # Clover 格式
```
---
## Docker 测试
### Docker 测试配置
**docker-compose.test.yml**
```yaml
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rwadurian_planting_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
test:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test
JWT_SECRET: test-jwt-secret
volumes:
- ./src:/app/src
- ./test:/app/test
- ./prisma:/app/prisma
```
**Dockerfile.test**
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma/
RUN npx prisma generate
COPY . .
RUN npm run build
CMD ["npm", "run", "test"]
```
### 运行 Docker 测试
```bash
# 运行所有 Docker 测试
make test-docker-all
# 分步运行
docker-compose -f docker-compose.test.yml up -d db
docker-compose -f docker-compose.test.yml run --rm test npm run test
docker-compose -f docker-compose.test.yml down -v
```
---
## CI/CD 集成
### GitHub Actions 示例
```yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rwadurian_planting_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Run migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
- name: Run unit tests
run: npm run test
- name: Run E2E tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
```
---
## 最佳实践
### 1. 测试命名规范
```typescript
describe('被测试的单元', () => {
describe('方法名', () => {
it('应该 + 预期行为', () => {
// ...
});
});
});
```
### 2. AAA 模式
```typescript
it('应该创建有效的订单', () => {
// Arrange - 准备
const userId = BigInt(1);
const treeCount = 5;
// Act - 执行
const order = PlantingOrder.create(userId, treeCount);
// Assert - 断言
expect(order.treeCount).toBe(5);
});
```
### 3. 避免测试实现细节
```typescript
// 不好 - 测试实现细节
it('应该调用 repository.save', () => {
await service.createOrder(1, 5);
expect(repository.save).toHaveBeenCalledTimes(1);
});
// 好 - 测试行为
it('应该返回创建的订单', () => {
const result = await service.createOrder(1, 5);
expect(result.orderNo).toBeDefined();
expect(result.treeCount).toBe(5);
});
```
### 4. 独立的测试数据
```typescript
// 每个测试使用独立数据
beforeEach(() => {
jest.clearAllMocks();
});
// 使用工厂函数创建测试数据
function createTestOrder(overrides = {}) {
return PlantingOrder.create(BigInt(1), 1, {
...defaultProps,
...overrides,
});
}
```
### 5. Mock 外部依赖
```typescript
// 只 mock 必要的外部依赖
const walletService = {
getBalance: jest.fn().mockResolvedValue({ available: 100000 }),
deductForPlanting: jest.fn().mockResolvedValue(true),
};
```
---
## 常用命令速查
```bash
# 单元测试
make test-unit # 运行单元测试
npm run test -- --watch # 监听模式
npm run test -- --verbose # 详细输出
npm run test -- file.spec # 运行特定文件
# 集成测试
make test-integration # 运行集成测试
# E2E 测试
make test-e2e # 运行 E2E 测试
# 覆盖率
make test-cov # 生成覆盖率报告
# Docker 测试
make test-docker-all # Docker 中运行所有测试
# 所有测试
make test-all # 运行所有测试
```

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
{
"name": "planting-service",
"version": "1.0.0",
"description": "RWA Durian Queen Planting Service",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/axios": "^3.0.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@prisma/client": "^5.7.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"jsonwebtoken": "^9.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^2.0.12",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.7.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -0,0 +1,197 @@
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])
@@index([userId])
@@index([orderNo])
@@index([status])
@@index([poolInjectionBatchId])
@@index([selectedProvince, selectedCity])
@@index([createdAt])
@@index([paidAt])
@@map("planting_orders")
}
// ============================================
// 资金分配明细表 (行为表, 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])
@@index([orderId])
@@index([targetType, targetAccountId])
@@index([createdAt])
@@map("fund_allocations")
}
// ============================================
// 用户持仓表 (状态表)
// ============================================
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[]
@@index([userId])
@@index([totalTreeCount])
@@map("planting_positions")
}
// ============================================
// 持仓省市分布表
// ============================================
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])
@@index([userId])
@@index([provinceCode])
@@index([cityCode])
@@map("position_province_city_distribution")
}
// ============================================
// 底池注入批次表 (状态表)
// ============================================
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[]
@@index([batchNo])
@@index([startDate, endDate])
@@index([status])
@@index([scheduledInjectionTime])
@@map("pool_injection_batches")
}
// ============================================
// 认种事件表 (行为表, 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")
@@index([aggregateType, aggregateId])
@@index([eventType])
@@index([userId])
@@index([occurredAt])
@@map("planting_events")
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PlantingOrderController } from './controllers/planting-order.controller';
import { PlantingPositionController } from './controllers/planting-position.controller';
import { HealthController } from './controllers/health.controller';
import { ApplicationModule } from '../application/application.module';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Module({
imports: [ApplicationModule],
controllers: [
PlantingOrderController,
PlantingPositionController,
HealthController,
],
providers: [JwtAuthGuard],
})
export class ApiModule {}

View File

@ -0,0 +1,27 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('健康检查')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务正常' })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'planting-service',
};
}
@Get('ready')
@ApiOperation({ summary: '就绪检查' })
@ApiResponse({ status: 200, description: '服务就绪' })
ready() {
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,3 @@
export * from './planting-order.controller';
export * from './planting-position.controller';
export * from './health.controller';

View File

@ -0,0 +1,187 @@
import {
Controller,
Post,
Get,
Body,
Param,
Query,
UseGuards,
Req,
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { PlantingApplicationService } from '../../application/services/planting-application.service';
import { CreatePlantingOrderDto } from '../dto/request/create-planting-order.dto';
import { SelectProvinceCityDto } from '../dto/request/select-province-city.dto';
import { PaginationDto } from '../dto/request/pagination.dto';
import {
CreateOrderResponse,
SelectProvinceCityResponse,
ConfirmProvinceCityResponse,
PayOrderResponse,
PlantingOrderResponse,
} from '../dto/response/planting-order.response';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
interface AuthenticatedRequest {
user: { id: string };
}
@ApiTags('认种订单')
@ApiBearerAuth()
@Controller('planting')
@UseGuards(JwtAuthGuard)
export class PlantingOrderController {
constructor(
private readonly plantingService: PlantingApplicationService,
) {}
@Post('orders')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建认种订单' })
@ApiResponse({
status: HttpStatus.CREATED,
description: '订单创建成功',
type: CreateOrderResponse,
})
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '参数错误' })
@ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '未授权' })
async createOrder(
@Req() req: AuthenticatedRequest,
@Body() dto: CreatePlantingOrderDto,
): Promise<CreateOrderResponse> {
const userId = BigInt(req.user.id);
return this.plantingService.createOrder(userId, dto.treeCount);
}
@Post('orders/:orderNo/select-province-city')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '选择省市' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({
status: HttpStatus.OK,
description: '省市选择成功',
type: SelectProvinceCityResponse,
})
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '参数错误' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
async selectProvinceCity(
@Req() req: AuthenticatedRequest,
@Param('orderNo') orderNo: string,
@Body() dto: SelectProvinceCityDto,
): Promise<SelectProvinceCityResponse> {
const userId = BigInt(req.user.id);
return this.plantingService.selectProvinceCity(
orderNo,
userId,
dto.provinceCode,
dto.provinceName,
dto.cityCode,
dto.cityName,
);
}
@Post('orders/:orderNo/confirm-province-city')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '确认省市选择5秒后调用' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({
status: HttpStatus.OK,
description: '省市确认成功',
type: ConfirmProvinceCityResponse,
})
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '还需等待5秒' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
async confirmProvinceCity(
@Req() req: AuthenticatedRequest,
@Param('orderNo') orderNo: string,
): Promise<ConfirmProvinceCityResponse> {
const userId = BigInt(req.user.id);
return this.plantingService.confirmProvinceCity(orderNo, userId);
}
@Post('orders/:orderNo/pay')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '支付认种订单' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({
status: HttpStatus.OK,
description: '支付成功',
type: PayOrderResponse,
})
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '余额不足或状态错误' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
async payOrder(
@Req() req: AuthenticatedRequest,
@Param('orderNo') orderNo: string,
): Promise<PayOrderResponse> {
const userId = BigInt(req.user.id);
return this.plantingService.payOrder(orderNo, userId);
}
@Get('orders')
@ApiOperation({ summary: '查询我的订单列表' })
@ApiResponse({
status: HttpStatus.OK,
description: '订单列表',
type: [PlantingOrderResponse],
})
async getUserOrders(
@Req() req: AuthenticatedRequest,
@Query() pagination: PaginationDto,
): Promise<PlantingOrderResponse[]> {
const userId = BigInt(req.user.id);
return this.plantingService.getUserOrders(
userId,
pagination.page,
pagination.pageSize,
);
}
@Get('orders/:orderNo')
@ApiOperation({ summary: '查询订单详情' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({
status: HttpStatus.OK,
description: '订单详情',
type: PlantingOrderResponse,
})
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
async getOrderDetail(
@Req() req: AuthenticatedRequest,
@Param('orderNo') orderNo: string,
): Promise<PlantingOrderResponse> {
const userId = BigInt(req.user.id);
const order = await this.plantingService.getOrderDetail(orderNo, userId);
if (!order) {
throw new NotFoundException('订单不存在');
}
return order;
}
@Post('orders/:orderNo/cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '取消订单' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({
status: HttpStatus.OK,
description: '取消成功',
})
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '订单状态不允许取消' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
async cancelOrder(
@Req() req: AuthenticatedRequest,
@Param('orderNo') orderNo: string,
): Promise<{ success: boolean }> {
const userId = BigInt(req.user.id);
return this.plantingService.cancelOrder(orderNo, userId);
}
}

View File

@ -0,0 +1,44 @@
import {
Controller,
Get,
UseGuards,
Req,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { PlantingApplicationService } from '../../application/services/planting-application.service';
import { PlantingPositionResponse } from '../dto/response/planting-position.response';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
interface AuthenticatedRequest {
user: { id: string };
}
@ApiTags('认种持仓')
@ApiBearerAuth()
@Controller('planting')
@UseGuards(JwtAuthGuard)
export class PlantingPositionController {
constructor(
private readonly plantingService: PlantingApplicationService,
) {}
@Get('position')
@ApiOperation({ summary: '查询我的持仓' })
@ApiResponse({
status: HttpStatus.OK,
description: '持仓信息',
type: PlantingPositionResponse,
})
async getUserPosition(
@Req() req: AuthenticatedRequest,
): Promise<PlantingPositionResponse> {
const userId = BigInt(req.user.id);
return this.plantingService.getUserPosition(userId);
}
}

View File

@ -0,0 +1,2 @@
export * from './request';
export * from './response';

View File

@ -0,0 +1,15 @@
import { IsInt, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreatePlantingOrderDto {
@ApiProperty({
description: '认种数量',
minimum: 1,
maximum: 100,
example: 1,
})
@IsInt({ message: '认种数量必须是整数' })
@Min(1, { message: '认种数量最少为1棵' })
@Max(100, { message: '单次认种数量最多为100棵' })
treeCount: number;
}

View File

@ -0,0 +1,3 @@
export * from './create-planting-order.dto';
export * from './select-province-city.dto';
export * from './pagination.dto';

View File

@ -0,0 +1,29 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationDto {
@ApiPropertyOptional({
description: '页码',
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
minimum: 1,
maximum: 100,
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 10;
}

View File

@ -0,0 +1,40 @@
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SelectProvinceCityDto {
@ApiProperty({
description: '省份代码',
example: '440000',
})
@IsString({ message: '省份代码必须是字符串' })
@IsNotEmpty({ message: '省份代码不能为空' })
@MaxLength(10, { message: '省份代码长度不能超过10' })
provinceCode: string;
@ApiProperty({
description: '省份名称',
example: '广东省',
})
@IsString({ message: '省份名称必须是字符串' })
@IsNotEmpty({ message: '省份名称不能为空' })
@MaxLength(50, { message: '省份名称长度不能超过50' })
provinceName: string;
@ApiProperty({
description: '城市代码',
example: '440100',
})
@IsString({ message: '城市代码必须是字符串' })
@IsNotEmpty({ message: '城市代码不能为空' })
@MaxLength(10, { message: '城市代码长度不能超过10' })
cityCode: string;
@ApiProperty({
description: '城市名称',
example: '广州市',
})
@IsString({ message: '城市名称必须是字符串' })
@IsNotEmpty({ message: '城市名称不能为空' })
@MaxLength(50, { message: '城市名称长度不能超过50' })
cityName: string;
}

View File

@ -0,0 +1,2 @@
export * from './planting-order.response';
export * from './planting-position.response';

View File

@ -0,0 +1,80 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PlantingOrderResponse {
@ApiProperty({ description: '订单号' })
orderNo: string;
@ApiProperty({ description: '认种数量' })
treeCount: number;
@ApiProperty({ description: '订单总金额' })
totalAmount: number;
@ApiProperty({ description: '订单状态' })
status: string;
@ApiPropertyOptional({ description: '省份代码' })
provinceCode?: string | null;
@ApiPropertyOptional({ description: '城市代码' })
cityCode?: string | null;
@ApiProperty({ description: '是否已开启挖矿' })
isMiningEnabled: boolean;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
}
export class CreateOrderResponse {
@ApiProperty({ description: '订单号' })
orderNo: string;
@ApiProperty({ description: '认种数量' })
treeCount: number;
@ApiProperty({ description: '订单总金额' })
totalAmount: number;
@ApiProperty({ description: '订单状态' })
status: string;
}
export class SelectProvinceCityResponse {
@ApiProperty({ description: '是否成功' })
success: boolean;
@ApiProperty({ description: '选择时间' })
selectedAt: Date;
}
export class ConfirmProvinceCityResponse {
@ApiProperty({ description: '是否成功' })
success: boolean;
}
export class PayOrderResponse {
@ApiProperty({ description: '订单号' })
orderNo: string;
@ApiProperty({ description: '订单状态' })
status: string;
@ApiProperty({
description: '资金分配详情',
type: 'array',
items: {
type: 'object',
properties: {
targetType: { type: 'string' },
amount: { type: 'number' },
targetAccountId: { type: 'string', nullable: true },
},
},
})
allocations: Array<{
targetType: string;
amount: number;
targetAccountId: string | null;
}>;
}

View File

@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
export class PositionDistributionResponse {
@ApiProperty({ description: '省份代码', nullable: true })
provinceCode: string | null;
@ApiProperty({ description: '城市代码', nullable: true })
cityCode: string | null;
@ApiProperty({ description: '认种数量' })
treeCount: number;
}
export class PlantingPositionResponse {
@ApiProperty({ description: '总认种数量' })
totalTreeCount: number;
@ApiProperty({ description: '有效认种数量(已开启挖矿)' })
effectiveTreeCount: number;
@ApiProperty({ description: '待生效认种数量' })
pendingTreeCount: number;
@ApiProperty({
description: '省市分布',
type: [PositionDistributionResponse],
})
distributions: PositionDistributionResponse[];
}

View File

@ -0,0 +1 @@
export * from './jwt-auth.guard';

View File

@ -0,0 +1,50 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export interface JwtPayload {
sub: string;
userId: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('Missing authentication token');
}
try {
const secret = this.configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET not configured');
}
const payload = jwt.verify(token, secret) as JwtPayload;
request.user = {
id: payload.userId || payload.sub,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid authentication token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1,4 @@
export * from './controllers';
export * from './dto';
export * from './guards';
export * from './api.module';

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { DomainModule } from './domain/domain.module';
import { ApplicationModule } from './application/application.module';
import { ApiModule } from './api/api.module';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
import configs from './config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.development', '.env'],
load: configs,
}),
InfrastructureModule,
DomainModule,
ApplicationModule,
ApiModule,
],
providers: [
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PlantingApplicationService } from './services/planting-application.service';
import { PoolInjectionService } from './services/pool-injection.service';
import { DomainModule } from '../domain/domain.module';
@Module({
imports: [DomainModule],
providers: [PlantingApplicationService, PoolInjectionService],
exports: [PlantingApplicationService, PoolInjectionService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,2 @@
export * from './services';
export * from './application.module';

View File

@ -0,0 +1,2 @@
export * from './planting-application.service';
export * from './pool-injection.service';

View File

@ -0,0 +1,303 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PlantingApplicationService } from './planting-application.service';
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
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 {
IPoolInjectionBatchRepository,
POOL_INJECTION_BATCH_REPOSITORY,
} from '../../domain/repositories/pool-injection-batch.repository.interface';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
import { PlantingOrder } from '../../domain/aggregates/planting-order.aggregate';
import { PlantingPosition } from '../../domain/aggregates/planting-position.aggregate';
import { PoolInjectionBatch } from '../../domain/aggregates/pool-injection-batch.aggregate';
import { PlantingOrderStatus } from '../../domain/value-objects/planting-order-status.enum';
describe('PlantingApplicationService (Integration)', () => {
let service: PlantingApplicationService;
let orderRepository: jest.Mocked<IPlantingOrderRepository>;
let positionRepository: jest.Mocked<IPlantingPositionRepository>;
let batchRepository: jest.Mocked<IPoolInjectionBatchRepository>;
let walletService: jest.Mocked<WalletServiceClient>;
let referralService: jest.Mocked<ReferralServiceClient>;
beforeEach(async () => {
// Create mocks
orderRepository = {
save: jest.fn(),
findById: jest.fn(),
findByOrderNo: jest.fn(),
findByUserId: jest.fn(),
findByStatus: jest.fn(),
findPendingPoolScheduling: jest.fn(),
findByBatchId: jest.fn(),
findReadyForMining: jest.fn(),
countTreesByUserId: jest.fn(),
countByUserId: jest.fn(),
};
positionRepository = {
save: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
getOrCreate: jest.fn(),
};
batchRepository = {
save: jest.fn(),
findById: jest.fn(),
findByBatchNo: jest.fn(),
findByStatus: jest.fn(),
findCurrentBatch: jest.fn(),
findOrCreateCurrentBatch: jest.fn(),
findScheduledBatchesReadyForInjection: jest.fn(),
};
walletService = {
getBalance: jest.fn(),
deductForPlanting: jest.fn(),
allocateFunds: jest.fn(),
injectToPool: jest.fn(),
} as any;
referralService = {
getReferralContext: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
PlantingApplicationService,
FundAllocationDomainService,
{ provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository },
{ provide: PLANTING_POSITION_REPOSITORY, useValue: positionRepository },
{ provide: POOL_INJECTION_BATCH_REPOSITORY, useValue: batchRepository },
{ provide: WalletServiceClient, useValue: walletService },
{ provide: ReferralServiceClient, useValue: referralService },
],
}).compile();
service = module.get<PlantingApplicationService>(PlantingApplicationService);
});
describe('createOrder', () => {
it('应该成功创建订单', async () => {
const userId = BigInt(1);
const treeCount = 5;
orderRepository.countTreesByUserId.mockResolvedValue(0);
walletService.getBalance.mockResolvedValue({
userId: '1',
available: 100000,
locked: 0,
currency: 'USDT',
});
orderRepository.save.mockResolvedValue(undefined);
const result = await service.createOrder(userId, treeCount);
expect(result.treeCount).toBe(5);
expect(result.totalAmount).toBe(5 * 2199);
expect(result.status).toBe(PlantingOrderStatus.CREATED);
expect(orderRepository.save).toHaveBeenCalled();
});
it('应该拒绝超过限购数量', async () => {
const userId = BigInt(1);
const treeCount = 100;
orderRepository.countTreesByUserId.mockResolvedValue(950);
await expect(service.createOrder(userId, treeCount)).rejects.toThrow(
'超过个人最大认种数量限制',
);
});
it('应该拒绝余额不足', async () => {
const userId = BigInt(1);
const treeCount = 5;
orderRepository.countTreesByUserId.mockResolvedValue(0);
walletService.getBalance.mockResolvedValue({
userId: '1',
available: 100,
locked: 0,
currency: 'USDT',
});
await expect(service.createOrder(userId, treeCount)).rejects.toThrow(
'余额不足',
);
});
});
describe('selectProvinceCity', () => {
it('应该成功选择省市', async () => {
const order = PlantingOrder.create(BigInt(1), 1);
orderRepository.findByOrderNo.mockResolvedValue(order);
orderRepository.save.mockResolvedValue(undefined);
const result = await service.selectProvinceCity(
order.orderNo,
BigInt(1),
'440000',
'广东省',
'440100',
'广州市',
);
expect(result.success).toBe(true);
expect(orderRepository.save).toHaveBeenCalled();
});
it('应该拒绝不存在的订单', async () => {
orderRepository.findByOrderNo.mockResolvedValue(null);
await expect(
service.selectProvinceCity(
'invalid',
BigInt(1),
'440000',
'广东省',
'440100',
'广州市',
),
).rejects.toThrow('订单不存在');
});
it('应该拒绝无权操作的订单', async () => {
const order = PlantingOrder.create(BigInt(1), 1);
orderRepository.findByOrderNo.mockResolvedValue(order);
await expect(
service.selectProvinceCity(
order.orderNo,
BigInt(999),
'440000',
'广东省',
'440100',
'广州市',
),
).rejects.toThrow('无权操作此订单');
});
});
describe('confirmProvinceCity', () => {
it('应该在5秒后成功确认省市', async () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
orderRepository.findByOrderNo.mockResolvedValue(order);
orderRepository.save.mockResolvedValue(undefined);
const result = await service.confirmProvinceCity(order.orderNo, BigInt(1));
expect(result.success).toBe(true);
expect(orderRepository.save).toHaveBeenCalled();
jest.useRealTimers();
});
});
describe('payOrder', () => {
it('应该成功支付订单并完成资金分配', async () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
const position = PlantingPosition.create(BigInt(1));
const batch = PoolInjectionBatch.create(new Date());
(batch as any)._id = BigInt(1);
orderRepository.findByOrderNo.mockResolvedValue(order);
walletService.deductForPlanting.mockResolvedValue(true);
referralService.getReferralContext.mockResolvedValue({
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
});
walletService.allocateFunds.mockResolvedValue(true);
positionRepository.getOrCreate.mockResolvedValue(position);
batchRepository.findOrCreateCurrentBatch.mockResolvedValue(batch);
orderRepository.save.mockResolvedValue(undefined);
positionRepository.save.mockResolvedValue(undefined);
batchRepository.save.mockResolvedValue(undefined);
const result = await service.payOrder(order.orderNo, BigInt(1));
expect(result.status).toBe(PlantingOrderStatus.POOL_SCHEDULED);
expect(result.allocations.length).toBe(10);
expect(walletService.deductForPlanting).toHaveBeenCalled();
expect(walletService.allocateFunds).toHaveBeenCalled();
jest.useRealTimers();
});
});
describe('getUserOrders', () => {
it('应该返回用户订单列表', async () => {
const order1 = PlantingOrder.create(BigInt(1), 1);
const order2 = PlantingOrder.create(BigInt(1), 2);
orderRepository.findByUserId.mockResolvedValue([order1, order2]);
const result = await service.getUserOrders(BigInt(1), 1, 10);
expect(result.length).toBe(2);
expect(result[0].treeCount).toBe(1);
expect(result[1].treeCount).toBe(2);
});
});
describe('getUserPosition', () => {
it('应该返回用户持仓信息', async () => {
const position = PlantingPosition.create(BigInt(1));
position.addPlanting(5, '440000', '440100');
positionRepository.findByUserId.mockResolvedValue(position);
const result = await service.getUserPosition(BigInt(1));
expect(result.totalTreeCount).toBe(5);
expect(result.pendingTreeCount).toBe(5);
expect(result.distributions.length).toBe(1);
});
it('应该返回空持仓当用户无记录', async () => {
positionRepository.findByUserId.mockResolvedValue(null);
const result = await service.getUserPosition(BigInt(1));
expect(result.totalTreeCount).toBe(0);
expect(result.effectiveTreeCount).toBe(0);
expect(result.pendingTreeCount).toBe(0);
});
});
describe('cancelOrder', () => {
it('应该成功取消未支付订单', async () => {
const order = PlantingOrder.create(BigInt(1), 1);
orderRepository.findByOrderNo.mockResolvedValue(order);
orderRepository.save.mockResolvedValue(undefined);
const result = await service.cancelOrder(order.orderNo, BigInt(1));
expect(result.success).toBe(true);
expect(orderRepository.save).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,370 @@
import { Injectable, Inject, Logger } 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 {
IPoolInjectionBatchRepository,
POOL_INJECTION_BATCH_REPOSITORY,
} from '../../domain/repositories/pool-injection-batch.repository.interface';
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
import { PRICE_PER_TREE } from '../../domain/value-objects/fund-allocation-target-type.enum';
// 个人最大认种数量限制
const MAX_TREES_PER_USER = 1000;
export interface CreateOrderResult {
orderNo: string;
treeCount: number;
totalAmount: number;
status: string;
}
export interface OrderListItem {
orderNo: string;
treeCount: number;
totalAmount: number;
status: string;
provinceCode?: string | null;
cityCode?: string | null;
isMiningEnabled: boolean;
createdAt: Date;
}
export interface UserPositionResult {
totalTreeCount: number;
effectiveTreeCount: number;
pendingTreeCount: number;
distributions: Array<{
provinceCode: string | null;
cityCode: string | null;
treeCount: number;
}>;
}
@Injectable()
export class PlantingApplicationService {
private readonly logger = new Logger(PlantingApplicationService.name);
constructor(
@Inject(PLANTING_ORDER_REPOSITORY)
private readonly orderRepository: IPlantingOrderRepository,
@Inject(PLANTING_POSITION_REPOSITORY)
private readonly positionRepository: IPlantingPositionRepository,
@Inject(POOL_INJECTION_BATCH_REPOSITORY)
private readonly batchRepository: IPoolInjectionBatchRepository,
private readonly fundAllocationService: FundAllocationDomainService,
private readonly walletService: WalletServiceClient,
private readonly referralService: ReferralServiceClient,
) {}
/**
*
*/
async createOrder(
userId: bigint,
treeCount: number,
): Promise<CreateOrderResult> {
this.logger.log(`Creating order for user ${userId}, treeCount: ${treeCount}`);
// 风控检查
await this.checkRiskControl(userId, treeCount);
// 检查余额
const balance = await this.walletService.getBalance(userId.toString());
const requiredAmount = treeCount * PRICE_PER_TREE;
if (balance.available < requiredAmount) {
throw new Error(
`余额不足: 需要 ${requiredAmount} USDT, 当前可用 ${balance.available} USDT`,
);
}
// 创建订单
const order = PlantingOrder.create(userId, treeCount);
await this.orderRepository.save(order);
this.logger.log(`Order created: ${order.orderNo}`);
return {
orderNo: order.orderNo,
treeCount: order.treeCount.value,
totalAmount: order.totalAmount,
status: order.status,
};
}
/**
*
*/
async selectProvinceCity(
orderNo: string,
userId: bigint,
provinceCode: string,
provinceName: string,
cityCode: string,
cityName: string,
): Promise<{ success: boolean; selectedAt: Date }> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order) {
throw new Error('订单不存在');
}
if (order.userId !== userId) {
throw new Error('无权操作此订单');
}
order.selectProvinceCity(provinceCode, provinceName, cityCode, cityName);
await this.orderRepository.save(order);
return {
success: true,
selectedAt: order.provinceCitySelection!.selectedAt,
};
}
/**
* (5)
*/
async confirmProvinceCity(
orderNo: string,
userId: bigint,
): Promise<{ success: boolean }> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order) {
throw new Error('订单不存在');
}
if (order.userId !== userId) {
throw new Error('无权操作此订单');
}
order.confirmProvinceCity();
await this.orderRepository.save(order);
return { success: true };
}
/**
*
*/
async payOrder(
orderNo: string,
userId: bigint,
): Promise<{
orderNo: string;
status: string;
allocations: Array<{
targetType: string;
amount: number;
targetAccountId: string | null;
}>;
}> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order) {
throw new Error('订单不存在');
}
if (order.userId !== userId) {
throw new Error('无权操作此订单');
}
const selection = order.provinceCitySelection;
if (!selection) {
throw new Error('请先选择并确认省市');
}
// 调用钱包服务扣款
await this.walletService.deductForPlanting({
userId: userId.toString(),
amount: order.totalAmount,
orderId: order.orderNo,
});
// 标记已支付
order.markAsPaid();
// 获取推荐链上下文
const referralContext = await this.referralService.getReferralContext(
userId.toString(),
selection.provinceCode,
selection.cityCode,
);
// 计算资金分配
const allocations = this.fundAllocationService.calculateAllocations(
order,
referralContext,
);
// 分配资金
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,
selection.provinceCode,
selection.cityCode,
);
await this.positionRepository.save(position);
// 安排底池注入批次
await this.schedulePoolInjection(order);
this.logger.log(`Order paid: ${order.orderNo}`);
return {
orderNo: order.orderNo,
status: order.status,
allocations: allocations.map((a) => a.toDTO()),
};
}
/**
*
*/
async getUserOrders(
userId: bigint,
page: number = 1,
pageSize: number = 10,
): Promise<OrderListItem[]> {
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,
provinceCode: o.provinceCitySelection?.provinceCode,
cityCode: o.provinceCitySelection?.cityCode,
isMiningEnabled: o.isMiningEnabled,
createdAt: o.createdAt,
}));
}
/**
*
*/
async getUserPosition(userId: bigint): Promise<UserPositionResult> {
const position = await this.positionRepository.findByUserId(userId);
if (!position) {
return {
totalTreeCount: 0,
effectiveTreeCount: 0,
pendingTreeCount: 0,
distributions: [],
};
}
return {
totalTreeCount: position.totalTreeCount,
effectiveTreeCount: position.effectiveTreeCount,
pendingTreeCount: position.pendingTreeCount,
distributions: position.distributions.map((d) => ({
provinceCode: d.provinceCode,
cityCode: d.cityCode,
treeCount: d.treeCount,
})),
};
}
/**
*
*/
async getOrderDetail(
orderNo: string,
userId: bigint,
): Promise<OrderListItem | null> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order || order.userId !== userId) {
return null;
}
return {
orderNo: order.orderNo,
treeCount: order.treeCount.value,
totalAmount: order.totalAmount,
status: order.status,
provinceCode: order.provinceCitySelection?.provinceCode,
cityCode: order.provinceCitySelection?.cityCode,
isMiningEnabled: order.isMiningEnabled,
createdAt: order.createdAt,
};
}
/**
*
*/
async cancelOrder(
orderNo: string,
userId: bigint,
): Promise<{ success: boolean }> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order) {
throw new Error('订单不存在');
}
if (order.userId !== userId) {
throw new Error('无权操作此订单');
}
order.cancel();
await this.orderRepository.save(order);
return { success: true };
}
/**
*
*/
private async checkRiskControl(
userId: bigint,
treeCount: number,
): Promise<void> {
// 检查用户限购
const existingCount = await this.orderRepository.countTreesByUserId(userId);
if (existingCount + treeCount > MAX_TREES_PER_USER) {
throw new Error(
`超过个人最大认种数量限制: 已认种 ${existingCount} 棵, 本次 ${treeCount} 棵, 上限 ${MAX_TREES_PER_USER}`,
);
}
}
/**
*
*/
private async schedulePoolInjection(order: PlantingOrder): Promise<void> {
const batch = await this.batchRepository.findOrCreateCurrentBatch();
const poolAmount = this.fundAllocationService.getPoolInjectionAmount(
order.treeCount.value,
);
batch.addOrder(poolAmount);
await this.batchRepository.save(batch);
// 计算注入时间(批次结束后)
const scheduledTime = new Date(batch.endDate);
scheduledTime.setHours(scheduledTime.getHours() + 1);
order.schedulePoolInjection(batch.id!, scheduledTime);
await this.orderRepository.save(order);
}
}

View File

@ -0,0 +1,163 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
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 {
IPoolInjectionBatchRepository,
POOL_INJECTION_BATCH_REPOSITORY,
} from '../../domain/repositories/pool-injection-batch.repository.interface';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
import { BatchStatus } from '../../domain/value-objects/batch-status.enum';
@Injectable()
export class PoolInjectionService {
private readonly logger = new Logger(PoolInjectionService.name);
constructor(
@Inject(PLANTING_ORDER_REPOSITORY)
private readonly orderRepository: IPlantingOrderRepository,
@Inject(PLANTING_POSITION_REPOSITORY)
private readonly positionRepository: IPlantingPositionRepository,
@Inject(POOL_INJECTION_BATCH_REPOSITORY)
private readonly batchRepository: IPoolInjectionBatchRepository,
private readonly walletService: WalletServiceClient,
) {}
/**
*
*
*/
async processScheduledBatches(): Promise<void> {
const batches =
await this.batchRepository.findScheduledBatchesReadyForInjection();
for (const batch of batches) {
try {
await this.processBatch(batch.id!);
} catch (error) {
this.logger.error(
`Failed to process batch ${batch.batchNo}`,
error,
);
}
}
}
/**
*
*/
async processBatch(batchId: bigint): Promise<void> {
const batch = await this.batchRepository.findById(batchId);
if (!batch) {
throw new Error('批次不存在');
}
if (batch.status !== BatchStatus.SCHEDULED) {
throw new Error('批次状态不正确');
}
this.logger.log(`Processing batch ${batch.batchNo}`);
// 标记为注入中
batch.startInjection();
await this.batchRepository.save(batch);
try {
// 调用钱包服务注入底池
const result = await this.walletService.injectToPool(
batch.batchNo,
batch.totalAmount,
);
// 完成注入
batch.completeInjection(result.txHash);
await this.batchRepository.save(batch);
// 更新批次内所有订单状态
const orders = await this.orderRepository.findByBatchId(batchId);
for (const order of orders) {
order.confirmPoolInjection(result.txHash);
await this.orderRepository.save(order);
// 开启挖矿
order.enableMining();
await this.orderRepository.save(order);
// 更新用户持仓为有效
const position = await this.positionRepository.findByUserId(
order.userId,
);
if (position) {
position.activateMining(order.treeCount.value);
await this.positionRepository.save(position);
}
}
this.logger.log(
`Batch ${batch.batchNo} processed successfully, txHash: ${result.txHash}`,
);
} catch (error) {
this.logger.error(`Failed to inject batch ${batch.batchNo}`, error);
throw error;
}
}
/**
*
*
*/
async schedulePendingBatches(): Promise<void> {
const batches = await this.batchRepository.findByStatus(BatchStatus.PENDING);
const now = new Date();
for (const batch of batches) {
// 如果批次已过期,安排注入
if (batch.endDate < now) {
const scheduledTime = new Date();
scheduledTime.setMinutes(scheduledTime.getMinutes() + 5);
batch.schedule(scheduledTime);
await this.batchRepository.save(batch);
this.logger.log(
`Batch ${batch.batchNo} scheduled for injection at ${scheduledTime}`,
);
}
}
}
/**
*
*/
async getBatchStatus(
batchId: bigint,
): Promise<{
batchNo: string;
status: string;
orderCount: number;
totalAmount: number;
scheduledInjectionTime: Date | null;
actualInjectionTime: Date | null;
injectionTxHash: string | null;
} | null> {
const batch = await this.batchRepository.findById(batchId);
if (!batch) {
return null;
}
return {
batchNo: batch.batchNo,
status: batch.status,
orderCount: batch.orderCount,
totalAmount: batch.totalAmount,
scheduledInjectionTime: batch.scheduledInjectionTime,
actualInjectionTime: batch.actualInjectionTime,
injectionTxHash: batch.injectionTxHash,
};
}
}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.APP_PORT || '3003', 10),
serviceName: 'planting-service',
}));

View File

@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('external', () => ({
walletServiceUrl:
process.env.WALLET_SERVICE_URL || 'http://localhost:3002',
identityServiceUrl:
process.env.IDENTITY_SERVICE_URL || 'http://localhost:3001',
referralServiceUrl:
process.env.REFERRAL_SERVICE_URL || 'http://localhost:3004',
}));

View File

@ -0,0 +1,5 @@
import appConfig from './app.config';
import jwtConfig from './jwt.config';
import externalConfig from './external.config';
export default [appConfig, jwtConfig, externalConfig];

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'default-secret-change-me',
}));

View File

@ -0,0 +1,3 @@
export * from './planting-order.aggregate';
export * from './planting-position.aggregate';
export * from './pool-injection-batch.aggregate';

View File

@ -0,0 +1,214 @@
import { PlantingOrder } from './planting-order.aggregate';
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
import { FundAllocation } from '../value-objects/fund-allocation.vo';
import { FundAllocationTargetType } from '../value-objects/fund-allocation-target-type.enum';
describe('PlantingOrder', () => {
describe('create', () => {
it('应该成功创建认种订单', () => {
const order = PlantingOrder.create(BigInt(1), 5);
expect(order.orderNo).toMatch(/^PLT/);
expect(order.userId).toBe(BigInt(1));
expect(order.treeCount.value).toBe(5);
expect(order.totalAmount).toBe(5 * 2199);
expect(order.status).toBe(PlantingOrderStatus.CREATED);
expect(order.domainEvents.length).toBe(1);
expect(order.domainEvents[0].type).toBe('PlantingOrderCreated');
});
it('应该拒绝0棵树', () => {
expect(() => PlantingOrder.create(BigInt(1), 0)).toThrow(
'认种数量必须大于0',
);
});
it('应该拒绝负数', () => {
expect(() => PlantingOrder.create(BigInt(1), -1)).toThrow(
'认种数量必须大于0',
);
});
});
describe('selectProvinceCity', () => {
it('应该成功选择省市', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
expect(order.provinceCitySelection).not.toBeNull();
expect(order.provinceCitySelection?.provinceCode).toBe('440000');
expect(order.provinceCitySelection?.cityCode).toBe('440100');
expect(order.provinceCitySelection?.isConfirmed).toBe(false);
});
it('应该允许在确认前修改省市', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
order.selectProvinceCity('110000', '北京市', '110100', '北京市');
expect(order.provinceCitySelection?.provinceCode).toBe('110000');
});
});
describe('confirmProvinceCity', () => {
it('应该在5秒后成功确认省市', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
expect(order.provinceCitySelection?.isConfirmed).toBe(true);
jest.useRealTimers();
});
it('应该在5秒前拒绝确认', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
expect(() => order.confirmProvinceCity()).toThrow('请等待5秒确认时间');
});
it('应该拒绝未选择省市的确认', () => {
const order = PlantingOrder.create(BigInt(1), 1);
expect(() => order.confirmProvinceCity()).toThrow('请先选择省市');
});
});
describe('markAsPaid', () => {
it('应该在省市确认后成功标记为已支付', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
order.markAsPaid();
expect(order.status).toBe(PlantingOrderStatus.PAID);
expect(order.paidAt).not.toBeNull();
jest.useRealTimers();
});
it('应该拒绝未确认省市的支付', () => {
const order = PlantingOrder.create(BigInt(1), 1);
expect(() => order.markAsPaid()).toThrow('订单状态错误');
});
});
describe('allocateFunds', () => {
it('应该成功分配资金', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
order.markAsPaid();
const allocations = [
new FundAllocation(FundAllocationTargetType.COST_ACCOUNT, 400, 'ACC1'),
new FundAllocation(
FundAllocationTargetType.OPERATION_ACCOUNT,
300,
'ACC2',
),
new FundAllocation(
FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
9,
'ACC3',
),
new FundAllocation(
FundAllocationTargetType.REFERRAL_RIGHTS,
500,
'ACC4',
),
new FundAllocation(
FundAllocationTargetType.PROVINCE_AREA_RIGHTS,
15,
'ACC5',
),
new FundAllocation(
FundAllocationTargetType.PROVINCE_TEAM_RIGHTS,
20,
'ACC6',
),
new FundAllocation(
FundAllocationTargetType.CITY_AREA_RIGHTS,
35,
'ACC7',
),
new FundAllocation(
FundAllocationTargetType.CITY_TEAM_RIGHTS,
40,
'ACC8',
),
new FundAllocation(
FundAllocationTargetType.COMMUNITY_RIGHTS,
80,
'ACC9',
),
new FundAllocation(FundAllocationTargetType.RWAD_POOL, 800, 'ACC10'),
];
order.allocateFunds(allocations);
expect(order.status).toBe(PlantingOrderStatus.FUND_ALLOCATED);
expect(order.fundAllocations.length).toBe(10);
jest.useRealTimers();
});
it('应该拒绝金额不匹配的分配', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
order.markAsPaid();
const allocations = [
new FundAllocation(FundAllocationTargetType.COST_ACCOUNT, 100, 'ACC1'),
];
expect(() => order.allocateFunds(allocations)).toThrow(
'资金分配总额不匹配',
);
jest.useRealTimers();
});
});
describe('cancel', () => {
it('应该成功取消未支付的订单', () => {
const order = PlantingOrder.create(BigInt(1), 1);
order.cancel();
expect(order.status).toBe(PlantingOrderStatus.CANCELLED);
});
it('应该拒绝取消已支付的订单', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
order.markAsPaid();
expect(() => order.cancel()).toThrow('只有未支付的订单才能取消');
jest.useRealTimers();
});
});
});

View File

@ -0,0 +1,413 @@
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';
import { PRICE_PER_TREE } from '../value-objects/fund-allocation-target-type.enum';
import { DomainEvent } from '../events/domain-event.interface';
import { PlantingOrderCreatedEvent } from '../events/planting-order-created.event';
import { ProvinceCityConfirmedEvent } from '../events/province-city-confirmed.event';
import { PlantingOrderPaidEvent } from '../events/planting-order-paid.event';
import { FundsAllocatedEvent } from '../events/funds-allocated.event';
import { PoolInjectedEvent } from '../events/pool-injected.event';
import { MiningEnabledEvent } from '../events/mining-enabled.event';
export interface PlantingOrderData {
id?: bigint;
orderNo: string;
userId: bigint;
treeCount: number;
totalAmount: number;
status: PlantingOrderStatus;
selectedProvince?: string | null;
selectedCity?: string | null;
provinceCitySelectedAt?: Date | null;
provinceCityConfirmedAt?: Date | null;
poolInjectionBatchId?: bigint | null;
poolInjectionScheduledTime?: Date | null;
poolInjectionActualTime?: Date | null;
poolInjectionTxHash?: string | null;
miningEnabledAt?: Date | null;
createdAt?: Date;
paidAt?: Date | null;
fundAllocatedAt?: Date | null;
}
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: DomainEvent[] = [];
private constructor(
orderNo: string,
userId: bigint,
treeCount: TreeCount,
totalAmount: number,
createdAt?: Date,
) {
this._id = null;
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 = 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 poolInjectionScheduledTime(): Date | null {
return this._poolInjectionScheduledTime;
}
get poolInjectionActualTime(): Date | null {
return this._poolInjectionActualTime;
}
get poolInjectionTxHash(): string | null {
return this._poolInjectionTxHash;
}
get miningEnabledAt(): Date | null {
return this._miningEnabledAt;
}
get isMiningEnabled(): boolean {
return this._miningEnabledAt !== null;
}
get createdAt(): Date {
return this._createdAt;
}
get paidAt(): Date | null {
return this._paidAt;
}
get fundAllocatedAt(): Date | null {
return this._fundAllocatedAt;
}
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
/**
*
*/
static create(userId: bigint, treeCount: number): PlantingOrder {
if (treeCount <= 0) {
throw new Error('认种数量必须大于0');
}
const orderNo = `PLT${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
const tree = TreeCount.create(treeCount);
const totalAmount = treeCount * PRICE_PER_TREE;
const order = new PlantingOrder(orderNo, userId, tree, totalAmount);
// 发布领域事件
order._domainEvents.push(
new PlantingOrderCreatedEvent(orderNo, {
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(
new ProvinceCityConfirmedEvent(this.orderNo, {
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(
new PlantingOrderPaidEvent(this.orderNo, {
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(
new FundsAllocatedEvent(this.orderNo, {
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(
new PoolInjectedEvent(this.orderNo, {
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(
new MiningEnabledEvent(this.orderNo, {
orderNo: this.orderNo,
userId: this.userId.toString(),
treeCount: this.treeCount.value,
}),
);
}
/**
*
*/
cancel(): void {
if (
this._status !== PlantingOrderStatus.CREATED &&
this._status !== PlantingOrderStatus.PROVINCE_CITY_CONFIRMED
) {
throw new Error('只有未支付的订单才能取消');
}
this._status = PlantingOrderStatus.CANCELLED;
}
/**
*
*/
clearDomainEvents(): void {
this._domainEvents = [];
}
private ensureStatus(...allowedStatuses: PlantingOrderStatus[]): void {
if (!allowedStatuses.includes(this._status)) {
throw new Error(
`订单状态错误: 当前 ${this._status}, 期望 ${allowedStatuses.join(' 或 ')}`,
);
}
}
/**
* ID
*/
setId(id: bigint): void {
if (this._id !== null) {
throw new Error('ID已设置不可修改');
}
this._id = id;
}
/**
*
*/
static reconstitute(data: PlantingOrderData): PlantingOrder {
const order = new PlantingOrder(
data.orderNo,
data.userId,
TreeCount.create(data.treeCount),
data.totalAmount,
data.createdAt,
);
if (data.id) {
order._id = data.id;
}
order._status = data.status;
order._paidAt = data.paidAt || null;
order._fundAllocatedAt = data.fundAllocatedAt || null;
order._poolInjectionBatchId = data.poolInjectionBatchId || null;
order._poolInjectionScheduledTime = data.poolInjectionScheduledTime || null;
order._poolInjectionActualTime = data.poolInjectionActualTime || null;
order._poolInjectionTxHash = data.poolInjectionTxHash || null;
order._miningEnabledAt = data.miningEnabledAt || null;
if (data.selectedProvince && data.selectedCity) {
order._provinceCitySelection = ProvinceCitySelection.reconstitute(
data.selectedProvince,
'',
data.selectedCity,
'',
data.provinceCitySelectedAt || new Date(),
data.provinceCityConfirmedAt || null,
);
}
return order;
}
/**
*
*/
toPersistence(): PlantingOrderData {
return {
id: this._id || undefined,
orderNo: this._orderNo,
userId: this._userId,
treeCount: this._treeCount.value,
totalAmount: this._totalAmount,
status: this._status,
selectedProvince: this._provinceCitySelection?.provinceCode || null,
selectedCity: this._provinceCitySelection?.cityCode || null,
provinceCitySelectedAt: this._provinceCitySelection?.selectedAt || null,
provinceCityConfirmedAt: this._provinceCitySelection?.confirmedAt || null,
poolInjectionBatchId: this._poolInjectionBatchId,
poolInjectionScheduledTime: this._poolInjectionScheduledTime,
poolInjectionActualTime: this._poolInjectionActualTime,
poolInjectionTxHash: this._poolInjectionTxHash,
miningEnabledAt: this._miningEnabledAt,
createdAt: this._createdAt,
paidAt: this._paidAt,
fundAllocatedAt: this._fundAllocatedAt,
};
}
}

View File

@ -0,0 +1,235 @@
export interface PositionDistributionData {
id?: bigint;
userId: bigint;
provinceCode: string | null;
cityCode: string | null;
treeCount: number;
}
export interface PlantingPositionData {
id?: bigint;
userId: bigint;
totalTreeCount: number;
effectiveTreeCount: number;
pendingTreeCount: number;
firstMiningStartAt?: Date | null;
distributions?: PositionDistributionData[];
}
export class PositionDistribution {
private _id: bigint | null;
private readonly _userId: bigint;
private readonly _provinceCode: string | null;
private readonly _cityCode: string | null;
private _treeCount: number;
constructor(
userId: bigint,
provinceCode: string | null,
cityCode: string | null,
treeCount: number = 0,
id?: bigint,
) {
this._id = id || null;
this._userId = userId;
this._provinceCode = provinceCode;
this._cityCode = cityCode;
this._treeCount = treeCount;
}
get id(): bigint | null {
return this._id;
}
get userId(): bigint {
return this._userId;
}
get provinceCode(): string | null {
return this._provinceCode;
}
get cityCode(): string | null {
return this._cityCode;
}
get treeCount(): number {
return this._treeCount;
}
addTrees(count: number): void {
if (count <= 0) {
throw new Error('添加数量必须大于0');
}
this._treeCount += count;
}
setId(id: bigint): void {
this._id = id;
}
matchesLocation(provinceCode: string | null, cityCode: string | null): boolean {
return this._provinceCode === provinceCode && this._cityCode === cityCode;
}
}
export class PlantingPosition {
private _id: bigint | null;
private readonly _userId: bigint;
private _totalTreeCount: number;
private _effectiveTreeCount: number;
private _pendingTreeCount: number;
private _firstMiningStartAt: Date | null;
private _distributions: PositionDistribution[];
private constructor(
userId: bigint,
totalTreeCount: number = 0,
effectiveTreeCount: number = 0,
pendingTreeCount: number = 0,
) {
this._id = null;
this._userId = userId;
this._totalTreeCount = totalTreeCount;
this._effectiveTreeCount = effectiveTreeCount;
this._pendingTreeCount = pendingTreeCount;
this._firstMiningStartAt = null;
this._distributions = [];
}
// Getters
get id(): bigint | null {
return this._id;
}
get userId(): bigint {
return this._userId;
}
get totalTreeCount(): number {
return this._totalTreeCount;
}
get effectiveTreeCount(): number {
return this._effectiveTreeCount;
}
get pendingTreeCount(): number {
return this._pendingTreeCount;
}
get firstMiningStartAt(): Date | null {
return this._firstMiningStartAt;
}
get distributions(): ReadonlyArray<PositionDistribution> {
return this._distributions;
}
/**
*
*/
static create(userId: bigint): PlantingPosition {
return new PlantingPosition(userId);
}
/**
*
*/
addPlanting(
treeCount: number,
provinceCode: string | null,
cityCode: string | null,
): void {
if (treeCount <= 0) {
throw new Error('认种数量必须大于0');
}
// 更新总数和待生效数
this._totalTreeCount += treeCount;
this._pendingTreeCount += treeCount;
// 更新省市分布
let distribution = this._distributions.find((d) =>
d.matchesLocation(provinceCode, cityCode),
);
if (distribution) {
distribution.addTrees(treeCount);
} else {
distribution = new PositionDistribution(
this._userId,
provinceCode,
cityCode,
treeCount,
);
this._distributions.push(distribution);
}
}
/**
*
*/
activateMining(treeCount: number): void {
if (treeCount > this._pendingTreeCount) {
throw new Error('激活数量不能超过待生效数量');
}
this._pendingTreeCount -= treeCount;
this._effectiveTreeCount += treeCount;
if (!this._firstMiningStartAt) {
this._firstMiningStartAt = new Date();
}
}
/**
* ID
*/
setId(id: bigint): void {
this._id = id;
}
/**
*
*/
static reconstitute(data: PlantingPositionData): PlantingPosition {
const position = new PlantingPosition(
data.userId,
data.totalTreeCount,
data.effectiveTreeCount,
data.pendingTreeCount,
);
if (data.id) {
position._id = data.id;
}
position._firstMiningStartAt = data.firstMiningStartAt || null;
if (data.distributions) {
position._distributions = data.distributions.map(
(d) =>
new PositionDistribution(
d.userId,
d.provinceCode,
d.cityCode,
d.treeCount,
d.id,
),
);
}
return position;
}
/**
*
*/
toPersistence(): PlantingPositionData {
return {
id: this._id || undefined,
userId: this._userId,
totalTreeCount: this._totalTreeCount,
effectiveTreeCount: this._effectiveTreeCount,
pendingTreeCount: this._pendingTreeCount,
firstMiningStartAt: this._firstMiningStartAt,
distributions: this._distributions.map((d) => ({
id: d.id || undefined,
userId: d.userId,
provinceCode: d.provinceCode,
cityCode: d.cityCode,
treeCount: d.treeCount,
})),
};
}
}

View File

@ -0,0 +1,194 @@
import { BatchStatus } from '../value-objects/batch-status.enum';
export interface PoolInjectionBatchData {
id?: bigint;
batchNo: string;
startDate: Date;
endDate: Date;
orderCount: number;
totalAmount: number;
status: BatchStatus;
scheduledInjectionTime?: Date | null;
actualInjectionTime?: Date | null;
injectionTxHash?: string | null;
}
export class PoolInjectionBatch {
private _id: bigint | null;
private readonly _batchNo: string;
private readonly _startDate: Date;
private readonly _endDate: Date;
private _orderCount: number;
private _totalAmount: number;
private _status: BatchStatus;
private _scheduledInjectionTime: Date | null;
private _actualInjectionTime: Date | null;
private _injectionTxHash: string | null;
private constructor(
batchNo: string,
startDate: Date,
endDate: Date,
orderCount: number = 0,
totalAmount: number = 0,
) {
this._id = null;
this._batchNo = batchNo;
this._startDate = startDate;
this._endDate = endDate;
this._orderCount = orderCount;
this._totalAmount = totalAmount;
this._status = BatchStatus.PENDING;
this._scheduledInjectionTime = null;
this._actualInjectionTime = null;
this._injectionTxHash = null;
}
// Getters
get id(): bigint | null {
return this._id;
}
get batchNo(): string {
return this._batchNo;
}
get startDate(): Date {
return this._startDate;
}
get endDate(): Date {
return this._endDate;
}
get orderCount(): number {
return this._orderCount;
}
get totalAmount(): number {
return this._totalAmount;
}
get status(): BatchStatus {
return this._status;
}
get scheduledInjectionTime(): Date | null {
return this._scheduledInjectionTime;
}
get actualInjectionTime(): Date | null {
return this._actualInjectionTime;
}
get injectionTxHash(): string | null {
return this._injectionTxHash;
}
/**
*
* 5
*/
static create(startDate: Date): PoolInjectionBatch {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 5);
const batchNo = `BATCH${startDate.getFullYear()}${String(startDate.getMonth() + 1).padStart(2, '0')}${String(startDate.getDate()).padStart(2, '0')}`;
return new PoolInjectionBatch(batchNo, startDate, endDate);
}
/**
*
*/
addOrder(poolAmount: number): void {
if (this._status !== BatchStatus.PENDING) {
throw new Error('只有待处理状态的批次才能添加订单');
}
this._orderCount += 1;
this._totalAmount += poolAmount;
}
/**
*
*/
schedule(injectionTime: Date): void {
if (this._status !== BatchStatus.PENDING) {
throw new Error('只有待处理状态的批次才能安排注入');
}
this._scheduledInjectionTime = injectionTime;
this._status = BatchStatus.SCHEDULED;
}
/**
*
*/
startInjection(): void {
if (this._status !== BatchStatus.SCHEDULED) {
throw new Error('只有已排期状态的批次才能开始注入');
}
this._status = BatchStatus.INJECTING;
}
/**
*
*/
completeInjection(txHash: string): void {
if (this._status !== BatchStatus.INJECTING) {
throw new Error('只有注入中状态的批次才能完成注入');
}
this._actualInjectionTime = new Date();
this._injectionTxHash = txHash;
this._status = BatchStatus.INJECTED;
}
/**
*
*/
isDateInWindow(date: Date): boolean {
return date >= this._startDate && date <= this._endDate;
}
/**
* ID
*/
setId(id: bigint): void {
this._id = id;
}
/**
*
*/
static reconstitute(data: PoolInjectionBatchData): PoolInjectionBatch {
const batch = new PoolInjectionBatch(
data.batchNo,
data.startDate,
data.endDate,
data.orderCount,
data.totalAmount,
);
if (data.id) {
batch._id = data.id;
}
batch._status = data.status;
batch._scheduledInjectionTime = data.scheduledInjectionTime || null;
batch._actualInjectionTime = data.actualInjectionTime || null;
batch._injectionTxHash = data.injectionTxHash || null;
return batch;
}
/**
*
*/
toPersistence(): PoolInjectionBatchData {
return {
id: this._id || undefined,
batchNo: this._batchNo,
startDate: this._startDate,
endDate: this._endDate,
orderCount: this._orderCount,
totalAmount: this._totalAmount,
status: this._status,
scheduledInjectionTime: this._scheduledInjectionTime,
actualInjectionTime: this._actualInjectionTime,
injectionTxHash: this._injectionTxHash,
};
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FundAllocationDomainService } from './services/fund-allocation.service';
@Module({
providers: [FundAllocationDomainService],
exports: [FundAllocationDomainService],
})
export class DomainModule {}

View File

@ -0,0 +1,7 @@
export interface DomainEvent {
type: string;
aggregateId: string;
aggregateType: string;
occurredAt: Date;
data: Record<string, unknown>;
}

View File

@ -0,0 +1,18 @@
import { DomainEvent } from './domain-event.interface';
import { FundAllocationDTO } from '../value-objects/fund-allocation.vo';
export class FundsAllocatedEvent implements DomainEvent {
readonly type = 'FundsAllocated';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
allocations: FundAllocationDTO[];
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,7 @@
export * from './domain-event.interface';
export * from './planting-order-created.event';
export * from './province-city-confirmed.event';
export * from './planting-order-paid.event';
export * from './funds-allocated.event';
export * from './pool-injected.event';
export * from './mining-enabled.event';

View File

@ -0,0 +1,18 @@
import { DomainEvent } from './domain-event.interface';
export class MiningEnabledEvent implements DomainEvent {
readonly type = 'MiningEnabled';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
userId: string;
treeCount: number;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,19 @@
import { DomainEvent } from './domain-event.interface';
export class PlantingOrderCreatedEvent implements DomainEvent {
readonly type = 'PlantingOrderCreated';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
userId: string;
treeCount: number;
totalAmount: number;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,21 @@
import { DomainEvent } from './domain-event.interface';
export class PlantingOrderPaidEvent implements DomainEvent {
readonly type = 'PlantingOrderPaid';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
userId: string;
treeCount: number;
totalAmount: number;
provinceCode: string;
cityCode: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,19 @@
import { DomainEvent } from './domain-event.interface';
export class PoolInjectedEvent implements DomainEvent {
readonly type = 'PoolInjected';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
userId: string;
amount: number;
txHash: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,21 @@
import { DomainEvent } from './domain-event.interface';
export class ProvinceCityConfirmedEvent implements DomainEvent {
readonly type = 'ProvinceCityConfirmed';
readonly aggregateType = 'PlantingOrder';
readonly occurredAt: Date;
constructor(
public readonly aggregateId: string,
public readonly data: {
orderNo: string;
userId: string;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
},
) {
this.occurredAt = new Date();
}
}

View File

@ -0,0 +1,6 @@
export * from './aggregates';
export * from './events';
export * from './repositories';
export * from './services';
export * from './value-objects';
export * from './domain.module';

View File

@ -0,0 +1,3 @@
export * from './planting-order.repository.interface';
export * from './planting-position.repository.interface';
export * from './pool-injection-batch.repository.interface';

View File

@ -0,0 +1,24 @@
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>;
countByUserId(userId: bigint): Promise<number>;
}
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');

View File

@ -0,0 +1,12 @@
import { PlantingPosition } from '../aggregates/planting-position.aggregate';
export interface IPlantingPositionRepository {
save(position: PlantingPosition): Promise<void>;
findById(positionId: bigint): Promise<PlantingPosition | null>;
findByUserId(userId: bigint): Promise<PlantingPosition | null>;
getOrCreate(userId: bigint): Promise<PlantingPosition>;
}
export const PLANTING_POSITION_REPOSITORY = Symbol(
'IPlantingPositionRepository',
);

View File

@ -0,0 +1,16 @@
import { PoolInjectionBatch } from '../aggregates/pool-injection-batch.aggregate';
import { BatchStatus } from '../value-objects/batch-status.enum';
export interface IPoolInjectionBatchRepository {
save(batch: PoolInjectionBatch): Promise<void>;
findById(batchId: bigint): Promise<PoolInjectionBatch | null>;
findByBatchNo(batchNo: string): Promise<PoolInjectionBatch | null>;
findByStatus(status: BatchStatus): Promise<PoolInjectionBatch[]>;
findCurrentBatch(): Promise<PoolInjectionBatch | null>;
findOrCreateCurrentBatch(): Promise<PoolInjectionBatch>;
findScheduledBatchesReadyForInjection(): Promise<PoolInjectionBatch[]>;
}
export const POOL_INJECTION_BATCH_REPOSITORY = Symbol(
'IPoolInjectionBatchRepository',
);

View File

@ -0,0 +1,122 @@
import { FundAllocationDomainService } from './fund-allocation.service';
import { PlantingOrder } from '../aggregates/planting-order.aggregate';
import { FundAllocationTargetType } from '../value-objects/fund-allocation-target-type.enum';
describe('FundAllocationDomainService', () => {
let service: FundAllocationDomainService;
beforeEach(() => {
service = new FundAllocationDomainService();
});
describe('calculateAllocations', () => {
it('应该正确计算1棵树的资金分配', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
const context = {
referralChain: ['ref1', 'ref2'],
nearestProvinceAuth: 'province_auth_1',
nearestCityAuth: 'city_auth_1',
nearestCommunity: 'community_1',
};
const allocations = service.calculateAllocations(order, context);
expect(allocations.length).toBe(10);
// 验证各项金额
const costAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.COST_ACCOUNT,
);
expect(costAlloc?.amount).toBe(400);
const opAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.OPERATION_ACCOUNT,
);
expect(opAlloc?.amount).toBe(300);
const hqAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
);
expect(hqAlloc?.amount).toBe(9);
const referralAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.REFERRAL_RIGHTS,
);
expect(referralAlloc?.amount).toBe(500);
expect(referralAlloc?.targetAccountId).toBe('ref1');
const poolAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.RWAD_POOL,
);
expect(poolAlloc?.amount).toBe(800);
// 验证总额
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
expect(total).toBe(2199);
jest.useRealTimers();
});
it('应该正确计算5棵树的资金分配', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 5);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
const context = {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
};
const allocations = service.calculateAllocations(order, context);
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
expect(total).toBe(2199 * 5);
jest.useRealTimers();
});
it('应该在没有推荐人时使用总部社区', () => {
jest.useFakeTimers();
const order = PlantingOrder.create(BigInt(1), 1);
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
jest.advanceTimersByTime(5000);
order.confirmProvinceCity();
const context = {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
};
const allocations = service.calculateAllocations(order, context);
const referralAlloc = allocations.find(
(a) => a.targetType === FundAllocationTargetType.REFERRAL_RIGHTS,
);
expect(referralAlloc?.targetAccountId).toBe('SYSTEM_HEADQUARTERS_COMMUNITY');
jest.useRealTimers();
});
});
describe('getPoolInjectionAmount', () => {
it('应该返回正确的底池注入金额', () => {
expect(service.getPoolInjectionAmount(1)).toBe(800);
expect(service.getPoolInjectionAmount(5)).toBe(4000);
expect(service.getPoolInjectionAmount(10)).toBe(8000);
});
});
});

View File

@ -0,0 +1,153 @@
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';
export interface ReferralContext {
referralChain: string[];
nearestProvinceAuth: string | null;
nearestCityAuth: string | null;
nearestCommunity: string | null;
}
@Injectable()
export class FundAllocationDomainService {
/**
*
* 核心业务规则: 2199 USDT 10
*/
calculateAllocations(
order: PlantingOrder,
context: ReferralContext,
): FundAllocation[] {
const treeCount = order.treeCount.value;
const allocations: FundAllocation[] = [];
const selection = order.provinceCitySelection;
if (!selection) {
throw new Error('订单未选择省市,无法计算资金分配');
}
// 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,
context.referralChain.length > 0
? context.referralChain[0]
: 'SYSTEM_HEADQUARTERS_COMMUNITY',
{ referralChain: context.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,
context.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,
context.nearestCityAuth || 'SYSTEM_HEADQUARTERS_COMMUNITY',
),
);
// 9. 社区权益: 80 USDT/棵
allocations.push(
new FundAllocation(
FundAllocationTargetType.COMMUNITY_RIGHTS,
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.COMMUNITY_RIGHTS] *
treeCount,
context.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;
}
/**
*
*/
getPoolInjectionAmount(treeCount: number): number {
return FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.RWAD_POOL] * treeCount;
}
}

View File

@ -0,0 +1 @@
export * from './fund-allocation.service';

View File

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

View File

@ -0,0 +1,36 @@
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,
};
// 每棵树价格
export const PRICE_PER_TREE = 2199;
// 验证总额
const TOTAL = Object.values(FUND_ALLOCATION_AMOUNTS).reduce((a, b) => a + b, 0);
if (TOTAL !== PRICE_PER_TREE) {
throw new Error(`资金分配配置错误: 总额 ${TOTAL} != ${PRICE_PER_TREE}`);
}

View File

@ -0,0 +1,38 @@
import { FundAllocationTargetType } from './fund-allocation-target-type.enum';
export interface FundAllocationDTO {
targetType: FundAllocationTargetType;
amount: number;
targetAccountId: string | null;
metadata?: Record<string, unknown>;
}
export class FundAllocation {
constructor(
public readonly targetType: FundAllocationTargetType,
public readonly amount: number,
public readonly targetAccountId: string | null,
public readonly metadata?: Record<string, unknown>,
) {
if (amount < 0) {
throw new Error('分配金额不能为负数');
}
}
toDTO(): FundAllocationDTO {
return {
targetType: this.targetType,
amount: this.amount,
targetAccountId: this.targetAccountId,
metadata: this.metadata,
};
}
equals(other: FundAllocation): boolean {
return (
this.targetType === other.targetType &&
this.amount === other.amount &&
this.targetAccountId === other.targetAccountId
);
}
}

View File

@ -0,0 +1,7 @@
export * from './planting-order-status.enum';
export * from './fund-allocation-target-type.enum';
export * from './batch-status.enum';
export * from './tree-count.vo';
export * from './province-city-selection.vo';
export * from './fund-allocation.vo';
export * from './money.vo';

View File

@ -0,0 +1,59 @@
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string = 'USDT',
) {
if (amount < 0) {
throw new Error('金额不能为负数');
}
}
static create(amount: number, currency: string = 'USDT'): Money {
return new Money(amount, currency);
}
static zero(currency: string = 'USDT'): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.ensureSameCurrency(other);
if (this.amount < other.amount) {
throw new Error('余额不足');
}
return new Money(this.amount - other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other);
return this.amount > other.amount;
}
isLessThan(other: Money): boolean {
this.ensureSameCurrency(other);
return this.amount < other.amount;
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(`货币类型不匹配: ${this.currency} vs ${other.currency}`);
}
}
toString(): string {
return `${this.amount} ${this.currency}`;
}
}

View File

@ -0,0 +1,10 @@
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', // 已取消
}

View File

@ -0,0 +1,100 @@
import { ProvinceCitySelection } from './province-city-selection.vo';
describe('ProvinceCitySelection', () => {
describe('create', () => {
it('应该成功创建省市选择', () => {
const selection = ProvinceCitySelection.create(
'440000',
'广东省',
'440100',
'广州市',
);
expect(selection.provinceCode).toBe('440000');
expect(selection.provinceName).toBe('广东省');
expect(selection.cityCode).toBe('440100');
expect(selection.cityName).toBe('广州市');
expect(selection.isConfirmed).toBe(false);
});
it('应该拒绝空的省份代码', () => {
expect(() =>
ProvinceCitySelection.create('', '广东省', '440100', '广州市'),
).toThrow('省市代码不能为空');
});
it('应该拒绝空的城市代码', () => {
expect(() =>
ProvinceCitySelection.create('440000', '广东省', '', '广州市'),
).toThrow('省市代码不能为空');
});
});
describe('confirm', () => {
it('应该成功确认省市选择', () => {
const selection = ProvinceCitySelection.create(
'440000',
'广东省',
'440100',
'广州市',
);
// 模拟等待5秒
jest.useFakeTimers();
jest.advanceTimersByTime(5000);
const confirmed = selection.confirm();
expect(confirmed.isConfirmed).toBe(true);
jest.useRealTimers();
});
it('应该拒绝重复确认', () => {
const selection = ProvinceCitySelection.create(
'440000',
'广东省',
'440100',
'广州市',
);
jest.useFakeTimers();
jest.advanceTimersByTime(5000);
const confirmed = selection.confirm();
expect(() => confirmed.confirm()).toThrow('省市已确认,不可重复确认');
jest.useRealTimers();
});
});
describe('canConfirm', () => {
it('应该在5秒前返回false', () => {
const selection = ProvinceCitySelection.create(
'440000',
'广东省',
'440100',
'广州市',
);
expect(selection.canConfirm()).toBe(false);
});
it('应该在5秒后返回true', () => {
jest.useFakeTimers();
const selection = ProvinceCitySelection.create(
'440000',
'广东省',
'440100',
'广州市',
);
jest.advanceTimersByTime(5000);
expect(selection.canConfirm()).toBe(true);
jest.useRealTimers();
});
});
});

View File

@ -0,0 +1,83 @@
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 confirmedAt: Date | null,
) {}
get isConfirmed(): boolean {
return this.confirmedAt !== null;
}
static create(
provinceCode: string,
provinceName: string,
cityCode: string,
cityName: string,
): ProvinceCitySelection {
if (!provinceCode || !cityCode) {
throw new Error('省市代码不能为空');
}
return new ProvinceCitySelection(
provinceCode,
provinceName,
cityCode,
cityName,
new Date(),
null,
);
}
static reconstitute(
provinceCode: string,
provinceName: string,
cityCode: string,
cityName: string,
selectedAt: Date,
confirmedAt: Date | null,
): ProvinceCitySelection {
return new ProvinceCitySelection(
provinceCode,
provinceName,
cityCode,
cityName,
selectedAt,
confirmedAt,
);
}
confirm(): ProvinceCitySelection {
if (this.isConfirmed) {
throw new Error('省市已确认,不可重复确认');
}
return new ProvinceCitySelection(
this.provinceCode,
this.provinceName,
this.cityCode,
this.cityName,
this.selectedAt,
new Date(),
);
}
/**
* 5
*/
canConfirm(): boolean {
const elapsed = Date.now() - this.selectedAt.getTime();
return elapsed >= 5000; // 5秒
}
/**
*
*/
getRemainingWaitTime(): number {
const elapsed = Date.now() - this.selectedAt.getTime();
return Math.max(0, 5000 - elapsed);
}
}

View File

@ -0,0 +1,52 @@
import { TreeCount } from './tree-count.vo';
describe('TreeCount', () => {
describe('create', () => {
it('应该成功创建有效的树数量', () => {
const treeCount = TreeCount.create(5);
expect(treeCount.value).toBe(5);
});
it('应该拒绝0', () => {
expect(() => TreeCount.create(0)).toThrow('认种数量必须是正整数');
});
it('应该拒绝负数', () => {
expect(() => TreeCount.create(-1)).toThrow('认种数量必须是正整数');
});
it('应该拒绝小数', () => {
expect(() => TreeCount.create(1.5)).toThrow('认种数量必须是正整数');
});
});
describe('multiply', () => {
it('应该正确计算乘法', () => {
const treeCount = TreeCount.create(5);
expect(treeCount.multiply(2199)).toBe(10995);
});
});
describe('add', () => {
it('应该正确相加', () => {
const a = TreeCount.create(3);
const b = TreeCount.create(5);
const result = a.add(b);
expect(result.value).toBe(8);
});
});
describe('equals', () => {
it('应该正确比较相等', () => {
const a = TreeCount.create(5);
const b = TreeCount.create(5);
expect(a.equals(b)).toBe(true);
});
it('应该正确比较不相等', () => {
const a = TreeCount.create(3);
const b = TreeCount.create(5);
expect(a.equals(b)).toBe(false);
});
});
});

View File

@ -0,0 +1,23 @@
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;
}
add(other: TreeCount): TreeCount {
return new TreeCount(this.value + other.value);
}
equals(other: TreeCount): boolean {
return this.value === other.value;
}
}

View File

@ -0,0 +1,2 @@
export * from './wallet-service.client';
export * from './referral-service.client';

View File

@ -0,0 +1,73 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { ReferralContext } from '../../domain/services/fund-allocation.service';
export interface ReferralInfo {
userId: string;
referralChain: string[];
nearestProvinceAuth: string | null;
nearestCityAuth: string | null;
nearestCommunity: string | null;
}
@Injectable()
export class ReferralServiceClient {
private readonly logger = new Logger(ReferralServiceClient.name);
private readonly baseUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.baseUrl =
this.configService.get<string>('REFERRAL_SERVICE_URL') ||
'http://localhost:3004';
}
/**
*
*/
async getReferralContext(
userId: string,
provinceCode: string,
cityCode: string,
): Promise<ReferralContext> {
try {
const response = await firstValueFrom(
this.httpService.get<ReferralInfo>(
`${this.baseUrl}/api/v1/referrals/${userId}/context`,
{
params: { provinceCode, cityCode },
},
),
);
return {
referralChain: response.data.referralChain,
nearestProvinceAuth: response.data.nearestProvinceAuth,
nearestCityAuth: response.data.nearestCityAuth,
nearestCommunity: response.data.nearestCommunity,
};
} catch (error) {
this.logger.error(
`Failed to get referral context for user ${userId}`,
error,
);
// 在开发环境返回默认空数据
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn(
'Development mode: returning empty referral context',
);
return {
referralChain: [],
nearestProvinceAuth: null,
nearestCityAuth: null,
nearestCommunity: null,
};
}
throw error;
}
}
}

View File

@ -0,0 +1,144 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { FundAllocationDTO } from '../../domain/value-objects/fund-allocation.vo';
export interface DeductForPlantingRequest {
userId: string;
amount: number;
orderId: string;
}
export interface AllocateFundsRequest {
orderId: string;
allocations: FundAllocationDTO[];
}
export interface WalletBalance {
userId: string;
available: number;
locked: number;
currency: string;
}
@Injectable()
export class WalletServiceClient {
private readonly logger = new Logger(WalletServiceClient.name);
private readonly baseUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.baseUrl =
this.configService.get<string>('WALLET_SERVICE_URL') ||
'http://localhost:3002';
}
/**
*
*/
async getBalance(userId: string): Promise<WalletBalance> {
try {
const response = await firstValueFrom(
this.httpService.get<WalletBalance>(
`${this.baseUrl}/api/v1/wallets/${userId}/balance`,
),
);
return response.data;
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}`, error);
// 在开发环境返回模拟数据
if (this.configService.get('NODE_ENV') === 'development') {
return {
userId,
available: 100000,
locked: 0,
currency: 'USDT',
};
}
throw error;
}
}
/**
*
*/
async deductForPlanting(request: DeductForPlantingRequest): Promise<boolean> {
try {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/deduct-for-planting`,
request,
),
);
return response.data.success;
} catch (error) {
this.logger.error(
`Failed to deduct for planting: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful deduction');
return true;
}
throw error;
}
}
/**
*
*/
async allocateFunds(request: AllocateFundsRequest): Promise<boolean> {
try {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/allocate-funds`,
request,
),
);
return response.data.success;
} catch (error) {
this.logger.error(
`Failed to allocate funds for order: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful allocation');
return true;
}
throw error;
}
}
/**
*
*/
async injectToPool(
batchId: string,
amount: number,
): Promise<{ txHash: string }> {
try {
const response = await firstValueFrom(
this.httpService.post<{ txHash: string }>(
`${this.baseUrl}/api/v1/pool/inject`,
{ batchId, amount },
),
);
return response.data;
} catch (error) {
this.logger.error(`Failed to inject to pool: batch ${batchId}`, error);
// 在开发环境返回模拟交易哈希
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating pool injection');
return {
txHash: `0x${Date.now().toString(16)}${Math.random().toString(16).substring(2)}`,
};
}
throw error;
}
}
}

View File

@ -0,0 +1,5 @@
export * from './persistence/prisma/prisma.service';
export * from './persistence/repositories';
export * from './persistence/mappers';
export * from './external';
export * from './infrastructure.module';

View File

@ -0,0 +1,47 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PrismaService } from './persistence/prisma/prisma.service';
import { PlantingOrderRepositoryImpl } from './persistence/repositories/planting-order.repository.impl';
import { PlantingPositionRepositoryImpl } from './persistence/repositories/planting-position.repository.impl';
import { PoolInjectionBatchRepositoryImpl } from './persistence/repositories/pool-injection-batch.repository.impl';
import { WalletServiceClient } from './external/wallet-service.client';
import { ReferralServiceClient } from './external/referral-service.client';
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface';
import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface';
@Global()
@Module({
imports: [
HttpModule.register({
timeout: 5000,
maxRedirects: 5,
}),
],
providers: [
PrismaService,
{
provide: PLANTING_ORDER_REPOSITORY,
useClass: PlantingOrderRepositoryImpl,
},
{
provide: PLANTING_POSITION_REPOSITORY,
useClass: PlantingPositionRepositoryImpl,
},
{
provide: POOL_INJECTION_BATCH_REPOSITORY,
useClass: PoolInjectionBatchRepositoryImpl,
},
WalletServiceClient,
ReferralServiceClient,
],
exports: [
PrismaService,
PLANTING_ORDER_REPOSITORY,
PLANTING_POSITION_REPOSITORY,
POOL_INJECTION_BATCH_REPOSITORY,
WalletServiceClient,
ReferralServiceClient,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,3 @@
export * from './planting-order.mapper';
export * from './planting-position.mapper';
export * from './pool-injection-batch.mapper';

View File

@ -0,0 +1,115 @@
import {
PlantingOrder as PrismaPlantingOrder,
FundAllocation as PrismaFundAllocation,
Prisma,
} from '@prisma/client';
import {
PlantingOrder,
PlantingOrderData,
} from '../../../domain/aggregates/planting-order.aggregate';
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
import { FundAllocation } from '../../../domain/value-objects/fund-allocation.vo';
import { FundAllocationTargetType } from '../../../domain/value-objects/fund-allocation-target-type.enum';
type PlantingOrderWithAllocations = PrismaPlantingOrder & {
fundAllocations?: PrismaFundAllocation[];
};
export class PlantingOrderMapper {
static toDomain(prismaOrder: PlantingOrderWithAllocations): PlantingOrder {
const data: PlantingOrderData = {
id: prismaOrder.id,
orderNo: prismaOrder.orderNo,
userId: prismaOrder.userId,
treeCount: prismaOrder.treeCount,
totalAmount: Number(prismaOrder.totalAmount),
status: prismaOrder.status as PlantingOrderStatus,
selectedProvince: prismaOrder.selectedProvince,
selectedCity: prismaOrder.selectedCity,
provinceCitySelectedAt: prismaOrder.provinceCitySelectedAt,
provinceCityConfirmedAt: prismaOrder.provinceCityConfirmedAt,
poolInjectionBatchId: prismaOrder.poolInjectionBatchId,
poolInjectionScheduledTime: prismaOrder.poolInjectionScheduledTime,
poolInjectionActualTime: prismaOrder.poolInjectionActualTime,
poolInjectionTxHash: prismaOrder.poolInjectionTxHash,
miningEnabledAt: prismaOrder.miningEnabledAt,
createdAt: prismaOrder.createdAt,
paidAt: prismaOrder.paidAt,
fundAllocatedAt: prismaOrder.fundAllocatedAt,
};
return PlantingOrder.reconstitute(data);
}
static toPersistence(order: PlantingOrder): {
orderData: {
id?: bigint;
orderNo: string;
userId: bigint;
treeCount: number;
totalAmount: Prisma.Decimal;
selectedProvince: string | null;
selectedCity: string | null;
provinceCitySelectedAt: Date | null;
provinceCityConfirmedAt: Date | null;
status: string;
poolInjectionBatchId: bigint | null;
poolInjectionScheduledTime: Date | null;
poolInjectionActualTime: Date | null;
poolInjectionTxHash: string | null;
miningEnabledAt: Date | null;
createdAt: Date;
paidAt: Date | null;
fundAllocatedAt: Date | null;
};
allocations: {
orderId: bigint;
targetType: string;
amount: Prisma.Decimal;
targetAccountId: string | null;
metadata: Prisma.InputJsonValue | null;
}[];
} {
const data = order.toPersistence();
const orderData = {
id: data.id,
orderNo: data.orderNo,
userId: data.userId,
treeCount: data.treeCount,
totalAmount: new Prisma.Decimal(data.totalAmount),
selectedProvince: data.selectedProvince || null,
selectedCity: data.selectedCity || null,
provinceCitySelectedAt: data.provinceCitySelectedAt || null,
provinceCityConfirmedAt: data.provinceCityConfirmedAt || null,
status: data.status,
poolInjectionBatchId: data.poolInjectionBatchId || null,
poolInjectionScheduledTime: data.poolInjectionScheduledTime || null,
poolInjectionActualTime: data.poolInjectionActualTime || null,
poolInjectionTxHash: data.poolInjectionTxHash || null,
miningEnabledAt: data.miningEnabledAt || null,
createdAt: data.createdAt || new Date(),
paidAt: data.paidAt || null,
fundAllocatedAt: data.fundAllocatedAt || null,
};
const allocations = order.fundAllocations.map((a) => ({
orderId: data.id!,
targetType: a.targetType,
amount: new Prisma.Decimal(a.amount),
targetAccountId: a.targetAccountId,
metadata: (a.metadata as Prisma.InputJsonValue) || null,
}));
return { orderData, allocations };
}
static mapFundAllocation(prisma: PrismaFundAllocation): FundAllocation {
return new FundAllocation(
prisma.targetType as FundAllocationTargetType,
Number(prisma.amount),
prisma.targetAccountId,
prisma.metadata as Record<string, unknown> | undefined,
);
}
}

View File

@ -0,0 +1,67 @@
import {
PlantingPosition as PrismaPlantingPosition,
PositionDistribution as PrismaPositionDistribution,
} from '@prisma/client';
import {
PlantingPosition,
PlantingPositionData,
} from '../../../domain/aggregates/planting-position.aggregate';
type PlantingPositionWithDistributions = PrismaPlantingPosition & {
distributions?: PrismaPositionDistribution[];
};
export class PlantingPositionMapper {
static toDomain(
prismaPosition: PlantingPositionWithDistributions,
): PlantingPosition {
const data: PlantingPositionData = {
id: prismaPosition.id,
userId: prismaPosition.userId,
totalTreeCount: prismaPosition.totalTreeCount,
effectiveTreeCount: prismaPosition.effectiveTreeCount,
pendingTreeCount: prismaPosition.pendingTreeCount,
firstMiningStartAt: prismaPosition.firstMiningStartAt,
distributions: prismaPosition.distributions?.map((d) => ({
id: d.id,
userId: d.userId,
provinceCode: d.provinceCode,
cityCode: d.cityCode,
treeCount: d.treeCount,
})),
};
return PlantingPosition.reconstitute(data);
}
static toPersistence(position: PlantingPosition): {
positionData: Omit<PrismaPlantingPosition, 'id' | 'createdAt' | 'updatedAt'> & {
id?: bigint;
};
distributions: Omit<
PrismaPositionDistribution,
'id' | 'createdAt' | 'updatedAt'
>[];
} {
const data = position.toPersistence();
const positionData = {
id: data.id,
userId: data.userId,
totalTreeCount: data.totalTreeCount,
effectiveTreeCount: data.effectiveTreeCount,
pendingTreeCount: data.pendingTreeCount,
firstMiningStartAt: data.firstMiningStartAt || null,
};
const distributions =
data.distributions?.map((d) => ({
userId: d.userId,
provinceCode: d.provinceCode,
cityCode: d.cityCode,
treeCount: d.treeCount,
})) || [];
return { positionData, distributions };
}
}

View File

@ -0,0 +1,53 @@
import { PoolInjectionBatch as PrismaPoolInjectionBatch, Prisma } from '@prisma/client';
import {
PoolInjectionBatch,
PoolInjectionBatchData,
} from '../../../domain/aggregates/pool-injection-batch.aggregate';
import { BatchStatus } from '../../../domain/value-objects/batch-status.enum';
export class PoolInjectionBatchMapper {
static toDomain(prismaBatch: PrismaPoolInjectionBatch): PoolInjectionBatch {
const data: PoolInjectionBatchData = {
id: prismaBatch.id,
batchNo: prismaBatch.batchNo,
startDate: prismaBatch.startDate,
endDate: prismaBatch.endDate,
orderCount: prismaBatch.orderCount,
totalAmount: Number(prismaBatch.totalAmount),
status: prismaBatch.status as BatchStatus,
scheduledInjectionTime: prismaBatch.scheduledInjectionTime,
actualInjectionTime: prismaBatch.actualInjectionTime,
injectionTxHash: prismaBatch.injectionTxHash,
};
return PoolInjectionBatch.reconstitute(data);
}
static toPersistence(batch: PoolInjectionBatch): {
id?: bigint;
batchNo: string;
startDate: Date;
endDate: Date;
orderCount: number;
totalAmount: Prisma.Decimal;
status: string;
scheduledInjectionTime: Date | null;
actualInjectionTime: Date | null;
injectionTxHash: string | null;
} {
const data = batch.toPersistence();
return {
id: data.id,
batchNo: data.batchNo,
startDate: data.startDate,
endDate: data.endDate,
orderCount: data.orderCount,
totalAmount: new Prisma.Decimal(data.totalAmount),
status: data.status,
scheduledInjectionTime: data.scheduledInjectionTime || null,
actualInjectionTime: data.actualInjectionTime || null,
injectionTxHash: data.injectionTxHash || null,
};
}
}

View File

@ -0,0 +1,43 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super({
log:
process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
async cleanDatabase() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('cleanDatabase can only be used in test environment');
}
const tablenames = await this.$queryRaw<
Array<{ tablename: string }>
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await this.$executeRawUnsafe(
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`,
);
}
}
}
}

View File

@ -0,0 +1,3 @@
export * from './planting-order.repository.impl';
export * from './planting-position.repository.impl';
export * from './pool-injection-batch.repository.impl';

View File

@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { IPlantingOrderRepository } from '../../../domain/repositories/planting-order.repository.interface';
import { PlantingOrder } from '../../../domain/aggregates/planting-order.aggregate';
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
import { PlantingOrderMapper } from '../mappers/planting-order.mapper';
@Injectable()
export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository {
constructor(private readonly prisma: PrismaService) {}
async save(order: PlantingOrder): Promise<void> {
const { orderData, allocations } =
PlantingOrderMapper.toPersistence(order);
if (order.id) {
// 更新
await this.prisma.plantingOrder.update({
where: { id: order.id },
data: {
status: orderData.status,
selectedProvince: orderData.selectedProvince,
selectedCity: orderData.selectedCity,
provinceCitySelectedAt: orderData.provinceCitySelectedAt,
provinceCityConfirmedAt: orderData.provinceCityConfirmedAt,
poolInjectionBatchId: orderData.poolInjectionBatchId,
poolInjectionScheduledTime: orderData.poolInjectionScheduledTime,
poolInjectionActualTime: orderData.poolInjectionActualTime,
poolInjectionTxHash: orderData.poolInjectionTxHash,
miningEnabledAt: orderData.miningEnabledAt,
paidAt: orderData.paidAt,
fundAllocatedAt: orderData.fundAllocatedAt,
},
});
// 如果有新的资金分配,插入
if (allocations.length > 0) {
const existingAllocations = await this.prisma.fundAllocation.count({
where: { orderId: order.id },
});
if (existingAllocations === 0) {
await this.prisma.fundAllocation.createMany({
data: allocations.map((a) => ({
orderId: order.id!,
targetType: a.targetType,
amount: a.amount,
targetAccountId: a.targetAccountId,
metadata: a.metadata ?? Prisma.DbNull,
})),
});
}
}
} else {
// 创建
const created = await this.prisma.plantingOrder.create({
data: {
orderNo: orderData.orderNo,
userId: orderData.userId,
treeCount: orderData.treeCount,
totalAmount: orderData.totalAmount,
status: orderData.status,
selectedProvince: orderData.selectedProvince,
selectedCity: orderData.selectedCity,
provinceCitySelectedAt: orderData.provinceCitySelectedAt,
provinceCityConfirmedAt: orderData.provinceCityConfirmedAt,
poolInjectionBatchId: orderData.poolInjectionBatchId,
poolInjectionScheduledTime: orderData.poolInjectionScheduledTime,
poolInjectionActualTime: orderData.poolInjectionActualTime,
poolInjectionTxHash: orderData.poolInjectionTxHash,
miningEnabledAt: orderData.miningEnabledAt,
paidAt: orderData.paidAt,
fundAllocatedAt: orderData.fundAllocatedAt,
},
});
order.setId(created.id);
}
}
async findById(orderId: bigint): Promise<PlantingOrder | null> {
const order = await this.prisma.plantingOrder.findUnique({
where: { id: orderId },
include: { fundAllocations: true },
});
return order ? PlantingOrderMapper.toDomain(order) : null;
}
async findByOrderNo(orderNo: string): Promise<PlantingOrder | null> {
const order = await this.prisma.plantingOrder.findUnique({
where: { orderNo },
include: { fundAllocations: true },
});
return order ? PlantingOrderMapper.toDomain(order) : null;
}
async findByUserId(
userId: bigint,
page: number = 1,
pageSize: number = 10,
): Promise<PlantingOrder[]> {
const orders = await this.prisma.plantingOrder.findMany({
where: { userId },
include: { fundAllocations: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return orders.map(PlantingOrderMapper.toDomain);
}
async findByStatus(
status: PlantingOrderStatus,
limit: number = 100,
): Promise<PlantingOrder[]> {
const orders = await this.prisma.plantingOrder.findMany({
where: { status },
include: { fundAllocations: true },
orderBy: { createdAt: 'asc' },
take: limit,
});
return orders.map(PlantingOrderMapper.toDomain);
}
async findPendingPoolScheduling(): Promise<PlantingOrder[]> {
const orders = await this.prisma.plantingOrder.findMany({
where: {
status: PlantingOrderStatus.FUND_ALLOCATED,
poolInjectionBatchId: null,
},
include: { fundAllocations: true },
orderBy: { createdAt: 'asc' },
});
return orders.map(PlantingOrderMapper.toDomain);
}
async findByBatchId(batchId: bigint): Promise<PlantingOrder[]> {
const orders = await this.prisma.plantingOrder.findMany({
where: { poolInjectionBatchId: batchId },
include: { fundAllocations: true },
orderBy: { createdAt: 'asc' },
});
return orders.map(PlantingOrderMapper.toDomain);
}
async findReadyForMining(): Promise<PlantingOrder[]> {
const orders = await this.prisma.plantingOrder.findMany({
where: {
status: PlantingOrderStatus.POOL_INJECTED,
miningEnabledAt: null,
},
include: { fundAllocations: true },
orderBy: { createdAt: 'asc' },
});
return orders.map(PlantingOrderMapper.toDomain);
}
async countTreesByUserId(userId: bigint): Promise<number> {
const result = await this.prisma.plantingOrder.aggregate({
where: {
userId,
status: {
notIn: [PlantingOrderStatus.CANCELLED],
},
},
_sum: {
treeCount: true,
},
});
return result._sum.treeCount || 0;
}
async countByUserId(userId: bigint): Promise<number> {
return this.prisma.plantingOrder.count({
where: {
userId,
status: {
notIn: [PlantingOrderStatus.CANCELLED],
},
},
});
}
}

View File

@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { IPlantingPositionRepository } from '../../../domain/repositories/planting-position.repository.interface';
import { PlantingPosition } from '../../../domain/aggregates/planting-position.aggregate';
import { PlantingPositionMapper } from '../mappers/planting-position.mapper';
@Injectable()
export class PlantingPositionRepositoryImpl
implements IPlantingPositionRepository
{
constructor(private readonly prisma: PrismaService) {}
async save(position: PlantingPosition): Promise<void> {
const { positionData, distributions } =
PlantingPositionMapper.toPersistence(position);
if (position.id) {
// 更新持仓
await this.prisma.plantingPosition.update({
where: { id: position.id },
data: {
totalTreeCount: positionData.totalTreeCount,
effectiveTreeCount: positionData.effectiveTreeCount,
pendingTreeCount: positionData.pendingTreeCount,
firstMiningStartAt: positionData.firstMiningStartAt,
},
});
// 更新分布
for (const dist of distributions) {
await this.prisma.positionDistribution.upsert({
where: {
userId_provinceCode_cityCode: {
userId: dist.userId,
provinceCode: dist.provinceCode ?? '',
cityCode: dist.cityCode ?? '',
},
},
update: {
treeCount: dist.treeCount,
},
create: {
userId: dist.userId,
provinceCode: dist.provinceCode,
cityCode: dist.cityCode,
treeCount: dist.treeCount,
},
});
}
} else {
// 创建
const created = await this.prisma.plantingPosition.create({
data: {
userId: positionData.userId,
totalTreeCount: positionData.totalTreeCount,
effectiveTreeCount: positionData.effectiveTreeCount,
pendingTreeCount: positionData.pendingTreeCount,
firstMiningStartAt: positionData.firstMiningStartAt,
},
});
position.setId(created.id);
// 创建分布
if (distributions.length > 0) {
await this.prisma.positionDistribution.createMany({
data: distributions,
});
}
}
}
async findById(positionId: bigint): Promise<PlantingPosition | null> {
const position = await this.prisma.plantingPosition.findUnique({
where: { id: positionId },
include: { distributions: true },
});
return position ? PlantingPositionMapper.toDomain(position) : null;
}
async findByUserId(userId: bigint): Promise<PlantingPosition | null> {
const position = await this.prisma.plantingPosition.findUnique({
where: { userId },
include: { distributions: true },
});
return position ? PlantingPositionMapper.toDomain(position) : null;
}
async getOrCreate(userId: bigint): Promise<PlantingPosition> {
let position = await this.findByUserId(userId);
if (!position) {
position = PlantingPosition.create(userId);
await this.save(position);
}
return position;
}
}

View File

@ -0,0 +1,114 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { IPoolInjectionBatchRepository } from '../../../domain/repositories/pool-injection-batch.repository.interface';
import { PoolInjectionBatch } from '../../../domain/aggregates/pool-injection-batch.aggregate';
import { BatchStatus } from '../../../domain/value-objects/batch-status.enum';
import { PoolInjectionBatchMapper } from '../mappers/pool-injection-batch.mapper';
@Injectable()
export class PoolInjectionBatchRepositoryImpl
implements IPoolInjectionBatchRepository
{
constructor(private readonly prisma: PrismaService) {}
async save(batch: PoolInjectionBatch): Promise<void> {
const data = PoolInjectionBatchMapper.toPersistence(batch);
if (batch.id) {
// 更新
await this.prisma.poolInjectionBatch.update({
where: { id: batch.id },
data: {
orderCount: data.orderCount,
totalAmount: data.totalAmount,
status: data.status,
scheduledInjectionTime: data.scheduledInjectionTime,
actualInjectionTime: data.actualInjectionTime,
injectionTxHash: data.injectionTxHash,
},
});
} else {
// 创建
const created = await this.prisma.poolInjectionBatch.create({
data: {
batchNo: data.batchNo,
startDate: data.startDate,
endDate: data.endDate,
orderCount: data.orderCount,
totalAmount: data.totalAmount,
status: data.status,
scheduledInjectionTime: data.scheduledInjectionTime,
actualInjectionTime: data.actualInjectionTime,
injectionTxHash: data.injectionTxHash,
},
});
batch.setId(created.id);
}
}
async findById(batchId: bigint): Promise<PoolInjectionBatch | null> {
const batch = await this.prisma.poolInjectionBatch.findUnique({
where: { id: batchId },
});
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
}
async findByBatchNo(batchNo: string): Promise<PoolInjectionBatch | null> {
const batch = await this.prisma.poolInjectionBatch.findUnique({
where: { batchNo },
});
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
}
async findByStatus(status: BatchStatus): Promise<PoolInjectionBatch[]> {
const batches = await this.prisma.poolInjectionBatch.findMany({
where: { status },
orderBy: { startDate: 'asc' },
});
return batches.map(PoolInjectionBatchMapper.toDomain);
}
async findCurrentBatch(): Promise<PoolInjectionBatch | null> {
const now = new Date();
const batch = await this.prisma.poolInjectionBatch.findFirst({
where: {
status: BatchStatus.PENDING,
startDate: { lte: now },
endDate: { gte: now },
},
orderBy: { startDate: 'desc' },
});
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
}
async findOrCreateCurrentBatch(): Promise<PoolInjectionBatch> {
let batch = await this.findCurrentBatch();
if (!batch) {
const today = new Date();
today.setHours(0, 0, 0, 0);
batch = PoolInjectionBatch.create(today);
await this.save(batch);
}
return batch;
}
async findScheduledBatchesReadyForInjection(): Promise<PoolInjectionBatch[]> {
const now = new Date();
const batches = await this.prisma.poolInjectionBatch.findMany({
where: {
status: BatchStatus.SCHEDULED,
scheduledInjectionTime: { lte: now },
},
orderBy: { scheduledInjectionTime: 'asc' },
});
return batches.map(PoolInjectionBatchMapper.toDomain);
}
}

View File

@ -0,0 +1,51 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
// 全局前缀
app.setGlobalPrefix('api/v1');
// 全局验证管道
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// CORS 配置
app.enableCors({
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
});
// Swagger API 文档
const config = new DocumentBuilder()
.setTitle('Planting Service API')
.setDescription('RWA 榴莲女皇平台认种服务 API')
.setVersion('1.0.0')
.addBearerAuth()
.addTag('认种订单', '认种订单相关接口')
.addTag('认种持仓', '认种持仓相关接口')
.addTag('健康检查', '服务健康检查接口')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.APP_PORT || 3003;
await app.listen(port);
logger.log(`Planting Service is running on port ${port}`);
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,40 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
}
response.status(status).json({
statusCode: status,
message: Array.isArray(message) ? message : [message],
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1 @@
export * from './global-exception.filter';

View File

@ -0,0 +1 @@
export * from './filters';

View File

@ -0,0 +1,372 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
import * as request from 'supertest';
import { HealthController } from '../src/api/controllers/health.controller';
import { PlantingOrderController } from '../src/api/controllers/planting-order.controller';
import { PlantingPositionController } from '../src/api/controllers/planting-position.controller';
import { PlantingApplicationService } from '../src/application/services/planting-application.service';
import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard';
import { ConfigModule } from '@nestjs/config';
// Mock for PlantingApplicationService
const mockPlantingService = {
createOrder: jest.fn(),
selectProvinceCity: jest.fn(),
confirmProvinceCity: jest.fn(),
payOrder: jest.fn(),
getUserOrders: jest.fn(),
getOrderDetail: jest.fn(),
getUserPosition: jest.fn(),
cancelOrder: jest.fn(),
};
// Mock JwtAuthGuard that always rejects unauthorized requests
const mockJwtAuthGuardReject = {
canActivate: jest.fn().mockReturnValue(false),
};
describe('PlantingController (e2e) - Unauthorized', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env.test',
}),
],
controllers: [HealthController, PlantingOrderController, PlantingPositionController],
providers: [
{
provide: PlantingApplicationService,
useValue: mockPlantingService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockJwtAuthGuardReject)
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('/health (GET)', () => {
it('应该返回健康状态', () => {
return request(app.getHttpServer())
.get('/api/v1/health')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ok');
expect(res.body.service).toBe('planting-service');
});
});
});
describe('/health/ready (GET)', () => {
it('应该返回就绪状态', () => {
return request(app.getHttpServer())
.get('/api/v1/health/ready')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ready');
});
});
});
describe('/planting/orders (POST)', () => {
it('应该拒绝未认证的请求', () => {
return request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 1 })
.expect(403);
});
});
describe('/planting/orders (GET)', () => {
it('应该拒绝未认证的请求', () => {
return request(app.getHttpServer())
.get('/api/v1/planting/orders')
.expect(403);
});
});
describe('/planting/position (GET)', () => {
it('应该拒绝未认证的请求', () => {
return request(app.getHttpServer())
.get('/api/v1/planting/position')
.expect(403);
});
});
});
describe('PlantingController (e2e) - Authorized', () => {
let app: INestApplication;
const mockUser = { id: '1', username: 'testuser' };
beforeAll(async () => {
// Reset mocks
jest.clearAllMocks();
const mockJwtAuthGuardAccept = {
canActivate: (context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
req.user = mockUser;
return true;
},
};
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env.test',
}),
],
controllers: [HealthController, PlantingOrderController, PlantingPositionController],
providers: [
{
provide: PlantingApplicationService,
useValue: mockPlantingService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockJwtAuthGuardAccept)
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('/planting/orders (POST)', () => {
it('应该成功创建订单', async () => {
const mockOrder = {
orderNo: 'PO202411300001',
userId: '1',
treeCount: 5,
totalAmount: 10995,
status: 'CREATED',
createdAt: new Date().toISOString(),
};
mockPlantingService.createOrder.mockResolvedValue(mockOrder);
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 5 })
.expect(201);
expect(response.body.orderNo).toBe('PO202411300001');
expect(response.body.treeCount).toBe(5);
});
it('应该验证treeCount必须为正整数', async () => {
await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 0 })
.expect(400);
await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: -1 })
.expect(400);
});
it('应该验证treeCount不能超过最大限制', async () => {
await request(app.getHttpServer())
.post('/api/v1/planting/orders')
.send({ treeCount: 1001 })
.expect(400);
});
});
describe('/planting/orders (GET)', () => {
it('应该返回用户订单列表', async () => {
const mockOrders = [
{
orderNo: 'PO202411300001',
treeCount: 5,
totalAmount: 10995,
status: 'CREATED',
},
{
orderNo: 'PO202411300002',
treeCount: 3,
totalAmount: 6597,
status: 'PAID',
},
];
mockPlantingService.getUserOrders.mockResolvedValue(mockOrders);
const response = await request(app.getHttpServer())
.get('/api/v1/planting/orders')
.query({ page: 1, pageSize: 10 })
.expect(200);
expect(response.body).toHaveLength(2);
});
});
describe('/planting/orders/:orderNo (GET)', () => {
it('应该返回订单详情', async () => {
const mockOrder = {
orderNo: 'PO202411300001',
treeCount: 5,
totalAmount: 10995,
status: 'CREATED',
};
mockPlantingService.getOrderDetail.mockResolvedValue(mockOrder);
const response = await request(app.getHttpServer())
.get('/api/v1/planting/orders/PO202411300001')
.expect(200);
expect(response.body.orderNo).toBe('PO202411300001');
});
it('应该返回404当订单不存在', async () => {
mockPlantingService.getOrderDetail.mockResolvedValue(null);
await request(app.getHttpServer())
.get('/api/v1/planting/orders/NONEXISTENT')
.expect(404);
});
});
describe('/planting/position (GET)', () => {
it('应该返回用户持仓信息', async () => {
const mockPosition = {
totalTreeCount: 10,
effectiveTreeCount: 8,
pendingTreeCount: 2,
distributions: [
{ provinceCode: '440000', cityCode: '440100', treeCount: 10 },
],
};
mockPlantingService.getUserPosition.mockResolvedValue(mockPosition);
const response = await request(app.getHttpServer())
.get('/api/v1/planting/position')
.expect(200);
expect(response.body.totalTreeCount).toBe(10);
expect(response.body.distributions).toHaveLength(1);
});
});
describe('/planting/orders/:orderNo/select-province-city (POST)', () => {
it('应该成功选择省市', async () => {
mockPlantingService.selectProvinceCity.mockResolvedValue({
success: true,
message: '省市选择成功',
});
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders/PO202411300001/select-province-city')
.send({
provinceCode: '440000',
provinceName: '广东省',
cityCode: '440100',
cityName: '广州市',
})
.expect(200);
expect(response.body.success).toBe(true);
});
it('应该验证省市参数', async () => {
await request(app.getHttpServer())
.post('/api/v1/planting/orders/PO202411300001/select-province-city')
.send({
provinceCode: '',
provinceName: '广东省',
})
.expect(400);
});
});
describe('/planting/orders/:orderNo/confirm-province-city (POST)', () => {
it('应该成功确认省市', async () => {
mockPlantingService.confirmProvinceCity.mockResolvedValue({
success: true,
message: '省市确认成功',
});
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders/PO202411300001/confirm-province-city')
.expect(200);
expect(response.body.success).toBe(true);
});
});
describe('/planting/orders/:orderNo/pay (POST)', () => {
it('应该成功支付订单', async () => {
const mockPayResult = {
orderNo: 'PO202411300001',
status: 'POOL_SCHEDULED',
allocations: [
{ targetType: 'POOL', amount: 1979.1 },
{ targetType: 'OPERATION', amount: 109.95 },
],
};
mockPlantingService.payOrder.mockResolvedValue(mockPayResult);
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders/PO202411300001/pay')
.expect(200);
expect(response.body.status).toBe('POOL_SCHEDULED');
expect(response.body.allocations).toHaveLength(2);
});
});
describe('/planting/orders/:orderNo/cancel (POST)', () => {
it('应该成功取消订单', async () => {
mockPlantingService.cancelOrder.mockResolvedValue({
success: true,
message: '订单取消成功',
});
const response = await request(app.getHttpServer())
.post('/api/v1/planting/orders/PO202411300001/cancel')
.expect(200);
expect(response.body.success).toBe(true);
});
});
});

View File

@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/../src/$1"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}